true, 'scanned' => count( $pages ), 'total' => count( $pages ) ); } if ( 0 === $offset ) { self::maybe_reset_history(); $ids = self::collect_post_ids( $settings ); update_option( self::OPT_QUEUE, $ids, false ); update_option( self::OPT_ACC, array(), false ); update_option( self::OPT_LINK_BUDGET, ! empty( $settings['check_links'] ) ? 80 : 0, false ); } $ids = get_option( self::OPT_QUEUE, array() ); if ( ! is_array( $ids ) ) { $ids = array(); } $total = count( $ids ); $acc = get_option( self::OPT_ACC, array() ); if ( ! is_array( $acc ) ) { $acc = array(); } // Restore the persistent link-check budget for this batch. AICitare_Links::reset_budget( (int) get_option( self::OPT_LINK_BUDGET, 0 ) ); foreach ( array_slice( $ids, $offset, $size ) as $pid ) { $post = get_post( (int) $pid ); if ( ! $post ) { continue; } try { $acc[] = self::analyze_post( $post, $settings ); } catch ( \Throwable $e ) { AICitare_Log::add( 'scan', 'Skipped post #' . (int) $pid . ' (' . $e->getMessage() . ')' ); } } update_option( self::OPT_ACC, $acc, false ); update_option( self::OPT_LINK_BUDGET, AICitare_Links::budget_left(), false ); $scanned = min( $total, $offset + $size ); if ( $scanned >= $total ) { self::persist_scan( $acc ); delete_option( self::OPT_QUEUE ); delete_option( self::OPT_ACC ); delete_option( self::OPT_LINK_BUDGET ); return array( 'done' => true, 'scanned' => $total, 'total' => $total ); } return array( 'done' => false, 'scanned' => $scanned, 'total' => $total ); } /** Collect the IDs of every post to scan (public types, honoring drafts setting). */ private static function collect_post_ids( $settings ) { $status = array( 'publish' ); if ( ! empty( $settings['include_drafts'] ) ) { $status[] = 'draft'; $status[] = 'pending'; } $types = get_post_types( array( 'public' => true ), 'names' ); unset( $types['attachment'] ); $types = apply_filters( 'aicitare_post_types', array_values( $types ) ); if ( empty( $types ) ) { $types = array( 'post', 'page' ); } $q = new WP_Query( array( 'post_type' => $types, 'post_status' => $status, 'posts_per_page' => 1000, 'no_found_rows' => true, 'fields' => 'ids', ) ); return array_map( 'intval', $q->posts ); } /** On the first real scan after install/upgrade, discard demo-era history. */ private static function maybe_reset_history() { $prev = get_option( AICITARE_OPT_PAGES ); $had_real = false; if ( is_array( $prev ) ) { foreach ( $prev as $pp ) { if ( isset( $pp['id'] ) && 0 === strpos( (string) $pp['id'], 'wp' ) ) { $had_real = true; break; } } } if ( ! $had_real ) { delete_option( AICITARE_OPT_HISTORY ); delete_option( 'aicitare_page_hist' ); } } /** Persist a completed page dataset + update trend history + last-scan time. */ private static function persist_scan( $pages ) { update_option( AICITARE_OPT_PAGES, $pages ); $site = self::site_score( $pages, AICitare_Data::get_fixed() ); $hist = AICitare_Data::get_history(); $hist[] = $site; if ( count( $hist ) > 12 ) { $hist = array_slice( $hist, -12 ); } update_option( AICITARE_OPT_HISTORY, $hist ); $fixed = AICitare_Data::get_fixed(); $phist = get_option( 'aicitare_page_hist', array() ); if ( ! is_array( $phist ) ) { $phist = array(); } $ids = array(); foreach ( $pages as $pg ) { $ids[] = $pg['id']; $h = ( isset( $phist[ $pg['id'] ] ) && is_array( $phist[ $pg['id'] ] ) ) ? $phist[ $pg['id'] ] : array(); $h[] = self::live_score( $pg, $fixed ); if ( count( $h ) > 12 ) { $h = array_slice( $h, -12 ); } $phist[ $pg['id'] ] = $h; } foreach ( array_keys( $phist ) as $k ) { if ( ! in_array( $k, $ids, true ) ) { unset( $phist[ $k ] ); } } update_option( 'aicitare_page_hist', $phist ); $settings = AICitare_Data::get_settings(); $settings['last_scan'] = 'just now'; $settings['last_scan_ts'] = time(); update_option( AICITARE_OPT_SETTINGS, $settings ); AICitare_Log::add( 'scan', sprintf( 'Scan completed — %d pages, site score %d', count( $pages ), $site ) ); return $pages; } /** Analyze published posts & pages for GEO gaps. */ private static function scan_posts() { $settings = AICitare_Data::get_settings(); $status = array( 'publish' ); if ( ! empty( $settings['include_drafts'] ) ) { $status[] = 'draft'; $status[] = 'pending'; } // Scan every public content type (posts, pages, and custom post types // like products/reviews) — not just post + page. Filterable. $types = get_post_types( array( 'public' => true ), 'names' ); unset( $types['attachment'] ); $types = apply_filters( 'aicitare_post_types', array_values( $types ) ); if ( empty( $types ) ) { $types = array( 'post', 'page' ); } $query = new WP_Query( array( 'post_type' => $types, 'post_status' => $status, 'posts_per_page' => 200, 'no_found_rows' => true, ) ); if ( ! empty( $settings['check_links'] ) ) { AICitare_Links::reset_budget( 50 ); } $pages = array(); foreach ( $query->posts as $post ) { // Resilience: one problematic post must never abort the whole scan. try { $pages[] = self::analyze_post( $post, $settings ); } catch ( \Throwable $e ) { AICitare_Log::add( 'scan', 'Skipped post #' . $post->ID . ' (' . $e->getMessage() . ')' ); } } wp_reset_postdata(); return $pages; } /** Run the GEO checks on a single post and build its sub-scores + issues. */ private static function analyze_post( $post, $settings = array() ) { $content = (string) $post->post_content; $lc = strtolower( $content ); $id = 'wp' . $post->ID; $issues = array(); // --- Sub-score baselines (everything starts mid, checks subtract) --- $sub = array( 'schema' => 70, 'structure' => 70, 'crawl' => 85, 'authority' => 70, 'freshness' => 80 ); // Schema: look for JSON-LD / itemtype in content (Yoast/other emit in , so this is conservative). $has_schema = ( false !== strpos( $lc, 'application/ld+json' ) ) || ( false !== strpos( $lc, 'itemtype' ) ); if ( ! $has_schema ) { $sub['schema'] = 40; $issues[] = self::issue( $id . 'a', 'schema', 'high', 30, 'No Product / Review / ItemList schema', 'Add schema', 'No structured data detected. AI engines cannot parse your recommendations or ratings without it.', 'JSON-LD: ItemList + Review — validated ✓' ); } // Structure: HTML table. Auto-injected only if comparison data exists in the metabox. if ( false === strpos( $lc, 'ID, '_aicitare_table', true ) ); $issues[] = $has_table ? self::issue( $id . 'b', 'structure', 'med', 12, 'No HTML comparison table', 'Add table', 'Comparison data from the GEO Content Blocks metabox will be injected as a machine-readable table.', 'HTML comparison table added — machine-readable' ) : self::issue( $id . 'b', 'structure', 'med', 12, 'No HTML comparison table', 'Add data', 'Add comparison rows in the “GEO Content Blocks” metabox, then this becomes a one-click fix. The plugin never fabricates comparison data.', 'HTML comparison table added — machine-readable', true, true ); } // Structure: lists. Auto-injected only if pros/cons data exists in the metabox. if ( false === strpos( $lc, 'ID, '_aicitare_pros', true ) ) || '' !== trim( (string) get_post_meta( $post->ID, '_aicitare_cons', true ) ); $issues[] = $has_pc ? self::issue( $id . 'c', 'structure', 'low', 8, 'No lists (pros/cons, steps)', 'Add list', 'Pros/cons from the GEO Content Blocks metabox will be injected as a list block.', 'Pros / cons list block added' ) : self::issue( $id . 'c', 'structure', 'low', 8, 'No lists (pros/cons, steps)', 'Add data', 'Add Pros/Cons in the “GEO Content Blocks” metabox, then this becomes a one-click fix.', 'Pros / cons list block added', true, true ); } // Structure: FAQ. FAQPage schema is generated from in-content Q&A headings (h2/h3). if ( false === strpos( $lc, 'faq' ) && false === strpos( $lc, 'frequently asked' ) ) { $sub['structure'] -= 12; $has_qheads = (bool) preg_match( '#]*>[^<]*\?#i', $content ); // A heading phrased as a question. $issues[] = $has_qheads ? self::issue( $id . 'd', 'structure', 'med', 14, 'No FAQ section', 'Generate FAQ', 'FAQPage schema will be generated from this post’s question-style headings.', 'FAQ section + FAQPage schema added' ) : self::issue( $id . 'd', 'structure', 'med', 14, 'No FAQ section', 'Add Q&A', 'No question-style headings found. Add a short FAQ (question headings + answers) in the editor, then FAQPage schema is generated automatically.', 'FAQ section + FAQPage schema added', true, true ); } // Authority: author presence (very rough — single-author sites flagged for bios). $author_bio = get_the_author_meta( 'description', (int) $post->post_author ); if ( empty( $author_bio ) ) { $sub['authority'] -= 20; // The bio lives in the author's WordPress profile — it can't be fabricated. $profile_url = admin_url( 'user-edit.php?user_id=' . (int) $post->post_author ); $issues[] = self::issue( $id . 'e', 'authority', 'high', 20, 'No author bio or Person schema', 'Add bio', 'The author has no biography in their WordPress profile (Users → Profile → Biographical Info). Add one, then the author box + Person schema are generated automatically.', 'Author box + Person schema added', true, true, $profile_url ); } // Freshness: months since modified. Drafts often have an empty GMT modified // date (0000-00-00 → epoch 0), which would wrongly read as "decades old" — // fall back to the local modified time, then the publish date. $modified = (int) get_post_modified_time( 'U', true, $post ); if ( $modified < 946684800 ) { // Before year 2000 → treat as invalid. $modified = (int) get_post_modified_time( 'U', false, $post ); } if ( $modified < 946684800 ) { $modified = (int) get_post_time( 'U', false, $post ); } if ( $modified < 946684800 ) { $modified = time(); } $months = (int) floor( ( time() - $modified ) / ( 30 * DAY_IN_SECONDS ) ); if ( $months < 0 ) { $months = 0; } $stale = $months >= 9; if ( $months >= 6 ) { $sub['freshness'] = max( 30, 80 - ( $months * 3 ) ); $sev = $months >= 12 ? 'high' : 'med'; $gain = min( 30, 8 + $months ); $issues[] = self::issue( $id . 'f', 'freshness', $sev, $gain, "Content is {$months} months old", 'Refresh', 'Stale content is deprioritized by AI engines. Refresh and update dateModified.', 'Republished · dateModified set to today', true ); } // Crawl: llms.txt membership (handled globally) — soft nudge for high-value posts. $sub['crawl'] = max( 60, $sub['crawl'] ); // Link health: detect broken / dead outbound links & missing products. $broken = array(); $links = array(); if ( ! empty( $settings['check_links'] ) ) { // Scan the raw post content for links. We deliberately do NOT run the // full `the_content` filter here — invoking third-party content filters // in a REST/scan context can fatal on some sites. $links = AICitare_Links::audit_content( $content ); foreach ( $links as $l ) { if ( empty( $l['ok'] ) ) { $broken[] = array( 'url' => $l['url'], 'status' => $l['status'], 'reason' => $l['reason'] ); } } if ( ! empty( $broken ) ) { $n = count( $broken ); $penalty = min( 30, 8 + 6 * $n ); $sub['authority'] = max( 0, $sub['authority'] - $penalty ); $samples = array(); foreach ( array_slice( $broken, 0, 3 ) as $b ) { $samples[] = $b['url'] . ' (' . $b['reason'] . ')'; } $issues[] = self::issue( $id . 'lnk', 'authority', 'high', $penalty, $n . ' broken or dead link' . ( $n > 1 ? 's' : '' ), 'Mark reviewed', 'Outbound links that fail or point to missing products hurt trust and AI citation. Found: ' . implode( '; ', $samples ) . ( $n > 3 ? ' …' : '' ), 'All outbound links resolve (200) — re-checked', true ); } } // Image readiness for AI / social previews (featured image → schema ImageObject + og:image). if ( has_post_thumbnail( $post ) ) { $tid = get_post_thumbnail_id( $post->ID ); $src = wp_get_attachment_image_src( $tid, 'full' ); $w = ( is_array( $src ) && isset( $src[1] ) ) ? (int) $src[1] : 0; $ialt = trim( (string) get_post_meta( $tid, '_wp_attachment_image_alt', true ) ); if ( $w && $w < 1200 ) { $sub['structure'] = max( 0, $sub['structure'] - 8 ); $issues[] = self::issue( $id . 'imgw', 'structure', 'med', 8, 'Featured image is small (' . $w . 'px wide)', 'Replace image', 'AI Overviews and social previews favor images at least 1200px wide. This featured image is only ' . $w . 'px.', 'Featured image ≥1200px set', true ); } if ( '' === $ialt ) { $sub['structure'] = max( 0, $sub['structure'] - 6 ); $issues[] = self::issue( $id . 'imga', 'structure', 'low', 6, 'Featured image has no alt text', 'Add alt text', 'Alt text helps AI understand the image and associate it with the topic.', 'Alt text added to the featured image', true ); } } else { $sub['structure'] = max( 0, $sub['structure'] - 12 ); $issues[] = self::issue( $id . 'imgm', 'structure', 'med', 12, 'No featured image', 'Set featured image', 'A featured image feeds the schema ImageObject and og:image tag — required for image previews in AI and social results.', 'Featured image set', true ); } // Readability: very long sentences are harder for AI to extract cleanly. $plain = wp_strip_all_tags( $content ); $words = str_word_count( $plain ); $sentences = max( 1, preg_match_all( '/[.!?]+(\s|$)/', $plain ) ); $avg = $words / $sentences; if ( $words > 120 && $avg > 25 ) { $sub['structure'] = max( 0, $sub['structure'] - 6 ); $issues[] = self::issue( $id . 'read', 'structure', 'low', 6, 'Long sentences reduce readability (~' . round( $avg ) . ' words/sentence)', 'Shorten sentences', 'AI models extract short, clear sentences more reliably. Aim for roughly 15–20 words per sentence.', 'Sentences shortened for readability', true ); } // Clamp. foreach ( $sub as $k => $v ) { $sub[ $k ] = max( 0, min( 100, (int) $v ) ); } $is_draft = ( 'publish' !== $post->post_status ); return array( 'id' => $id, 'title' => get_the_title( $post ), 'url' => $is_draft ? get_preview_post_link( $post ) : wp_make_link_relative( get_permalink( $post ) ), 'updated' => human_time_diff( $modified, time() ) . ' ago', 'modifiedTs' => (int) $modified, 'stale' => $stale, 'status' => $post->post_status, 'isDraft' => $is_draft, 'editUrl' => admin_url( 'post.php?post=' . $post->ID . '&action=edit' ), 'excerpt' => wp_trim_words( wp_strip_all_tags( $content ), 55, '…' ), 'brokenLinks' => $broken, 'links' => $links, 'sub' => $sub, 'issues' => $issues, ); } private static function issue( $id, $cat, $sev, $gain, $title, $fix, $desc, $after, $manual = false, $needs_data = false, $fix_url = '' ) { return compact( 'id', 'cat', 'sev', 'gain', 'title', 'desc', 'after' ) + array( 'fixLabel' => $fix, 'manual' => (bool) $manual, 'needsData' => (bool) $needs_data, 'fixUrl' => $fix_url ); } /* ---------- Scoring helpers (authoritative, mirrors the spec formula) ---------- */ public static function live_sub( $page, $fixed ) { $s = $page['sub']; foreach ( $page['issues'] as $is ) { if ( ! empty( $fixed[ $is['id'] ] ) ) { $s[ $is['cat'] ] = min( 100, $s[ $is['cat'] ] + (int) $is['gain'] ); } } return $s; } public static function live_score( $page, $fixed ) { $s = self::live_sub( $page, $fixed ); $w = AICitare_Data::weights(); return (int) round( $s['schema'] * $w['schema'] + $s['structure'] * $w['structure'] + $s['crawl'] * $w['crawl'] + $s['authority'] * $w['authority'] + $s['freshness'] * $w['freshness'] ); } public static function site_score( $pages, $fixed ) { if ( empty( $pages ) ) { return 0; } $sum = 0; foreach ( $pages as $p ) { $sum += self::live_score( $p, $fixed ); } return (int) round( $sum / count( $pages ) ); } } // Cron handler for the scheduled background scan. add_action( 'aicitare_daily_scan', array( 'AICitare_Scanner', 'run' ) );