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' ) );