openai_client = $openai_client;
}
public function supported_post_types() {
$post_types = array('page', 'post');
if (post_type_exists('product')) {
$post_types[] = 'product';
}
$post_types = apply_filters('aiseo_supported_post_types', $post_types);
$post_types = array_values(array_unique(array_filter(array_map('sanitize_key', (array) $post_types))));
return array_values(array_filter($post_types, 'post_type_exists'));
}
public function excluded_scan_slugs() {
static $cached_slugs = null;
if (null !== $cached_slugs) {
return $cached_slugs;
}
$slugs = array('checkout', 'cart', 'wishlist', 'compare');
$slugs = apply_filters('aiseo_excluded_scan_slugs', $slugs);
$slugs = array_values(array_unique(array_filter(array_map('sanitize_title', (array) $slugs))));
$cached_slugs = $slugs;
return $cached_slugs;
}
public function excluded_scan_post_ids() {
static $cached_ids = null;
if (null !== $cached_ids) {
return $cached_ids;
}
$slugs = $this->excluded_scan_slugs();
$ids = array();
if ($slugs) {
$ids = get_posts(
array(
'post_type' => $this->supported_post_types(),
'post_status' => 'any',
'fields' => 'ids',
'posts_per_page' => -1,
'post_name__in' => $slugs,
'no_found_rows' => true,
)
);
}
if (function_exists('wc_get_page_id')) {
foreach (array('cart', 'checkout') as $woocommerce_page) {
$page_id = (int) wc_get_page_id($woocommerce_page);
if ($page_id > 0) {
$ids[] = $page_id;
}
}
}
$ids = apply_filters('aiseo_excluded_scan_post_ids', $ids, $slugs);
$cached_ids = array_values(array_unique(array_filter(array_map('absint', (array) $ids))));
return $cached_ids;
}
public function is_scannable_post($post) {
if (!$post instanceof WP_Post) {
$post = get_post($post);
}
if (!$post || 'publish' !== $post->post_status || !in_array($post->post_type, $this->supported_post_types(), true)) {
return false;
}
if (in_array(sanitize_title($post->post_name), $this->excluded_scan_slugs(), true)) {
return false;
}
if (in_array((int) $post->ID, $this->excluded_scan_post_ids(), true)) {
return false;
}
return (bool) apply_filters('aiseo_is_scannable_post', true, $post);
}
public function scan_post($post_id, $should_cancel = null) {
$post_id = absint($post_id);
$post = get_post($post_id);
if (!$this->is_scannable_post($post)) {
return new WP_Error('aiseo_invalid_post', __('This item cannot be scanned.', 'aivisibility-seo-geo-agent'));
}
$url = get_permalink($post);
if (!$url) {
return new WP_Error('aiseo_missing_permalink', __('Could not resolve the permalink for this item.', 'aivisibility-seo-geo-agent'));
}
$page_data = $this->extract_database_page_data($post, $url);
$live_report = $this->extract_server_html_data($post, $url);
if (is_wp_error($live_report)) {
return $live_report;
}
$page_data = $this->merge_database_and_live_data($page_data, $live_report);
$local_report = $this->build_local_report($page_data);
$ai_payload = $this->build_ai_payload($post, $url, $page_data);
$ai_report = $this->openai_client->analyze($ai_payload);
if (is_callable($should_cancel) && call_user_func($should_cancel)) {
return new WP_Error('aiseo_scan_stopped', __('Scan was stopped before the AI report was saved.', 'aivisibility-seo-geo-agent'));
}
$ai_error = '';
if (is_wp_error($ai_report)) {
$ai_error = $ai_report->get_error_message();
$ai_report = null;
}
$score = is_array($ai_report) && isset($ai_report['score']) ? (int) $ai_report['score'] : (int) $local_report['score'];
$scan_time = current_time('mysql');
$report = array(
'post_id' => $post_id,
'post_type' => $post->post_type,
'title' => get_the_title($post),
'edit_url' => get_edit_post_link($post_id, ''),
'url' => $url,
'score' => $score,
'scanned_at' => $scan_time,
'scan_source' => isset($page_data['source']) ? $page_data['source'] : 'database',
'source_label' => isset($page_data['source_label']) ? $page_data['source_label'] : __('WordPress database', 'aivisibility-seo-geo-agent'),
'live_html_status' => isset($page_data['live_html_status']) ? $page_data['live_html_status'] : 'disabled',
'live_html_error' => isset($page_data['live_html_error']) ? $page_data['live_html_error'] : '',
'local' => $page_data,
'local_report' => $local_report,
'ai_report' => $ai_report,
'ai_error' => $ai_error,
'model' => $this->openai_client->model(),
);
if (is_callable($should_cancel) && call_user_func($should_cancel)) {
return new WP_Error('aiseo_scan_stopped', __('Scan was stopped before the AI report was saved.', 'aivisibility-seo-geo-agent'));
}
update_post_meta($post_id, '_aiseo_report', $report);
update_post_meta($post_id, '_aiseo_score', $score);
update_post_meta($post_id, '_aiseo_scanned_at', $scan_time);
return $report;
}
public function get_report($post_id) {
$report = get_post_meta(absint($post_id), '_aiseo_report', true);
return is_array($report) ? $report : array();
}
public function test_curl_source($post_id) {
$post_id = absint($post_id);
$post = get_post($post_id);
if (!$this->is_scannable_post($post)) {
return new WP_Error('aiseo_invalid_post', __('This item cannot be scanned.', 'aivisibility-seo-geo-agent'));
}
$url = get_permalink($post);
if (!$url) {
return new WP_Error('aiseo_missing_permalink', __('Could not resolve the permalink for this item.', 'aivisibility-seo-geo-agent'));
}
$source = $this->extract_server_html_data($post, $url);
if (is_wp_error($source)) {
return $source;
}
return array(
'post_id' => $post_id,
'url' => $url,
'status' => 'ok',
'source_label' => isset($source['source_label']) ? $source['source_label'] : __('Server-side cURL page source', 'aivisibility-seo-geo-agent'),
'live_html_status' => isset($source['live_html_status']) ? $source['live_html_status'] : 'server_html',
'title' => isset($source['title']) ? $source['title'] : '',
'word_count' => isset($source['word_count']) ? (int) $source['word_count'] : 0,
'meta_tags' => !empty($source['meta_tags']) && is_array($source['meta_tags']) ? count($source['meta_tags']) : 0,
'json_ld_count' => isset($source['json_ld_count']) ? (int) $source['json_ld_count'] : 0,
'images_total' => !empty($source['images']['total']) ? (int) $source['images']['total'] : 0,
);
}
public function build_strategy_autofill_payload($requested_mode = '') {
$requested_mode = sanitize_key((string) $requested_mode);
if (!in_array($requested_mode, array('local', 'generic', 'mixed'), true)) {
$requested_mode = sanitize_key((string) get_option('aiseo_geo_mode', 'mixed'));
}
if (!in_array($requested_mode, array('local', 'generic', 'mixed'), true)) {
$requested_mode = 'mixed';
}
$url = home_url('/');
$post = $this->homepage_post();
$page_data = array();
$page = array(
'id' => 0,
'type' => 'homepage',
'wordpress_title' => get_bloginfo('name'),
'slug' => '',
'url' => $url,
);
if ($post instanceof WP_Post) {
$page = array(
'id' => (int) $post->ID,
'type' => $post->post_type,
'wordpress_title' => get_the_title($post),
'slug' => $post->post_name,
'url' => $url,
);
$database_data = $this->extract_database_page_data($post, $url);
$live_data = $this->extract_server_html_data($post, $url);
if (is_wp_error($live_data)) {
return $live_data;
}
$page_data = $this->merge_database_and_live_data($database_data, $live_data);
} else {
$page_data = $this->extract_homepage_server_html_data($url);
if (is_wp_error($page_data)) {
return $page_data;
}
}
return array(
'task' => 'strategy_autofill',
'scan_architecture' => 'homepage_curl_source_for_settings_autofill',
'primary_source' => isset($page_data['source']) ? $page_data['source'] : 'server_html',
'live_html_status' => isset($page_data['live_html_status']) ? $page_data['live_html_status'] : 'server_html',
'page' => $page,
'site' => array(
'name' => get_bloginfo('name'),
'description' => get_bloginfo('description'),
'home_url' => home_url('/'),
'language' => get_locale(),
),
'seo_signals' => $page_data,
'current_settings' => array(
'business_address' => (string) get_option('aiseo_business_address', ''),
'business_phone' => (string) get_option('aiseo_business_phone', ''),
'business_email' => (string) get_option('aiseo_business_email', ''),
'mode' => $requested_mode,
'selected_mode' => $requested_mode,
'seo_strategy' => $this->seo_strategy_payload($requested_mode),
),
'instructions' => array(
'phase' => 'settings_autofill_from_homepage',
'purpose' => 'Infer editable SEO Strategy settings from the homepage. Do not generate page-level recommendations.',
'selected_mode_lock' => 'Return seo_geo_mode exactly as current_settings.mode and prioritize filling the strategy fields for that selected mode.',
'output_language' => 'Use the dominant language of the supplied HTML/page text when writing field values.',
'language_policy' => array(
'strict' => true,
'detect_from' => 'Detect the dominant language from seo_signals.lang, seo_signals.title, seo_signals.meta_description, seo_signals.headings, seo_signals.visible_text_sample, and the supplied HTML/text evidence.',
'same_language_output' => 'Write all returned strategy field values in the detected dominant page/site language. Do not translate into another language because of keywords, business settings, API locale, or examples.',
'seo_strategy_exception' => 'Only use a different output language when the user explicitly declares a target language in the SEO Strategy/current settings, for example "English pages" or "Greek and English".',
'mixed_language_pages' => 'If the page is mixed-language, use the language that dominates the visible public content. Preserve brand names, product names, URLs, schema identifiers, and proper nouns as-is.',
),
),
);
}
public function get_scan_ids($single_post_id = 0) {
if ($single_post_id) {
$post = get_post(absint($single_post_id));
if ($this->is_scannable_post($post)) {
return array(absint($single_post_id));
}
return array();
}
$ids = get_posts(
array(
'post_type' => $this->supported_post_types(),
'post_status' => 'publish',
'fields' => 'ids',
'numberposts' => -1,
'post__not_in' => $this->excluded_scan_post_ids(),
'orderby' => array(
'type' => 'ASC',
'title' => 'ASC',
),
)
);
return array_values(array_filter(array_map('absint', $ids), array($this, 'is_scannable_post')));
}
private function live_html_enabled(WP_Post $post) {
return (bool) apply_filters('aiseo_enable_live_html_scan', true, $post);
}
private function extract_server_html_data(WP_Post $post, $url) {
if (!$this->live_html_enabled($post)) {
return new WP_Error(
'aiseo_curl_scan_disabled',
sprintf(__('%s cURL source scan is disabled. Enable server-side cURL/live HTML scan before running Scan & Report.', 'aivisibility-seo-geo-agent'), AISEO_AUDITOR_NAME)
);
}
return $this->extract_server_html_data_from_url($url, $this->base_page_data($url, $post, 'server_html'));
}
private function extract_homepage_server_html_data($url) {
return $this->extract_server_html_data_from_url(
$url,
$this->base_page_data_from_values($url, get_bloginfo('name'), 'homepage', 'server_html')
);
}
private function extract_server_html_data_from_url($url, array $data) {
$html = $this->fetch_html($url);
if (is_wp_error($html)) {
return $html;
}
$data['source_label'] = __('Server-side cURL page source', 'aivisibility-seo-geo-agent');
$data['live_html_status'] = 'server_html';
$data['live_html_error'] = '';
$data['source_note'] = __('Full public HTML source was fetched server-side with WordPress HTTP API/cURL.', 'aivisibility-seo-geo-agent');
$data = $this->extract_html_document_signals($this->limit_raw_html($html, 300000), $data, true);
if (empty($data['head_html_sample']) && empty($data['visible_text_sample'])) {
return new WP_Error(
'aiseo_curl_parse_failed',
$this->curl_failure_message(
$url,
sprintf(__('The public page source was fetched, but %s could not parse useful HTML from it.', 'aivisibility-seo-geo-agent'), AISEO_AUDITOR_NAME)
)
);
}
return $data;
}
private function fetch_html($url) {
$response = wp_remote_get(
$url,
array(
'timeout' => (int) apply_filters('aiseo_live_html_timeout', 10),
'redirection' => 3,
'limit_response_size' => (int) apply_filters('aiseo_live_html_limit_response_size', 300000),
'headers' => array(
'Accept' => 'text/html,application/xhtml+xml',
'User-Agent' => AISEO_AUDITOR_NAME . '/' . AISEO_AUDITOR_VERSION . '; ' . home_url('/'),
),
)
);
if (is_wp_error($response)) {
return new WP_Error(
'aiseo_curl_blocked',
$this->curl_failure_message($url, $response->get_error_message())
);
}
$status_code = (int) wp_remote_retrieve_response_code($response);
if ($status_code < 200 || $status_code >= 300) {
$message = wp_remote_retrieve_response_message($response);
$body = $this->limit_text(wp_strip_all_tags((string) wp_remote_retrieve_body($response)), 220);
$details = sprintf(
__('HTTP %1$d %2$s. Response sample: %3$s', 'aivisibility-seo-geo-agent'),
$status_code,
$message ? $message : __('Unknown status', 'aivisibility-seo-geo-agent'),
$body ? $body : __('empty response body', 'aivisibility-seo-geo-agent')
);
return new WP_Error(
'aiseo_curl_http_blocked',
$this->curl_failure_message($url, $details)
);
}
$html = (string) wp_remote_retrieve_body($response);
if ('' === trim($html)) {
return new WP_Error(
'aiseo_curl_empty_html',
$this->curl_failure_message($url, __('The server returned an empty HTML response.', 'aivisibility-seo-geo-agent'))
);
}
return $html;
}
private function curl_failure_message($url, $details) {
return sprintf(
__('%4$s could not read the public HTML source with server-side cURL/WordPress HTTP API. Details: %1$s. Please allow server-side cURL requests from this website/server to the public page URL. If a firewall, WAF, security plugin, CDN, or Cloudflare is active, allowlist the %4$s request/User-Agent "%4$s/%2$s" for this URL: %3$s', 'aivisibility-seo-geo-agent'),
$details,
AISEO_AUDITOR_VERSION,
esc_url_raw($url),
AISEO_AUDITOR_NAME
);
}
private function extract_database_page_data(WP_Post $post, $url) {
$rendered_content = $this->render_post_content($post);
$manual_excerpt = $this->normalize_text($post->post_excerpt);
$generated_excerpt = $this->normalize_text(wp_trim_words(wp_strip_all_tags(strip_shortcodes($post->post_content)), 28, ''));
$description_candidate = '' !== $manual_excerpt ? $manual_excerpt : $generated_excerpt;
$data = $this->base_page_data($url, $post, 'database');
$data['source_label'] = __('WordPress database', 'aivisibility-seo-geo-agent');
$data['source_note'] = __('Database content snapshot. Public page source must be fetched with server-side cURL before AI analysis can run.', 'aivisibility-seo-geo-agent');
$data['live_html_status'] = 'disabled';
$data['title'] = get_the_title($post);
$data['title_length'] = $this->string_length($data['title']);
$data['title_source'] = 'wordpress_post_title';
$data['meta_description'] = $description_candidate;
$data['meta_description_length'] = $this->string_length($description_candidate);
$data['meta_description_source'] = '' !== $manual_excerpt ? 'wordpress_manual_excerpt' : 'generated_excerpt_candidate';
$data['has_manual_excerpt'] = '' !== $manual_excerpt;
$data['canonical_candidate'] = $url;
$data['post'] = array(
'id' => (int) $post->ID,
'slug' => $post->post_name,
'status' => $post->post_status,
'type' => $post->post_type,
'date_gmt' => $post->post_date_gmt,
'modified_gmt' => $post->post_modified_gmt,
'raw_content_length' => $this->string_length($post->post_content),
'raw_content_sample' => $this->limit_text(wp_strip_all_tags(strip_shortcodes($post->post_content)), 3500),
'rendered_content_sample' => $this->limit_text(wp_strip_all_tags($rendered_content), 5500),
);
$data['unavailable_in_db_scan'] = array(
'final_title_tag',
'final_meta_description_tag',
'canonical_tag',
'robots_meta_tag',
'open_graph_tags',
'twitter_card_tags',
'json_ld_schema_from_theme_or_seo_plugins',
'theme_header_footer_html',
);
$data = $this->extract_content_signals($rendered_content, $data);
$data = $this->add_featured_image_signal($post, $data);
return $data;
}
private function base_page_data($url, WP_Post $post, $source) {
return $this->base_page_data_from_values($url, get_the_title($post), $post->post_type, $source);
}
private function base_page_data_from_values($url, $title, $post_type, $source) {
return array(
'source' => $source,
'source_label' => $source,
'source_note' => '',
'url' => $url,
'post_title' => (string) $title,
'post_type' => (string) $post_type,
'title' => '',
'title_length' => 0,
'title_source' => '',
'meta_description' => '',
'meta_description_length' => 0,
'meta_description_source' => '',
'has_manual_excerpt' => false,
'canonical' => '',
'canonical_candidate' => '',
'robots' => '',
'viewport' => '',
'lang' => '',
'meta_tags' => array(),
'link_tags' => array(),
'open_graph' => array(),
'twitter' => array(),
'json_ld_count' => 0,
'json_ld_types' => array(),
'json_ld' => array(),
'headings' => array(
'h1' => array(),
'h2' => array(),
'h3' => array(),
'h4' => array(),
'h5' => array(),
'h6' => array(),
),
'images' => array(
'total' => 0,
'missing_alt' => 0,
'empty_alt' => 0,
'items' => array(),
'samples_missing_alt' => array(),
'featured_image' => null,
),
'links' => array(
'internal' => 0,
'external' => 0,
),
'word_count' => 0,
'visible_text_sample' => '',
'head_html_sample' => '',
'live_html_status' => 'disabled',
'live_html_error' => '',
'unavailable_in_db_scan' => array(),
);
}
private function homepage_post() {
if ('page' === (string) get_option('show_on_front')) {
$front_page_id = absint(get_option('page_on_front'));
if ($front_page_id) {
$post = get_post($front_page_id);
if ($post instanceof WP_Post && 'publish' === $post->post_status && in_array($post->post_type, $this->supported_post_types(), true)) {
return $post;
}
}
}
$post_id = absint(url_to_postid(home_url('/')));
if ($post_id) {
$post = get_post($post_id);
if ($post instanceof WP_Post && 'publish' === $post->post_status && in_array($post->post_type, $this->supported_post_types(), true)) {
return $post;
}
}
return null;
}
private function render_post_content(WP_Post $post) {
$previous_post = isset($GLOBALS['post']) ? $GLOBALS['post'] : null;
$GLOBALS['post'] = $post;
setup_postdata($post);
$content = apply_filters('the_content', $post->post_content);
wp_reset_postdata();
if ($previous_post instanceof WP_Post) {
$GLOBALS['post'] = $previous_post;
}
return (string) $content;
}
private function extract_html_document_signals($html, array $data, $include_head) {
if (!class_exists('DOMDocument')) {
$data['visible_text_sample'] = $this->limit_text(wp_strip_all_tags($html), 6000);
$data['word_count'] = $this->word_count($data['visible_text_sample']);
return $data;
}
$dom = $this->load_dom($html);
if (!$dom) {
$data['visible_text_sample'] = $this->limit_text(wp_strip_all_tags($html), 6000);
$data['word_count'] = $this->word_count($data['visible_text_sample']);
return $data;
}
if ($include_head) {
$data = $this->extract_head_signals($dom, $data);
}
return $this->extract_content_signals_from_dom($dom, $data);
}
private function extract_content_signals($html, array $data) {
if (!class_exists('DOMDocument')) {
$data['visible_text_sample'] = $this->limit_text(wp_strip_all_tags($html), 6000);
$data['word_count'] = $this->word_count($data['visible_text_sample']);
return $data;
}
$dom = $this->load_dom('
' . $html . '
');
if (!$dom) {
$data['visible_text_sample'] = $this->limit_text(wp_strip_all_tags($html), 6000);
$data['word_count'] = $this->word_count($data['visible_text_sample']);
return $data;
}
return $this->extract_content_signals_from_dom($dom, $data);
}
private function load_dom($html) {
$previous = libxml_use_internal_errors(true);
$dom = new DOMDocument();
$loaded = $dom->loadHTML('' . (string) $html);
libxml_clear_errors();
libxml_use_internal_errors($previous);
return $loaded ? $dom : null;
}
private function extract_head_signals(DOMDocument $dom, array $data) {
$title_nodes = $dom->getElementsByTagName('title');
if ($title_nodes->length > 0) {
$data['title'] = $this->normalize_text($title_nodes->item(0)->textContent);
$data['title_length'] = $this->string_length($data['title']);
$data['title_source'] = 'live_title_tag';
}
$html_node = $dom->getElementsByTagName('html')->item(0);
if ($html_node instanceof DOMElement) {
$data['lang'] = $html_node->getAttribute('lang');
}
foreach ($dom->getElementsByTagName('meta') as $meta) {
if (!$meta instanceof DOMElement) {
continue;
}
$name = strtolower($meta->getAttribute('name'));
$property = strtolower($meta->getAttribute('property'));
$http_equiv = strtolower($meta->getAttribute('http-equiv'));
$content = $this->normalize_text($meta->getAttribute('content'));
if ($content && count($data['meta_tags']) < 100) {
if ('' !== $property) {
$data['meta_tags'][] = array(
'attribute' => 'property',
'name' => $property,
'content' => $this->limit_text($content, 600),
);
} elseif ('' !== $http_equiv) {
$data['meta_tags'][] = array(
'attribute' => 'http-equiv',
'name' => $http_equiv,
'content' => $this->limit_text($content, 600),
);
} elseif ('' !== $name) {
$data['meta_tags'][] = array(
'attribute' => 'name',
'name' => $name,
'content' => $this->limit_text($content, 600),
);
}
}
if ('description' === $name) {
$data['meta_description'] = $content;
$data['meta_description_length'] = $this->string_length($content);
$data['meta_description_source'] = 'live_meta_description_tag';
} elseif ('robots' === $name) {
$data['robots'] = $content;
} elseif ('viewport' === $name) {
$data['viewport'] = $content;
}
if (0 === strpos($property, 'og:')) {
$data['open_graph'][$property] = $content;
}
if (0 === strpos($name, 'twitter:')) {
$data['twitter'][$name] = $content;
}
}
foreach ($dom->getElementsByTagName('link') as $link) {
if (!$link instanceof DOMElement) {
continue;
}
if ('canonical' === strtolower($link->getAttribute('rel'))) {
$data['canonical'] = esc_url_raw($link->getAttribute('href'));
}
if (count($data['link_tags']) < 60) {
$rel = $this->normalize_text($link->getAttribute('rel'));
$href = esc_url_raw($link->getAttribute('href'));
if ('' !== $rel || '' !== $href) {
$data['link_tags'][] = array(
'rel' => $this->limit_text($rel, 120),
'href' => $href,
'hreflang' => $this->limit_text($this->normalize_text($link->getAttribute('hreflang')), 40),
'type' => $this->limit_text($this->normalize_text($link->getAttribute('type')), 100),
'title' => $this->limit_text($this->normalize_text($link->getAttribute('title')), 160),
);
}
}
}
foreach ($dom->getElementsByTagName('script') as $script) {
if (!$script instanceof DOMElement || 'application/ld+json' !== strtolower(trim($script->getAttribute('type')))) {
continue;
}
$data['json_ld_count']++;
$decoded_json = json_decode(trim($script->textContent), true);
$types = $this->extract_json_ld_types($decoded_json);
$data['json_ld_types'] = array_values(array_unique(array_merge($data['json_ld_types'], $types)));
if (count($data['json_ld']) < 10) {
$data['json_ld'][] = array(
'types' => $types,
'decoded' => is_array($decoded_json) ? $decoded_json : null,
'raw_sample' => $this->limit_text(trim($script->textContent), 12000),
);
}
}
$head = $dom->getElementsByTagName('head')->item(0);
if ($head) {
$data['head_html_sample'] = $this->limit_text($dom->saveHTML($head), 5000);
}
return $data;
}
private function extract_content_signals_from_dom(DOMDocument $dom, array $data) {
foreach (array('h1', 'h2', 'h3', 'h4', 'h5', 'h6') as $tag) {
foreach ($dom->getElementsByTagName($tag) as $heading) {
$text = $this->normalize_text($heading->textContent);
if ('' !== $text) {
$data['headings'][$tag][] = $this->limit_text($text, 180);
}
}
}
$host = wp_parse_url(home_url('/'), PHP_URL_HOST);
foreach ($dom->getElementsByTagName('a') as $anchor) {
if (!$anchor instanceof DOMElement) {
continue;
}
$href = $anchor->getAttribute('href');
if (!$href || '#' === $href[0] || 0 === strpos($href, 'mailto:') || 0 === strpos($href, 'tel:')) {
continue;
}
$link_host = wp_parse_url($href, PHP_URL_HOST);
if (!$link_host || $link_host === $host) {
$data['links']['internal']++;
} else {
$data['links']['external']++;
}
}
foreach ($dom->getElementsByTagName('img') as $img) {
if (!$img instanceof DOMElement) {
continue;
}
$data['images']['total']++;
$has_alt = $img->hasAttribute('alt');
$alt = $has_alt ? trim($img->getAttribute('alt')) : '';
$this->add_image_item_sample($data, $img, $alt, $has_alt);
if (!$has_alt) {
$data['images']['missing_alt']++;
$this->add_missing_image_sample($data, $img);
} elseif ('' === $alt) {
$data['images']['empty_alt']++;
$this->add_missing_image_sample($data, $img);
}
}
$this->remove_non_content_nodes($dom);
$body = $dom->getElementsByTagName('body')->item(0);
$visible_text = $body ? $this->normalize_text($body->textContent) : $this->normalize_text($dom->textContent);
$data['visible_text_sample'] = $this->limit_text($visible_text, 6500);
$data['word_count'] = $this->word_count($visible_text);
return $data;
}
private function add_featured_image_signal(WP_Post $post, array $data) {
$thumbnail_id = get_post_thumbnail_id($post);
if (!$thumbnail_id) {
return $data;
}
$alt = trim((string) get_post_meta($thumbnail_id, '_wp_attachment_image_alt', true));
$src = wp_get_attachment_url($thumbnail_id);
$data['images']['total']++;
$data['images']['featured_image'] = array(
'id' => (int) $thumbnail_id,
'src' => $src ? esc_url_raw($src) : '',
'alt' => $alt,
);
$data['images']['items'][] = array(
'id' => (int) $thumbnail_id,
'attachment_id' => (int) $thumbnail_id,
'src' => $src ? esc_url_raw($src) : '',
'alt' => $alt,
'has_alt' => '' !== $alt,
'class' => 'featured-image',
'role' => 'featured',
);
if ('' === $alt) {
$data['images']['empty_alt']++;
if (count($data['images']['samples_missing_alt']) < 6) {
$data['images']['samples_missing_alt'][] = array(
'id' => (int) $thumbnail_id,
'attachment_id' => (int) $thumbnail_id,
'src' => $src ? esc_url_raw($src) : '',
'class' => 'featured-image',
);
}
}
return $data;
}
private function merge_database_and_live_data(array $database_data, array $live_data) {
$live_data['source'] = 'hybrid';
$live_data['source_label'] = __('Database + cURL page source', 'aivisibility-seo-geo-agent');
$live_data['source_note'] = __('DB content was collected first, then the public page source was read server-side with cURL.', 'aivisibility-seo-geo-agent');
$live_data['database_content'] = array(
'title' => $database_data['title'],
'meta_description' => $database_data['meta_description'],
'word_count' => $database_data['word_count'],
'headings' => $database_data['headings'],
'images' => $database_data['images'],
'links' => $database_data['links'],
'visible_text_sample' => $database_data['visible_text_sample'],
);
if (empty($live_data['meta_description']) && !empty($database_data['meta_description'])) {
$live_data['meta_description'] = $database_data['meta_description'];
$live_data['meta_description_length'] = $database_data['meta_description_length'];
$live_data['meta_description_source'] = $database_data['meta_description_source'];
}
if (!empty($database_data['images']) && is_array($database_data['images'])) {
if (empty($live_data['images']['featured_image']) && !empty($database_data['images']['featured_image'])) {
$live_data['images']['featured_image'] = $database_data['images']['featured_image'];
}
$database_items = !empty($database_data['images']['items']) && is_array($database_data['images']['items']) ? $database_data['images']['items'] : array();
$live_items = !empty($live_data['images']['items']) && is_array($live_data['images']['items']) ? $live_data['images']['items'] : array();
$live_data['images']['items'] = $this->merge_image_items($live_items, $database_items);
}
return $live_data;
}
private function build_ai_payload(WP_Post $post, $url, array $page_data) {
$selected_mode = sanitize_key((string) get_option('aiseo_geo_mode', 'mixed'));
if (!in_array($selected_mode, array('local', 'generic', 'mixed'), true)) {
$selected_mode = 'mixed';
}
$seo_strategy = $this->seo_strategy_payload($selected_mode);
return array(
'scan_architecture' => 'db_snapshot_plus_required_server_side_curl_page_source',
'primary_source' => isset($page_data['source']) ? $page_data['source'] : 'database',
'live_html_status' => isset($page_data['live_html_status']) ? $page_data['live_html_status'] : 'disabled',
'page' => array(
'id' => (int) $post->ID,
'type' => $post->post_type,
'wordpress_title' => get_the_title($post),
'slug' => $post->post_name,
'url' => $url,
'commerce' => $this->commerce_payload($post),
),
'seo_signals' => $page_data,
'schema_context' => $this->schema_context_payload($post, $url, $page_data),
'seo_geo_settings' => array(
'business_address' => (string) get_option('aiseo_business_address', ''),
'business_phone' => (string) get_option('aiseo_business_phone', ''),
'business_email' => (string) get_option('aiseo_business_email', ''),
'mode' => $selected_mode,
'selected_mode' => $selected_mode,
),
'seo_strategy' => $seo_strategy,
'instructions' => array(
'phase' => 'seo_geo_scan_and_report',
'primary_recommendations' => 'All improvement recommendations should come from the AI analysis.',
'business_context_requirement' => 'Before writing metadata, headings, JSON-LD, or image alt values, evaluate seo_geo_settings.business_address, seo_geo_settings.business_phone, seo_geo_settings.business_email, and seo_strategy.active_strategy as the authoritative business context unless contradicted by verified page facts.',
'selected_strategy_requirement' => 'Use seo_strategy.active_strategy as the selected SEO-GEO strategy for this page scan. Do not optimize against a different mode-specific strategy group.',
'recommendation_scope' => 'Report recommendations for metatags, Open Graph/Twitter social tags, JSON-LD, page headings, and image alt text. Paragraph/body copy is outside this report scope.',
'heading_recommendation_scope' => 'Return suggested_heading_updates for headings only. Include useful visible h1-h6 headings from seo_signals.headings, and from seo_signals.editable_headings when present. Do not stop at H1/H2; include H3, H4, H5, and H6 whenever they exist in the payload. Each update must include tag, zero-based index within that tag, exact current_text, suggested_text, seo_scope, and reason. Preserve nested markup in the source heading when describing the recommendation.',
'curl_source_requirement' => 'AI analysis runs only after the public page source is readable through server-side cURL/WordPress HTTP API.',
'json_ld_requirement' => 'For every scanned URL, return suggested_json_ld as complete valid Schema.org JSON-LD. Prefer a single object with @context and @graph. Include as many supported entities as the page evidence allows: WebSite, Organization/LocalBusiness/ProfessionalService, WebPage, BreadcrumbList, Article/BlogPosting, Service, Product, Offer, AggregateRating, Review, ImageObject, and VideoObject. Product, Offer, AggregateRating, and Review are allowed only when supported by page.commerce, existing JSON-LD, visible page content, or supplied business/SEO strategy facts. FAQPage is allowed only when the current page already contains explicit FAQ question-and-answer pairs or existing valid FAQPage JSON-LD. Do not invent prices, ratings, reviews, stock, SKUs, FAQ questions, FAQ answers, addresses, phones, or emails.',
'output_language' => 'Use the dominant language of the supplied HTML/page text for every generated text field.',
'language_policy' => array(
'strict' => true,
'detect_from' => 'Detect the dominant language from seo_signals.lang, seo_signals.title, seo_signals.meta_description, seo_signals.headings, seo_signals.visible_text_sample, and any supplied HTML/text evidence.',
'same_language_output' => 'All generated text must stay in the detected dominant language: title, meta description, Open Graph/Twitter text, headings, image alt text, JSON-LD names/descriptions, FAQ text, report reasons, and recommendations.',
'no_locale_override' => 'Do not switch language because of WordPress locale, API locale, example text, keyword lists, or business settings when the visible page HTML clearly uses another dominant language.',
'seo_strategy_exception' => 'Only use a different language when seo_strategy.active_strategy explicitly names the target language for this exact page, for example "English pages" or "Greek and English".',
'mixed_language_pages' => 'If the page is mixed-language, use the language that dominates the visible public content. Preserve brand names, product names, URLs, schema identifiers, and proper nouns as-is.',
),
),
);
}
private function schema_context_payload(WP_Post $post, $url, array $page_data) {
return array(
'required_output' => 'suggested_json_ld must be a complete JSON-LD object for this exact page, not only a type label or recommendation.',
'preferred_shape' => 'Use {"@context":"https://schema.org","@graph":[...]} with stable @id values based on the page URL and home URL.',
'facts_policy' => 'Use only verified page facts, existing JSON-LD, WordPress post data, WooCommerce product data, seo_geo_settings, and seo_strategy. If a rich entity is not supported by evidence, omit that entity instead of guessing. FAQPage requires explicit current-page FAQ question-and-answer evidence; never create FAQ schema from strategy, headings, keywords, or assumptions.',
'page_url' => esc_url_raw($url),
'post_type' => $post->post_type,
'existing_json_ld_types' => !empty($page_data['json_ld_types']) && is_array($page_data['json_ld_types']) ? array_values($page_data['json_ld_types']) : array(),
'faq_candidates' => $this->faq_candidates_from_page_data($page_data),
'commerce' => $this->commerce_payload($post),
);
}
private function commerce_payload(WP_Post $post) {
$payload = array(
'is_product_page' => 'product' === $post->post_type,
'woocommerce_available' => function_exists('wc_get_product'),
);
if ('product' !== $post->post_type || !function_exists('wc_get_product')) {
return $payload;
}
$product = wc_get_product($post->ID);
if (!$product) {
return $payload;
}
$images = array();
$main_image_id = method_exists($product, 'get_image_id') ? absint($product->get_image_id()) : 0;
$image_ids = $main_image_id ? array($main_image_id) : array();
if (method_exists($product, 'get_gallery_image_ids')) {
$image_ids = array_merge($image_ids, array_map('absint', (array) $product->get_gallery_image_ids()));
}
foreach (array_slice(array_values(array_unique(array_filter($image_ids))), 0, 8) as $image_id) {
$image_url = wp_get_attachment_image_url($image_id, 'full');
if ($image_url) {
$images[] = esc_url_raw($image_url);
}
}
$attributes = array();
if (method_exists($product, 'get_attributes')) {
foreach ((array) $product->get_attributes() as $attribute) {
if (!is_object($attribute) || !method_exists($attribute, 'get_name')) {
continue;
}
$name = (string) $attribute->get_name();
$label = function_exists('wc_attribute_label') ? wc_attribute_label($name) : $name;
$value = method_exists($product, 'get_attribute') ? $this->limit_text((string) $product->get_attribute($name), 240) : '';
if ('' !== $value) {
$attributes[] = array(
'name' => $this->limit_text($label, 120),
'value' => $value,
);
}
if (count($attributes) >= 12) {
break;
}
}
}
$categories = taxonomy_exists('product_cat') ? wp_get_post_terms($post->ID, 'product_cat', array('fields' => 'names')) : array();
$tags = taxonomy_exists('product_tag') ? wp_get_post_terms($post->ID, 'product_tag', array('fields' => 'names')) : array();
return array_merge($payload, array(
'product_type' => method_exists($product, 'get_type') ? (string) $product->get_type() : '',
'name' => method_exists($product, 'get_name') ? $this->limit_text((string) $product->get_name(), 220) : get_the_title($post),
'sku' => method_exists($product, 'get_sku') ? $this->limit_text((string) $product->get_sku(), 120) : '',
'price' => method_exists($product, 'get_price') ? (string) $product->get_price() : '',
'regular_price' => method_exists($product, 'get_regular_price') ? (string) $product->get_regular_price() : '',
'sale_price' => method_exists($product, 'get_sale_price') ? (string) $product->get_sale_price() : '',
'currency' => function_exists('get_woocommerce_currency') ? (string) get_woocommerce_currency() : '',
'stock_status' => method_exists($product, 'get_stock_status') ? (string) $product->get_stock_status() : '',
'in_stock' => method_exists($product, 'is_in_stock') ? (bool) $product->is_in_stock() : null,
'stock_quantity' => method_exists($product, 'get_stock_quantity') ? $product->get_stock_quantity() : null,
'average_rating' => method_exists($product, 'get_average_rating') ? (string) $product->get_average_rating() : '',
'rating_count' => method_exists($product, 'get_rating_count') ? (int) $product->get_rating_count() : 0,
'review_count' => method_exists($product, 'get_review_count') ? (int) $product->get_review_count() : 0,
'short_description' => method_exists($product, 'get_short_description') ? $this->limit_text(wp_strip_all_tags($product->get_short_description()), 900) : '',
'description' => method_exists($product, 'get_description') ? $this->limit_text(wp_strip_all_tags($product->get_description()), 1200) : '',
'categories' => is_wp_error($categories) ? array() : array_slice(array_values(array_filter(array_map('strval', (array) $categories))), 0, 12),
'tags' => is_wp_error($tags) ? array() : array_slice(array_values(array_filter(array_map('strval', (array) $tags))), 0, 16),
'images' => $images,
'attributes' => $attributes,
));
}
private function faq_candidates_from_page_data(array $page_data) {
$text = '';
foreach (array('visible_text_sample', 'rendered_content_sample', 'raw_content_sample') as $key) {
if (!empty($page_data[$key]) && is_string($page_data[$key])) {
$text .= ' ' . $page_data[$key];
} elseif (!empty($page_data['post'][$key]) && is_string($page_data['post'][$key])) {
$text .= ' ' . $page_data['post'][$key];
}
}
$text = $this->normalize_text($text);
if ('' === $text) {
return array();
}
$sentences = preg_split('/(?<=[\.;!\?])\s+/u', $text);
$candidates = array();
$total = is_array($sentences) ? count($sentences) : 0;
for ($i = 0; $i < $total; $i++) {
$question = $this->normalize_text($sentences[$i]);
if ('' === $question || !preg_match('/[\?;]$/u', $question)) {
continue;
}
if ($this->string_length($question) < 12 || $this->string_length($question) > 180) {
continue;
}
$answer = isset($sentences[$i + 1]) ? $this->normalize_text($sentences[$i + 1]) : '';
if ($this->string_length($answer) < 20 || $this->string_length($answer) > 420) {
continue;
}
$candidates[] = array(
'question' => $this->limit_text($question, 180),
'answer_candidate' => $this->limit_text($answer, 420),
);
if (count($candidates) >= 8) {
break;
}
}
return $candidates;
}
private function seo_strategy_payload($declared_mode = '') {
$declared_mode = sanitize_key((string) $declared_mode);
if (!in_array($declared_mode, array('local', 'generic', 'mixed'), true)) {
$declared_mode = sanitize_key((string) get_option('aiseo_geo_mode', 'mixed'));
}
if (!in_array($declared_mode, array('local', 'generic', 'mixed'), true)) {
$declared_mode = 'mixed';
}
$strategy = get_option('aiseo_seo_strategy', array());
if (!is_array($strategy)) {
$strategy = array();
}
$payload = array(
'declared_mode' => $declared_mode,
'common' => array(
'business_description' => $this->strategy_value($strategy, 'business_description'),
'main_service_or_product' => $this->strategy_value($strategy, 'main_offer'),
'target_audience' => $this->strategy_value($strategy, 'target_audience'),
'conversion_goal' => $this->strategy_value($strategy, 'conversion_goal'),
'preferred_tone' => $this->strategy_value($strategy, 'tone'),
'keywords_to_avoid' => $this->strategy_value($strategy, 'avoid_keywords'),
),
'local' => array(
'main_city_or_area' => $this->strategy_value($strategy, 'local_main_area'),
'service_areas' => $this->strategy_value($strategy, 'local_service_areas'),
'local_services' => $this->strategy_value($strategy, 'local_services'),
'local_keyword_strategy' => $this->strategy_value($strategy, 'local_keyword_strategy', 'ai_decide'),
'schema_focus' => $this->strategy_value($strategy, 'local_schema_focus', 'ai_decide'),
),
'generic' => array(
'target_country_or_language' => $this->strategy_value($strategy, 'generic_target_market'),
'main_topical_category' => $this->strategy_value($strategy, 'generic_topic_category'),
'intent_focus' => $this->strategy_value($strategy, 'generic_intent_focus', 'ai_decide'),
'primary_generic_keywords' => $this->strategy_value($strategy, 'generic_primary_keywords'),
'entity_topic_terms' => $this->strategy_value($strategy, 'generic_entity_terms'),
'local_terms_handling' => $this->strategy_value($strategy, 'generic_local_terms', 'avoid'),
),
'mixed' => array(
'primary_generic_keyword' => $this->strategy_value($strategy, 'mixed_primary_generic_keyword'),
'local_modifier_strategy' => $this->strategy_value($strategy, 'mixed_local_modifier_strategy', 'ai_decide'),
'priority_split' => $this->strategy_value($strategy, 'mixed_priority_split', 'balanced'),
'pages_that_stay_generic' => $this->strategy_value($strategy, 'mixed_generic_pages'),
'pages_with_local_signals' => $this->strategy_value($strategy, 'mixed_local_pages'),
'schema_mix' => $this->strategy_value($strategy, 'mixed_schema_mix', 'ai_decide'),
),
);
$payload['active_strategy'] = array(
'mode' => $declared_mode,
'common' => $payload['common'],
'mode_specific' => isset($payload[$declared_mode]) ? $payload[$declared_mode] : array(),
);
return $payload;
}
private function strategy_value(array $strategy, $key, $default = '') {
return isset($strategy[$key]) ? (string) $strategy[$key] : (string) $default;
}
private function build_local_report(array $data) {
$checks = array();
$priority_actions = array();
$score = 100;
$is_live_available = in_array($data['source'], array('live_html', 'hybrid'), true);
$this->local_check(
$checks,
$priority_actions,
$score,
'meta',
$data['title_length'] > 0 && $data['title_length'] <= 65,
__('Title candidate exists and has a reasonable length.', 'aivisibility-seo-geo-agent'),
__('Use a clear SEO title around 50-60 characters with the main keyword near the beginning.', 'aivisibility-seo-geo-agent'),
'high',
$data['title_length'] ? 6 : 12
);
$this->local_check(
$checks,
$priority_actions,
$score,
'meta',
!empty($data['has_manual_excerpt']) && $data['meta_description_length'] >= 80 && $data['meta_description_length'] <= 160,
__('Manual excerpt can support a useful meta description candidate.', 'aivisibility-seo-geo-agent'),
__('Add a manual excerpt or SEO meta description candidate around 120-155 characters.', 'aivisibility-seo-geo-agent'),
'medium',
8
);
$h2_h3_count = count($data['headings']['h2']) + count($data['headings']['h3']);
$this->local_check(
$checks,
$priority_actions,
$score,
'headings',
$h2_h3_count > 0 || (int) $data['word_count'] < 250,
__('Content headings provide structure.', 'aivisibility-seo-geo-agent'),
__('Add descriptive H2/H3 headings to structure the page and include related keywords naturally.', 'aivisibility-seo-geo-agent'),
'medium',
8
);
$missing_alt_total = (int) $data['images']['missing_alt'] + (int) $data['images']['empty_alt'];
$this->local_check(
$checks,
$priority_actions,
$score,
'images',
0 === $missing_alt_total,
__('Detected images have alt attributes.', 'aivisibility-seo-geo-agent'),
__('Add descriptive alt text to meaningful content and featured images.', 'aivisibility-seo-geo-agent'),
'medium',
min(12, 4 + $missing_alt_total)
);
$this->local_check(
$checks,
$priority_actions,
$score,
'content',
(int) $data['word_count'] >= 250,
__('The content has enough visible text for a basic SEO evaluation.', 'aivisibility-seo-geo-agent'),
__('Expand thin content with helpful sections, examples, FAQs, and internal links that match search intent.', 'aivisibility-seo-geo-agent'),
'medium',
10
);
$this->local_check(
$checks,
$priority_actions,
$score,
'content',
(int) $data['links']['internal'] > 0 || (int) $data['word_count'] < 250,
__('Content includes internal linking or is too short to require it.', 'aivisibility-seo-geo-agent'),
__('Add relevant internal links to supporting pages and conversion pages.', 'aivisibility-seo-geo-agent'),
'low',
4
);
if ($is_live_available) {
$this->local_check(
$checks,
$priority_actions,
$score,
'open_graph',
!empty($data['open_graph']['og:title']) && !empty($data['open_graph']['og:description']) && !empty($data['open_graph']['og:image']),
__('Core Open Graph tags are present in live HTML.', 'aivisibility-seo-geo-agent'),
__('Add og:title, og:description, and og:image so shares have controlled titles, copy, and preview images.', 'aivisibility-seo-geo-agent'),
'medium',
8
);
$this->local_check(
$checks,
$priority_actions,
$score,
'json_ld',
$data['json_ld_count'] > 0,
__('JSON-LD structured data was found in live HTML.', 'aivisibility-seo-geo-agent'),
__('Add relevant JSON-LD such as WebPage, Article, Organization, LocalBusiness, FAQPage, or BreadcrumbList where appropriate.', 'aivisibility-seo-geo-agent'),
'medium',
9
);
$this->local_check(
$checks,
$priority_actions,
$score,
'technical',
'' !== $data['canonical'],
__('Canonical tag was found in live HTML.', 'aivisibility-seo-geo-agent'),
__('Add a canonical URL to reduce duplicate-content ambiguity.', 'aivisibility-seo-geo-agent'),
'low',
4
);
} else {
$checks[] = array(
'category' => 'technical',
'severity' => 'info',
'status' => 'warning',
'finding' => __('Public page source was not checked for this scan.', 'aivisibility-seo-geo-agent'),
'recommendation' => sprintf(__('Allow server-side cURL access to the public page source before running %s recommendations.', 'aivisibility-seo-geo-agent'), AISEO_AUDITOR_NAME),
);
}
$score = max(0, min(100, $score));
$status_label = $this->score_status_label($score);
return array(
'score' => $score,
'summary' => sprintf(__('cURL page-source audit completed with %s status. AI recommendations are used when the AI Engine request succeeds.', 'aivisibility-seo-geo-agent'), $status_label),
'priority_actions' => array_slice($priority_actions, 0, 5),
'checks' => $checks,
);
}
private function score_status_label($score) {
if ($score >= 80) {
return __('Green', 'aivisibility-seo-geo-agent');
}
if ($score >= 55) {
return __('Orange', 'aivisibility-seo-geo-agent');
}
return __('Red', 'aivisibility-seo-geo-agent');
}
private function local_check(array &$checks, array &$priority_actions, &$score, $category, $pass, $pass_text, $recommendation, $severity, $penalty) {
$checks[] = array(
'category' => $category,
'severity' => $pass ? 'info' : $severity,
'status' => $pass ? 'pass' : 'fail',
'finding' => $pass ? $pass_text : __('Needs attention.', 'aivisibility-seo-geo-agent'),
'recommendation' => $pass ? __('No immediate action needed.', 'aivisibility-seo-geo-agent') : $recommendation,
);
if (!$pass) {
$score -= (int) $penalty;
$priority_actions[] = array(
'area' => $category,
'severity' => in_array($severity, array('high', 'medium', 'low'), true) ? $severity : 'medium',
'recommendation' => $recommendation,
'reason' => __('Local cURL/source checks found a weak SEO signal. AI recommendations should be preferred when available.', 'aivisibility-seo-geo-agent'),
);
}
}
private function extract_json_ld_types($decoded_json) {
$types = array();
if (!is_array($decoded_json)) {
return $types;
}
if (isset($decoded_json['@type'])) {
if (is_array($decoded_json['@type'])) {
$types = array_merge($types, array_map('strval', $decoded_json['@type']));
} else {
$types[] = (string) $decoded_json['@type'];
}
}
foreach (array('@graph', 'itemListElement') as $key) {
if (!empty($decoded_json[$key]) && is_array($decoded_json[$key])) {
foreach ($decoded_json[$key] as $item) {
$types = array_merge($types, $this->extract_json_ld_types($item));
}
}
}
return $types;
}
private function add_missing_image_sample(array &$data, DOMElement $img) {
if (count($data['images']['samples_missing_alt']) >= 6) {
return;
}
$attachment_id = $this->image_attachment_id($img);
$data['images']['samples_missing_alt'][] = array(
'id' => $attachment_id,
'attachment_id' => $attachment_id,
'src' => esc_url_raw($img->getAttribute('src')),
'class' => sanitize_text_field($img->getAttribute('class')),
);
}
private function add_image_item_sample(array &$data, DOMElement $img, $alt, $has_alt) {
if (count($data['images']['items']) >= (int) apply_filters('aiseo_image_inventory_limit', 36)) {
return;
}
$attachment_id = $this->image_attachment_id($img);
$src = esc_url_raw($img->getAttribute('src'));
if (!$attachment_id && !$src) {
return;
}
$key = $attachment_id ? 'id_' . $attachment_id : 'src_' . md5($src);
if (isset($data['images']['items'][$key])) {
return;
}
$data['images']['items'][$key] = array(
'id' => $attachment_id,
'attachment_id' => $attachment_id,
'src' => $src,
'alt' => $this->limit_text((string) $alt, 180),
'has_alt' => (bool) $has_alt,
'class' => sanitize_text_field($img->getAttribute('class')),
);
}
private function merge_image_items(array $primary, array $secondary) {
$merged = array();
foreach (array($primary, $secondary) as $items) {
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$attachment_id = !empty($item['attachment_id']) ? absint($item['attachment_id']) : (!empty($item['id']) ? absint($item['id']) : 0);
$src = !empty($item['src']) ? esc_url_raw($item['src']) : '';
if (!$attachment_id && !$src) {
continue;
}
$key = $attachment_id ? 'id_' . $attachment_id : 'src_' . md5($src);
if (!isset($merged[$key])) {
$item['id'] = $attachment_id;
$item['attachment_id'] = $attachment_id;
$item['src'] = $src;
$merged[$key] = $item;
}
}
}
return array_slice(array_values($merged), 0, (int) apply_filters('aiseo_image_inventory_limit', 36));
}
private function image_attachment_id(DOMElement $img) {
$class = (string) $img->getAttribute('class');
if (preg_match('/(?:^|\s)wp-image-(\d+)(?:\s|$)/', $class, $matches)) {
return absint($matches[1]);
}
$src = esc_url_raw($img->getAttribute('src'));
if ($src && function_exists('attachment_url_to_postid')) {
return absint(attachment_url_to_postid($src));
}
return 0;
}
private function remove_non_content_nodes(DOMDocument $dom) {
foreach (array('script', 'style', 'noscript') as $tag) {
$nodes = array();
foreach ($dom->getElementsByTagName($tag) as $node) {
$nodes[] = $node;
}
foreach ($nodes as $node) {
if ($node->parentNode) {
$node->parentNode->removeChild($node);
}
}
}
}
private function normalize_text($text) {
$text = html_entity_decode((string) $text, ENT_QUOTES, 'UTF-8');
$text = preg_replace('/\s+/u', ' ', $text);
return trim($text);
}
private function limit_text($text, $limit) {
$text = $this->normalize_text($text);
if ($this->string_length($text) <= $limit) {
return $text;
}
if (function_exists('mb_substr')) {
return mb_substr($text, 0, $limit, 'UTF-8') . '...';
}
return substr($text, 0, $limit) . '...';
}
private function limit_raw_html($html, $limit) {
$html = (string) $html;
if ($this->string_length($html) <= $limit) {
return $html;
}
if (function_exists('mb_substr')) {
return mb_substr($html, 0, $limit, 'UTF-8');
}
return substr($html, 0, $limit);
}
private function word_count($text) {
$text = $this->normalize_text($text);
if ('' === $text) {
return 0;
}
preg_match_all('/[\p{L}\p{N}]+/u', $text, $matches);
return count($matches[0]);
}
private function string_length($text) {
if (function_exists('mb_strlen')) {
return mb_strlen((string) $text, 'UTF-8');
}
return strlen((string) $text);
}
}