$t ) { $t = preg_replace( '/[^\w]/u', '', $t ); if ( $t === $subject ) { return $i; } } return -1; } /** * Normalize a heading for use as a stable dismissal key. Strips whitespace * and lowercases so trivial edits like extra spaces or capitalization don't * break the dismissal silently. If the user materially edits the heading, * the dismissal correctly stops applying (new heading, fresh scan). * * @param string $heading * @return string */ public static function normalize_heading( $heading ) { return strtolower( trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( (string) $heading ) ) ) ); } /** * Get the list of dismissed heading keys for a post. * * @param int $post_id * @return string[] */ public static function get_dismissed( $post_id ) { $raw = get_post_meta( (int) $post_id, self::DISMISS_KEY, true ); return is_array( $raw ) ? array_values( array_unique( array_map( 'strval', $raw ) ) ) : array(); } /** * @param int $post_id * @param string $heading * @return bool */ public static function is_dismissed( $post_id, $heading ) { $key = self::normalize_heading( $heading ); return $key !== '' && in_array( $key, self::get_dismissed( $post_id ), true ); } /** * User-asserted "this answer is fine, stop flagging it." Stored per heading * (not per post) so dismissing one heading on a post doesn't silence others. * Triggers a rescan so the score immediately reflects the dismissal. * * @param int $post_id * @param string $heading * @return array Updated scan result. */ public static function dismiss( $post_id, $heading ) { $key = self::normalize_heading( $heading ); if ( '' === $key ) { return array( 'error' => 'empty heading' ); } $list = self::get_dismissed( $post_id ); if ( ! in_array( $key, $list, true ) ) { $list[] = $key; update_post_meta( (int) $post_id, self::DISMISS_KEY, $list ); } return self::scan_post( (int) $post_id ); } /** * Reverse of dismiss(). Lets the user surface a heading again if they * change their mind. * * @param int $post_id * @param string $heading * @return array Updated scan result. */ public static function undismiss( $post_id, $heading ) { $key = self::normalize_heading( $heading ); if ( '' === $key ) { return array( 'error' => 'empty heading' ); } $list = self::get_dismissed( $post_id ); $list = array_values( array_filter( $list, function ( $k ) use ( $key ) { return $k !== $key; } ) ); update_post_meta( (int) $post_id, self::DISMISS_KEY, $list ); return self::scan_post( (int) $post_id ); } /** * Detect H2/H3 question-shaped headings. * * Returns an ordered list with byte offsets so extract_after_heading() * can slice the text between this heading and the next same-or-higher * heading. We deliberately do NOT stop at H4/H5: well-structured posts * use H4s for sub-points inside an answer block — under-counting valid * answers on those posts would punish the right behavior. * * @param string $html Raw post content. * @return array */ public static function find_question_headings( $html ) { $out = array(); if ( empty( $html ) ) { return $out; } // Match H2/H3 with their inner content. `$1` = level digit, `$2` = inner. if ( ! preg_match_all( '#]*>(.*?)#is', $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return $out; } foreach ( $matches as $m ) { $level = (int) $m[1][0]; $raw_inner = $m[2][0]; $text = trim( wp_strip_all_tags( $raw_inner ) ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); if ( $text === '' ) { continue; } if ( ! self::is_question( $text ) ) { continue; } $tag_offset = (int) $m[0][1]; $tag_length = strlen( $m[0][0] ); $out[] = array( 'level' => $level, 'text' => $text, 'offset' => $tag_offset, // start of opening heading tag 'end' => $tag_offset + $tag_length, // first byte after closing heading tag ); } return $out; } /** * Question-shape detector. Three signals — any one qualifies: * 1. Ends with `?` * 2. Starts with an interrogative word. * 3. Imperative "How to X" pattern even without `?` ("How to block GPTBot"). */ private static function is_question( $heading_text ) { $t = trim( $heading_text ); if ( $t === '' ) { return false; } if ( substr( $t, -1 ) === '?' ) { return true; } // Leading interrogatives — case-insensitive, word boundary. if ( preg_match( '/^(How|What|Why|When|Where|Who|Which|Can|Should|Is|Are|Does|Do|Will|Would|Could)\b/i', $t ) ) { return true; } return false; } /** * Slice the text that "answers" a heading: everything between this * heading and the next SAME-LEVEL OR HIGHER heading (per the spec * refinement — H3 questions can have H4 sub-headings inside the * answer block without us bailing early). We also stop at images and * tables because those are visual breaks. * * @param string $html Post content. * @param int $heading_end Byte offset right after the heading's . * @param int $heading_lvl 2 or 3. * @return string Sliced HTML body of the answer block. */ public static function extract_after_heading( $html, $heading_end, $heading_lvl ) { $tail = substr( $html, $heading_end ); // Find next same-level-or-higher heading. For an H3 question, that's // next H2 OR next H3 (NOT H4/H5). For an H2 question, just next H2. $level_class = $heading_lvl === 2 ? '2' : '2|3'; $boundary_re = '# 'no_answer', 'words_before_answer' => 0, 'first_sentence' => '' ); } // Numbered/bulleted list immediately after the heading (≤ 60 visible // chars of leading prose) is itself the answer. if ( preg_match( '/^[^.<\n]{0,60}<(ol|ul)\b/i', $body_html ) ) { return array( 'classification' => 'direct', 'words_before_answer' => 0, 'first_sentence' => self::extract_sentence( $plain, 0 ), ); } // Walk the first ~5 sentences and ANSWER_WINDOW_W words. Filler vetoes // direct: a sentence like "This means X" technically matches the // definitional pattern, but the pronoun subject ("This") needs prior // context that AI extraction can't resolve, so it's treated as // buried. The filler regex set is the user's curated list of those // pronoun-led / hedge-led openers that look definitional but aren't. $sentences = self::split_sentences( $plain ); $word_cursor = 0; foreach ( $sentences as $i => $sentence ) { $s = trim( $sentence ); if ( $s === '' ) { continue; } $is_answer = self::sentence_is_direct_answer( $s ); $is_filler = self::sentence_is_filler( $s ); if ( $is_answer && ! $is_filler ) { return array( 'classification' => $word_cursor === 0 ? 'direct' : 'buried', 'words_before_answer' => $word_cursor, 'first_sentence' => $sentences[0], ); } $word_cursor += str_word_count( $s ); if ( $word_cursor >= self::ANSWER_WINDOW_W ) { break; } if ( $i >= 5 ) { break; } } return array( 'classification' => 'no_answer', 'words_before_answer' => $word_cursor, 'first_sentence' => $sentences[0] ?? '', ); } /** * Direct-answer pattern set. A sentence qualifies if any of: * - Definitional: starts with a noun phrase + is/are/refers to/means. * - Affirmative: starts with Yes / No. * - Fact-first: contains a digit OR a date OR a proper-noun pair AND * is reasonably short (< 35 words — long sentences usually bury). */ private static function sentence_is_direct_answer( $s ) { // Subject character set: letters, digits, spaces, apostrophes, // hyphens, AND periods (for "robots.txt", "U.S.", file extensions, // version numbers). Without periods we miss legitimate definitional // openers like "The robots.txt file works on..." // Allow a single-letter leading word so article-led definitions like // "A citation is...", "An audit is...", "A brand mention is..." are // recognised, not downgraded to "could be more direct". The verb anchor // below keeps this from over-matching. $subject = "[A-Z][A-Za-z0-9\\s'\\-\\.]{1,50}"; // Definitional copulas: "X is Y", "This means Y", "AEO refers to Y". if ( preg_match( '/^' . $subject . '\s+(is|are|refers\s+to|means|stands\s+for|describes|equals|represents)\b/u', $s ) ) { return true; } // Action-verb thesis statements: "X works on Y", "Y uses Z", "X // requires N steps". A noun-phrase subject followed by a concrete // action verb that names what the thing DOES counts as a direct // answer for any "How X works", "What X does", "Why X happens" style // heading. Without this, sincere answers get downgraded to indirect. // Verb list is broad on purpose: a narrow list keeps surfacing real // answers as "no_answer" (e.g. "Perplexity selects sources..." when // `selects` wasn't on the list). Pronoun-led subjects (This, It, These) // are still caught by filler patterns below, so over-permissive matching // here doesn't leak buried-with-pronoun cases through. $action_verbs = implode( '|', array( // Action / process 'works','uses','applies','operates','handles','runs','executes','performs','processes','produces', 'returns','triggers','generates','sends','receives','stores','accepts','rejects','starts','begins', 'ends','starts','stops','launched','built','created','introduced','releases','provides','offers', 'powers','controls','manages','requires','allows','prevents','enables','disables','restricts','blocks', 'permits','limits','enforces','determines','defines','describes','indicates','signals','marks', // Selection / ranking / scoring (the bug we found) 'selects','chooses','picks','identifies','evaluates','prioritizes','prioritises','ranks','scores', 'rates','grades','measures','tracks','monitors','assesses','filters','sorts','orders','prefers','favors','favours', // Retrieval / fetching / crawling 'crawls','indexes','retrieves','fetches','reads','parses','scans','queries','searches','finds', 'collects','gathers','captures','records','logs','observes','detects', // Computation / output 'calculates','computes','analyses','analyzes','compares','aggregates','summarises','summarizes', 'displays','shows','renders','outputs','exports','reports','presents','surfaces','suggests', 'recommends','highlights','flags', // Schema / linking / referencing 'links','cites','mentions','references','points\s+to','maps\s+to','relies\s+on','depends\s+on', 'connects','integrates','communicates', // State changes 'updates','replaces','removes','adds','inserts','injects','prepends','appends','rewrites','edits', 'overwrites','syncs','synchronises','synchronizes','imports','installs','activates','deactivates', // Composition phrases 'consists\s+of','contains','includes','excludes', // Causal / outcome verbs — answers to "Why X ...", "What happens when ...", // "Why X fails". These were missing, so "Isolation exercises fail because..." // (a textbook direct answer) was scored as no_answer. 'fail','fails','fall','falls','break','breaks','cause','causes','lead','leads','result','results', 'happen','happens','occur','occurs','hurt','hurts','help','helps','improve','improves','increase','increases', 'decrease','decreases','reduce','reduces','drop','drops','boost','boosts','harm','harms','drive','drives', 'beat','beats','win','wins','lose','loses','matter','matters','suffer','suffers','struggle','struggles', 'succeed','succeeds','backfire','backfires','stall','stalls','plateau','plateaus','outperform','outperforms', ) ); if ( preg_match( '/^' . $subject . '\s+(' . $action_verbs . ')\b/u', $s ) ) { return true; } // Causal answer: a named subject explained with a causal connective answers a // "Why X ..." heading directly, whatever the main verb is. "Isolation exercises // fail because they only stress one muscle." Pronoun-led leads ("This is because...") // are still vetoed by the filler classifier, which runs separately. if ( preg_match( '/^' . $subject . '\b/u', $s ) && preg_match( '/\b(because|due\s+to|owing\s+to|thanks\s+to|the\s+reason|caused\s+by|results?\s+from|stems?\s+from|comes?\s+down\s+to|boils?\s+down\s+to|comes\s+from)\b/iu', $s ) ) { return true; } // Imperative how-to thesis: "Add X", "Use Y", "Set Z to N". if ( preg_match( '/^(Add|Use|Set|Configure|Install|Enable|Disable|Place|Put|Store|Pour|Grind|Brew|Write|Include|Choose|Pick|Select|Run|Block|Allow|Open|Close|Delete|Remove|Move|Edit|Update|Replace|Track|Treat|Keep|Avoid|Check|Focus|Aim|Start|Stop|Measure|Monitor|Watch|Test|Make|Ensure|Build|Create|Connect|Enter|Upload|Download|Copy|Paste|Schedule|Contact|Ask|Give|Take|Map|Match|Filter|Score|Compare|Limit|Prioriti[sz]e|Prefer|Send|Read|Scan|Audit|Verify|Review|Count|Tag|Name|Mark|Find|Stick)\s+[A-Z]?\w/u', $s ) ) { return true; } // Affirmative, including committed qualifiers. "Partly, and badly.", // "A little, ...", "Mostly", "Not really" all answer a yes/no or // how-much heading directly, even without a literal "Yes"/"No" lead. // (Hedges like "It depends" / "There are several" are vetoed later by // the filler classifier, so this stays safe.) if ( preg_match( '/^(Yes|No|Not\s+(really|quite|always|necessarily)|Partly|Mostly|Largely|Mainly|Sometimes|Rarely|Seldom|Usually|Often|Occasionally|Generally|Typically|Somewhat|Slightly|Almost|Barely|Hardly|Always|Never|A\s+(little|bit)|Kind\s+of|Sort\s+of)[\s,\-—:.]/i', $s ) ) { return true; } // Fact-first. Concrete numbers, dates, multi-cap proper nouns. Short. $word_count = str_word_count( $s ); if ( $word_count <= 35 ) { $has_digit = (bool) preg_match( '/\b\d/', $s ); $has_capital_pair = (bool) preg_match( '/\b[A-Z][a-zA-Z]+\s+[A-Z][a-zA-Z]+/', $s ); if ( $has_digit || $has_capital_pair ) { return true; } } return false; } /** * Filler / buried-answer opener detector. Augmented with the patterns * the user flagged as common WP buried-answer leads: * "So, when it comes to..." * "There are a few things to think about..." * "It depends..." * "Now, let's talk about..." * * NOTE: filler is checked AFTER the direct-answer pattern set, so a * sentence like "This means X" hits the definitional pattern first and * never reaches the `^This\s+(is|means)` filler check. */ private static function sentence_is_filler( $s ) { return self::classify_filler( $s ) !== ''; } /** * Classify the FLAVOR of filler so the to-do can give specific advice * instead of a generic "buried" warning. Returns one of: * 'filler' — content-free intros ("In today's...", "Picture this...") * 'setup' — defers the answer to a later sentence ("Before we dive in...", "First let's...") * 'hedge' — refuses to commit ("It depends", "There are several...") * '' — not filler * * Order matters: the more specific patterns (setup, hedge) win over the * generic filler bucket. */ public static function classify_filler( $s ) { // Setup — defers the answer to later. Highest priority. $setup = array( '/^(Before\s+(we|talking|going|jumping)|First,?\s+let|Let.?s\s+(start|begin|first))/i', '/^(To\s+(understand|appreciate|grasp)|In\s+order\s+to)\b/i', '/^(Welcome\s+to|Before\s+we\s+(dive|get)\s+in)\b/i', ); foreach ( $setup as $rx ) { if ( preg_match( $rx, $s ) ) { return 'setup'; } } // Hedge — refuses to give a clear answer. $hedge = array( '/^(It\s+depends|It\s+varies|There\s+(is|are)\s+(no|several|a\s+few|many))/i', '/^(This\s+depends|This\s+varies)/i', '/^(The\s+answer\s+(is|depends))/i', ); foreach ( $hedge as $rx ) { if ( preg_match( $rx, $s ) ) { return 'hedge'; } } // Generic filler — content-free intros. $filler = array( '/^(Let.?s|In\s+(today|this|the\s+world\s+of)|When\s+you\s+think\s+about|Picture\s+this|Imagine|Have\s+you\s+ever|It.?s\s+no\s+secret)\b/i', '/^(The\s+world\s+of)\b/i', '/^(So,?\s|So\s+what|Now,?\s)/i', '/^(This\s+(is|can\s+be|means))\b/i', '/^(It\s+(is|can\s+be))\b/i', ); foreach ( $filler as $rx ) { if ( preg_match( $rx, $s ) ) { return 'filler'; } } return ''; } /** * Body → plain text, capped at $word_limit words. Used as grounding * context for the AI-rewrite flow (gpt-4o-mini token budget is ~500). */ public static function body_to_first_paragraph( $html, $word_limit = 200 ) { $plain = self::body_to_plain( $html ); $plain = trim( $plain ); if ( $plain === '' ) { return ''; } $words = preg_split( '/\s+/u', $plain ); if ( count( $words ) > $word_limit ) { $words = array_slice( $words, 0, $word_limit ); $plain = implode( ' ', $words ) . '…'; } return $plain; } /** * Strip HTML, normalize entities, collapse whitespace. */ private static function body_to_plain( $html ) { $t = wp_strip_all_tags( $html, true ); $t = html_entity_decode( $t, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $t = preg_replace( '/\s+/u', ' ', $t ); return trim( $t ); } /** * Naive sentence splitter — good enough for our purposes (we only look * at the first 5 sentences and we never persist the splits). */ private static function split_sentences( $plain ) { $parts = preg_split( '/(?<=[\.\?\!])\s+(?=[A-Z0-9"\'])/u', $plain ); return $parts ?: array( $plain ); } private static function extract_sentence( $plain, $idx = 0 ) { $parts = self::split_sentences( $plain ); return isset( $parts[ $idx ] ) ? trim( $parts[ $idx ] ) : ''; } /** * Analyze rendered HTML without reading or writing WordPress state. * * The post scanner, generated-draft contract and external page auditor all * call this method so the direct-answer classifier and score math cannot * drift. User dismissals are an adapter concern and must be supplied * explicitly; an external page audit therefore never inherits editor state. * * @param string $content Rendered content HTML. * @param array $options Optional dismissed heading keys. * @return array Deterministic answer-density report. */ public static function scan_html( $content, $options = array() ) { $content = (string) $content; $word_count = str_word_count( wp_strip_all_tags( $content ) ); $headings = self::find_question_headings( $content ); $direct = 0; $first_sentence = 0; $buried_total = 0; $unanswered_total = 0; $dismissed_issue_count = 0; $words_before = array(); $issues = array(); $dismissed = array_values( array_unique( array_map( array( __CLASS__, 'normalize_heading' ), (array) ( $options['dismissed'] ?? array() ) ) ) ); foreach ( $headings as $h ) { // Dismissal is display state only. It may hide a to-do the editor has // consciously reviewed, but it must never manufacture a better raw // score or make the shared publishing gate disagree with the page. $is_dismissed = ! empty( $dismissed ) && in_array( self::normalize_heading( $h['text'] ), $dismissed, true ); $body = self::extract_after_heading( $content, $h['end'], $h['level'] ); $class = self::classify_answer( $body ); // Capture the first ~200 words of the body as plain text. The // AI-rewrite flow needs this as context: far more grounding than // the first sentence alone and cheap to store. // 500-word window. The AI rewrite prompt uses this as the // "what comes next, do not repeat" block. 200 words was thin: // a dense intro paragraph eats it on its own and the model // has nothing to know what to differ FROM, so it restated // content that already appeared below it. $first_paragraph = self::body_to_first_paragraph( $body, 500 ); // Classify the FLAVOR of the buried opener so the to-do can // prescribe a targeted fix (setup vs hedge vs filler vs no_answer). $opener_kind = self::classify_filler( $class['first_sentence'] ); if ( $class['classification'] === 'direct' ) { $direct++; if ( $class['words_before_answer'] === 0 ) { $first_sentence++; } $words_before[] = $class['words_before_answer']; } elseif ( $class['classification'] === 'buried' ) { $buried_total++; $words_before[] = $class['words_before_answer']; // Falling back from explicit filler-pattern matches to // "indirect" (rather than "setup") is honest: when the opener // isn't filler AND isn't direct, the writer attempted an // answer that just didn't lead with the thesis. Calling it // "Setup" was misleading — the sentence may not be a setup // at all, just an indirect angle. $issue = array( 'heading' => $h['text'], 'heading_level' => $h['level'], 'issue' => 'buried_answer', 'opener_kind' => $opener_kind ?: 'indirect', 'words_before_answer' => $class['words_before_answer'], 'first_sentence' => $class['first_sentence'], 'first_paragraph' => $first_paragraph, ); if ( $is_dismissed ) { $dismissed_issue_count++; } else { $issues[] = $issue; } } else { // No direct answer found at all in the first 50 words. // Still surface the opener_kind if filler patterns matched, // otherwise label as no_answer (truly missing). $unanswered_total++; $issue = array( 'heading' => $h['text'], 'heading_level' => $h['level'], 'issue' => 'no_direct_answer', 'opener_kind' => $opener_kind ?: 'no_answer', 'words_before_answer' => $class['words_before_answer'], 'first_sentence' => $class['first_sentence'], 'first_paragraph' => $first_paragraph, ); if ( $is_dismissed ) { $dismissed_issue_count++; } else { $issues[] = $issue; } } } $Q = count( $headings ); $A = $direct; $above_fold_ratio = $Q > 0 ? ( $first_sentence / $Q ) : 0.0; // Base ratio with partial credit for buried answers. // A buried answer is one where the answer exists in the body but isn't // in sentence 1 (the writer answered the heading, just took a roundabout // path). AI engines can still extract from there — it's worse than direct // but materially better than no_answer. Weighting 0.5 keeps the rubric // honest: full direct = 1.0, partial buried = 0.5, no_answer = 0.0. // Without partial credit, every "could be more direct" post collapses to // score 0 alongside posts that didn't answer the question at all, which // hides real improvement from AI Rewrite passes. $weighted_answers = $direct + ( $buried_total * 0.5 ); $ratio = $Q > 0 ? ( $weighted_answers / $Q ) : 0.0; $score = $ratio * 100; // Quality modifier. The base ratio already credits direct (1.0) and // buried (0.5) answers, so the score IS the share of question headings // that answer up front. We deliberately add NO per-answer or per-heading // bonuses: the old "+5 per above-fold answer" double-counted every direct // answer, and the flat "+10 for 3+ headings" let a post that answered // barely half its headings cap out at 100. Only a small penalty remains, // for answers that exist but sit deep in the section. $avg_before = $words_before ? array_sum( $words_before ) / count( $words_before ) : 0; if ( $avg_before > 30 ) { $score -= 8; } // Long-form fluff penalty — with the user's exemption: a deep tutorial // that nails one question with one above-fold answer is doing exactly // the right thing and should not be penalized. $single_topic_pass = ( $Q === 1 && $A === 1 && $above_fold_ratio === 1.0 ); if ( $word_count > 1500 && $Q < 2 && ! $single_topic_pass ) { $score -= 20; } $score = max( 0, min( 100, (int) round( $score ) ) ); return array( 'score_version' => self::SCORE_VERSION, 'word_count' => (int) $word_count, 'question_headings' => $Q, 'direct_answers' => $A, 'buried_answers' => $buried_total, 'unanswered_answers' => $unanswered_total, 'dismissed_issues' => $dismissed_issue_count, 'answer_density_score' => $score, 'applicable' => $Q > 0, 'issues' => $issues, 'above_fold_ratio' => round( $above_fold_ratio, 2 ), ); } /** * Score a single post and persist to postmeta. * * @param int $post_id Post ID. * @param string|null $content Optional already-rendered content. Supplying * it avoids running the_content twice when a * caller also needs the rendered-page report. * @return array Same JSON shape returned by the REST endpoint. */ public static function scan_post( $post_id, $content = null ) { $post = get_post( $post_id ); // Analyze any real, editable post (drafts/pending/private included) so the // editor panel scores work-in-progress. Only skip non-content statuses. if ( ! $post || in_array( $post->post_status, array( 'trash', 'auto-draft', 'inherit' ), true ) ) { return array( 'post_id' => (int) $post_id, 'score_version' => self::SCORE_VERSION, 'scanned_at' => gmdate( 'c' ), 'word_count' => 0, 'question_headings' => 0, 'direct_answers' => 0, 'buried_answers' => 0, 'unanswered_answers' => 0, 'dismissed_issues' => 0, 'answer_density_score' => 0, 'applicable' => false, 'issues' => array(), 'above_fold_ratio' => 0, ); } // "the_content" is a core WordPress filter; we are intentionally invoking // it so shortcodes / blocks render before the pure analyzer sees them. $content = null === $content ? ( class_exists( __NAMESPACE__ . '\\ContentGaps' ) ? ContentGaps::render_post_snapshot( $post ) : (string) apply_filters( 'the_content', $post->post_content ) ) // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound : (string) $content; $result = self::scan_html( $content, array( 'dismissed' => self::get_dismissed( $post_id ) ) ); $result = array_merge( array( 'post_id' => (int) $post_id, 'scanned_at' => gmdate( 'c' ), ), $result ); update_post_meta( $post_id, self::POSTMETA_KEY, $result ); // Lightweight numeric stamp used purely for rotation ordering. Lets the // nightly cron find never-scanned and least-recently-scanned posts with // a plain indexed meta query instead of unserializing every scan row. update_post_meta( $post_id, self::SCAN_TS_KEY, time() ); // Invalidate both aggregate caches: their score version can still be // current while their rows no longer match this saved post. wp_cache_delete( self::ROWS_CACHE_KEY, self::CACHE_GROUP ); delete_option( self::SUMMARY_OPT ); return $result; } /** * Whether a persisted result belongs to the current scoring contract. * * Missing versions are stale too: scans written before SCORE_VERSION was * introduced must never be mixed with results from the current detector. * * @param mixed $result Persisted Answer Density result. * @return bool */ public static function is_current_result( $result ) { return is_array( $result ) && isset( $result['score_version'] ) && self::SCORE_VERSION === (int) $result['score_version']; } /** * Remove a stale persisted scan and every cache derived from it. * * Deleting the rotation stamp makes the post a never-scanned target for the * next background pass if a caller only reads (rather than immediately * rescans) it. * * @param int $post_id Post ID. * @param array $stale_result Exact stale value that was read. */ private static function invalidate_persisted_result( $post_id, $stale_result ) { // Match the value we read so a concurrent current-version scan cannot be // deleted between get_post_meta() and this invalidation. $deleted = delete_post_meta( $post_id, self::POSTMETA_KEY, $stale_result ); if ( $deleted ) { delete_post_meta( $post_id, self::SCAN_TS_KEY ); } wp_cache_delete( self::ROWS_CACHE_KEY, self::CACHE_GROUP ); delete_option( self::SUMMARY_OPT ); } /** * Read the persisted scan for a post (does not trigger a fresh scan). * * Stale scoring-contract results are invalidated and reported as a cache * miss so API callers can recompute them with the current detector. */ public static function get_for_post( $post_id ) { $saved = get_post_meta( $post_id, self::POSTMETA_KEY, true ); if ( ! is_array( $saved ) ) { return null; } if ( ! self::is_current_result( $saved ) ) { self::invalidate_persisted_result( (int) $post_id, $saved ); return null; } return $saved; } /** * Full-catalog scan — the manual "Re-scan" path. * * Walks EVERY published post and page, scanning in a time-budgeted loop so a * single request never runs away. Whatever can't be reached inside the * budget is queued and a background event drains the rest, chaining until * the entire catalog is covered. Coverage is complete regardless of how many * posts the site has — the old fixed 50-post cap that left larger sites * partially graded is gone. * * @return array Site summary reflecting the synchronous portion of the pass. */ public static function scan_all_posts() { $ids = self::all_published_ids(); $total = count( $ids ); self::run_scan_pass( $ids, self::time_budget() ); $pending = get_option( self::QUEUE_OPT, array() ); $pending = is_array( $pending ) ? count( $pending ) : 0; $summary = self::get_summary(); $summary['scan_progress'] = array( 'complete' => 0 === $pending, 'checked' => max( 0, $total - $pending ), 'total' => $total, 'remaining' => $pending, ); return $summary; } /** * Background continuation of a full pass. Drains the queued tail the same * time-budgeted way and reschedules itself until the queue is empty. */ public static function continue_scan() { $ids = get_option( self::QUEUE_OPT, array() ); if ( empty( $ids ) || ! is_array( $ids ) ) { delete_option( self::QUEUE_OPT ); return; } self::run_scan_pass( $ids, self::time_budget() ); } /** * Nightly rotation — the cron path. * * Refreshes up to BATCH_SIZE posts per night, never-scanned first and then * least-recently-scanned, so the whole catalog cycles through over a few * nights without re-grading the same set every run. Any site left * under-covered by an older build self-heals within a few nightly cycles. */ public static function scan_rotation() { foreach ( self::rotation_targets( self::BATCH_SIZE ) as $pid ) { self::scan_post( (int) $pid ); } self::refresh_summary(); } /** * Scan a list of post IDs within a wall-clock budget. IDs not reached are * persisted to the queue and a single background event is scheduled to * continue. Always refreshes the summary so the dashboard tracks progress * after each chunk. * * @param int[] $ids * @param float $budget Seconds of wall-clock to spend this request. */ private static function run_scan_pass( $ids, $budget ) { $ids = array_values( array_unique( array_map( 'intval', (array) $ids ) ) ); $start = microtime( true ); while ( ! empty( $ids ) ) { self::scan_post( (int) array_shift( $ids ) ); if ( ( microtime( true ) - $start ) >= $budget ) { break; } } if ( ! empty( $ids ) ) { // Tail remains — persist it and schedule one continuation. The // wp_next_scheduled guard keeps a single chain even if traffic // fires cron while a continuation is already pending. update_option( self::QUEUE_OPT, array_values( $ids ), false ); if ( ! wp_next_scheduled( self::CONTINUE_HOOK ) ) { wp_schedule_single_event( time() + 30, self::CONTINUE_HOOK ); } } else { delete_option( self::QUEUE_OPT ); } self::refresh_summary(); } /** * Every published post + page ID, oldest-modified first. IDs only, so this * stays cheap even on catalogs with thousands of entries. * * @return int[] */ private static function all_published_ids() { $ids = get_posts( array( 'post_type' => array( 'post', 'page' ), 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'modified', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'suppress_filters' => true, ) ); return array_map( 'intval', (array) $ids ); } /** * Rotation targets for the nightly cron: never-scanned posts first (no scan * stamp yet), then the least-recently-scanned, up to $limit. Two plain * indexed meta queries — no full-table unserialization. * * @param int $limit * @return int[] */ private static function rotation_targets( $limit ) { $limit = max( 1, (int) $limit ); // 1) Never-scanned posts (no rotation stamp) take priority. $never = get_posts( array( 'post_type' => array( 'post', 'page' ), 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'modified', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, 'meta_query' => array( array( 'key' => self::SCAN_TS_KEY, 'compare' => 'NOT EXISTS' ), ), ) ); $never = array_map( 'intval', (array) $never ); if ( count( $never ) >= $limit ) { return array_slice( $never, 0, $limit ); } // 2) Fill the remainder with the oldest-scanned posts. The meta_key + // meta_value_num orderby inner-joins on the stamp, so this returns only // already-scanned posts — no overlap with step 1. $old = get_posts( array( 'post_type' => array( 'post', 'page' ), 'post_status' => 'publish', 'posts_per_page' => $limit - count( $never ), 'orderby' => 'meta_value_num', 'meta_key' => self::SCAN_TS_KEY, 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true, 'suppress_filters' => true, ) ); $old = array_map( 'intval', (array) $old ); return array_values( array_unique( array_merge( $never, $old ) ) ); } /** * Wall-clock budget for one synchronous scan request. Uses most of the * host's max_execution_time but stays well under common proxy/CDN timeouts * so the Re-scan call returns promptly; the tail (if any) finishes in the * background. * * @return float Seconds. */ private static function time_budget() { $max = (int) ini_get( 'max_execution_time' ); // 0 = unlimited (CLI / some hosts). if ( $max <= 0 ) { return 25.0; } return max( 8.0, min( 25.0, $max * 0.6 ) ); } /** * Aggregate across all scanned posts. Cached in the summary option so * the dashboard read is O(1). */ public static function refresh_summary() { global $wpdb; // Cache the postmeta scan for 5 minutes. The dashboard hits this // repeatedly on render; the underlying data only changes when a post // is scanned. flush_summary_cache() invalidates it after each scan. $cache_key = self::ROWS_CACHE_KEY; $rows = wp_cache_get( $cache_key, self::CACHE_GROUP ); if ( false === $rows ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->prepare( "SELECT pm.post_id, pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_status = %s AND p.post_type IN ( %s, %s )", self::POSTMETA_KEY, 'publish', 'post', 'page' ) ); wp_cache_set( $cache_key, $rows, self::CACHE_GROUP, 5 * MINUTE_IN_SECONDS ); } $total_scanned = 0; $applicable_posts = 0; // posts that have at least 1 question heading $na_posts = 0; // posts with no question headings (not graded) $sum_score = 0; $weak = 0; $weakest = array(); $last_scan = ''; foreach ( (array) $rows as $r ) { $data = maybe_unserialize( $r->meta_value ); if ( ! is_array( $data ) ) { continue; } if ( ! self::is_current_result( $data ) ) { // Never blend old score contracts into the current aggregate. Avoid // deleting thousands of rows synchronously here; post reads replace // them on demand and the bounded rotation refreshes the remainder. continue; } $total_scanned++; // A completed pass must update the dashboard time even when the most // recently scanned page has no question-style heading and is therefore // not scored. Previously those rows were skipped before their timestamp // was considered, making a successful Re-scan appear to do nothing. if ( ! empty( $data['scanned_at'] ) && $data['scanned_at'] > $last_scan ) { $last_scan = $data['scanned_at']; } $Q = isset( $data['question_headings'] ) ? (int) $data['question_headings'] : 0; $score = isset( $data['answer_density_score'] ) ? (int) $data['answer_density_score'] : 0; // Posts with no question headings aren't informational Q&A pages — // e.g. Pricing, Blog index, Cart. Don't grade them, don't average // them into the site score, don't list them as "weakest." They // simply have no direct-answer surface. if ( $Q === 0 ) { $na_posts++; continue; } $applicable_posts++; $sum_score += $score; if ( $score < 50 ) { $weak++; } $direct = isset( $data['direct_answers'] ) ? (int) $data['direct_answers'] : 0; $buried = isset( $data['buried_answers'] ) ? (int) $data['buried_answers'] : 0; $missing = max( 0, $Q - $direct - $buried ); // One-line "issue summary" for the dashboard row — readable. $bits = array(); if ( $missing > 0 ) { $bits[] = $missing . ' missing'; } if ( $buried > 0 ) { $bits[] = $buried . ' buried'; } if ( $direct > 0 ) { $bits[] = $direct . ' direct'; } // Pull the first issue so the dashboard's to-do list can show // the specific heading + buried opener + classification // without an extra round trip per row. $first_issue = null; if ( ! empty( $data['issues'] ) && is_array( $data['issues'] ) ) { $f = $data['issues'][0]; $first_issue = array( 'heading' => isset( $f['heading'] ) ? (string) $f['heading'] : '', 'heading_level' => isset( $f['heading_level'] ) ? (int) $f['heading_level'] : 0, 'issue' => isset( $f['issue'] ) ? (string) $f['issue'] : '', 'opener_kind' => isset( $f['opener_kind'] ) ? (string) $f['opener_kind'] : '', 'words_before_answer' => isset( $f['words_before_answer'] ) ? (int) $f['words_before_answer'] : 0, 'first_sentence' => isset( $f['first_sentence'] ) ? (string) $f['first_sentence'] : '', ); } $weakest[] = array( 'post_id' => (int) $r->post_id, 'score' => $score, 'question_headings' => $Q, 'direct_answers' => $direct, 'buried_answers' => $buried, 'missing_answers' => $missing, 'summary' => implode( ' · ', $bits ), 'first_issue' => $first_issue, ); } usort( $weakest, function ( $a, $b ) { return $a['score'] - $b['score']; } ); $weakest = array_slice( $weakest, 0, 10 ); // Status label so the dashboard doesn't have to invent one. $avg = $applicable_posts > 0 ? (int) round( $sum_score / $applicable_posts ) : 0; if ( $applicable_posts === 0 ) { $status = 'no_data'; } elseif ( $avg >= 75 ) { $status = 'excellent'; } elseif ( $avg >= 60 ) { $status = 'good'; } elseif ( $avg >= 40 ) { $status = 'needs_work'; } else { $status = 'weak'; } $summary = array( 'score_version' => self::SCORE_VERSION, 'avg_score' => $avg, 'status' => $status, 'applicable_posts' => $applicable_posts, 'na_posts' => $na_posts, 'total_scanned' => $total_scanned, 'weak_posts' => $weak, 'weakest' => $weakest, 'last_scanned_at' => $last_scan, 'refreshed_at' => gmdate( 'c' ), ); update_option( self::SUMMARY_OPT, $summary, false ); return $summary; } public static function get_summary() { $s = get_option( self::SUMMARY_OPT, array() ); if ( ! is_array( $s ) || empty( $s ) ) { return array(); } if ( ! isset( $s['score_version'] ) || self::SCORE_VERSION !== (int) $s['score_version'] ) { delete_option( self::SUMMARY_OPT ); return array(); } return $s; } /** * Bootstrap: cron + on-save scanning. Called from class-main.php. */ public static function init() { // Nightly rotation (bounded + self-healing). add_action( self::CRON_HOOK, array( __CLASS__, 'scan_rotation' ) ); if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + 600, 'daily', self::CRON_HOOK ); } // Background drain of a full (manual) pass on large catalogs. add_action( self::CONTINUE_HOOK, array( __CLASS__, 'continue_scan' ) ); // On-save: scan changed posts immediately so the editor panel sees // the new score on next refresh. add_action( 'save_post', function ( $post_id, $post, $update ) { if ( wp_is_post_revision( $post_id ) ) { return; } if ( wp_is_post_autosave( $post_id ) ) { return; } if ( ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) { return; } // Draft Quality and the editor sidebar score work-in-progress, so their // persisted Answer Density input must refresh on ordinary draft, // pending, private and published saves alike. Only non-content states // are excluded; scan_post() deliberately follows the same contract. if ( in_array( $post->post_status, array( 'trash', 'auto-draft', 'inherit' ), true ) ) { return; } self::scan_post( $post_id ); }, 20, 3 ); } }