*/ public static function available_post_types() { $objects = get_post_types( array( 'public' => true, 'show_ui' => true ), 'objects' ); $out = array(); /* * Scaffolding, not content. WordPress and the page builders register * public post types for reusable parts, saved templates and widgets. * A saved template is rendered INTO a real page, so scoring it on its * own would double count the same words and hand the owner a list of * fragments to "improve" that nobody ever reads directly. Elementor * pages are ordinary pages and are already covered by 'page'. */ $blocked = array( // WordPress core. 'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles', 'wp_font_family', 'wp_font_face', // Elementor. 'elementor_library', 'e-floating-buttons', 'e-landing-page', // Beaver Builder, Bricks, Divi, WPBakery, Oxygen, Brizy. 'fl-builder-template', 'fl-theme-layout', 'bricks_template', 'et_pb_layout', 'vc_grid_item', 'ct_template', 'brizy_template', // Commerce and forms scaffolding that carries no answerable prose. 'product_variation', 'shop_order', 'shop_coupon', 'wpforms', 'wpcf7_contact_form', ); /** * Filter the post types hidden from the content scope picker. * * @param array $blocked Post type names treated as scaffolding. */ $blocked = (array) apply_filters( 'asgm_content_scope_blocked_types', $blocked ); foreach ( (array) $objects as $name => $object ) { $name = sanitize_key( $name ); if ( in_array( $name, $blocked, true ) ) { continue; } $counts = wp_count_posts( $name ); $out[] = array( 'name' => $name, 'label' => isset( $object->labels->name ) ? (string) $object->labels->name : (string) $object->label, 'count' => isset( $counts->publish ) ? (int) $counts->publish : 0, 'builtin' => ! empty( $object->_builtin ), ); } usort( $out, static function ( $a, $b ) { $order = array( 'post' => 0, 'page' => 1 ); $ao = $order[ $a['name'] ] ?? 2; $bo = $order[ $b['name'] ] ?? 2; return $ao === $bo ? strcasecmp( $a['label'], $b['label'] ) : $ao - $bo; } ); return $out; } /** Return the saved, valid content types used by content scoring tools. */ public static function selected_post_types( $settings = null ) { $settings = is_array( $settings ) ? $settings : get_option( 'asgm_settings', array() ); $available = wp_list_pluck( self::available_post_types(), 'name' ); $defaults = array_values( array_intersect( array( 'post', 'page' ), $available ) ); if ( empty( $defaults ) && ! empty( $available ) ) { $defaults[] = reset( $available ); } $saved = array_key_exists( 'content_scope_post_types', $settings ) ? (array) $settings['content_scope_post_types'] : $defaults; $selected = array_values( array_intersect( $available, array_unique( array_map( 'sanitize_key', $saved ) ) ) ); return ! empty( $selected ) ? $selected : $defaults; } /** Add detected-type metadata to settings returned to the admin app. */ public static function settings_payload( $settings ) { $settings = is_array( $settings ) ? $settings : array(); $settings['content_scope_post_types'] = self::selected_post_types( $settings ); $settings['_content_scope'] = array( 'available' => self::available_post_types() ); return $settings; } /** Sanitize the persisted content-scope setting and remove response metadata. */ public static function sanitize_settings_scope( $settings ) { $settings = is_array( $settings ) ? $settings : array(); unset( $settings['_content_scope'] ); $settings['content_scope_post_types'] = self::selected_post_types( $settings ); return $settings; } /** Invalidate the aggregate and queue the selected population for rescoring. */ public static function handle_scope_change() { wp_cache_delete( self::ROWS_CACHE_KEY, self::CACHE_GROUP ); delete_option( self::SUMMARY_OPT ); $ids = self::all_published_ids(); if ( ! empty( $ids ) ) { update_option( self::QUEUE_OPT, $ids, false ); if ( ! wp_next_scheduled( self::CONTINUE_HOOK ) ) { wp_schedule_single_event( time() + 5, self::CONTINUE_HOOK ); } } else { delete_option( self::QUEUE_OPT ); } self::refresh_summary(); } /** * Containment overlap of A inside B at the trigram level. * * Returns the share of A's trigrams that already appear in B. Asymmetric on * purpose: we want to know "is the opener paraphrasing the body" (A=opener, * B=body), not how similar two arbitrary strings are. Threshold for the * `direct_but_derivative` verdict is intentionally provisional. Ship the * raw score and let real usage tune the cutoff. * * @param string $a The candidate (e.g. AI rewrite). * @param string $b The reference (e.g. body context below the opener). * @return float 0.0 (no overlap) to 1.0 (every trigram already in B). */ public static function trigram_overlap_containment( $a, $b ) { $trigrams = function ( $text ) { $text = strtolower( wp_strip_all_tags( (string) $text ) ); $text = preg_replace( '/[^\w\s]+/u', ' ', $text ); $words = preg_split( '/\s+/', trim( $text ) ); $words = array_values( array_filter( $words ) ); $out = array(); $n = count( $words ); for ( $i = 0; $i + 2 < $n; $i++ ) { $out[] = $words[ $i ] . ' ' . $words[ $i + 1 ] . ' ' . $words[ $i + 2 ]; } return array_unique( $out ); }; $A = $trigrams( $a ); if ( empty( $A ) ) { return 0.0; } $B = array_flip( $trigrams( $b ) ); $hits = 0; foreach ( $A as $t ) { if ( isset( $B[ $t ] ) ) { ++$hits; } } return round( $hits / count( $A ), 3 ); } /** * Pull the most likely "subject" word from a question-shaped heading. Skips * the leading interrogative ("how", "what", etc.) and a small set of * function words, then returns the first remaining content token. Naive on * purpose. Used as a soft signal (subject_position), not as logic. If the * heuristic misses the subject, the emitted position is just -1, which the * UI renders as "n/a". No fake precision. * * @param string $heading * @return string Lowercase subject token, or '' if no candidate found. */ public static function find_heading_subject( $heading ) { $skip = array( 'how','what','why','when','where','who','which','should','can','could','do','does','did','is','are','was','were', 'a','an','the','to','of','for','in','on','at','from','by','with','about','as','into','your','our','my', ); $tokens = preg_split( '/\s+/', strtolower( trim( wp_strip_all_tags( (string) $heading ) ) ) ); foreach ( (array) $tokens as $t ) { $t = preg_replace( '/[^\w]/u', '', $t ); if ( '' === $t ) { continue; } if ( ! in_array( $t, $skip, true ) ) { return $t; } } return ''; } /** * Word index where $subject first appears in $text (case-insensitive). -1 * if the subject doesn't appear at all. The classifier's "Direct" verdict * is most trustworthy when subject_position is small (0 to 5). * * @param string $text * @param string $subject Lowercase token from find_heading_subject(). * @return int */ public static function subject_position_in( $text, $subject ) { if ( '' === $subject ) { return -1; } $tokens = preg_split( '/\s+/', strtolower( wp_strip_all_tags( (string) $text ) ) ); foreach ( (array) $tokens as $i => $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 ) ) ) ); } /** * Explain why a post should not enter the site Answer Density score. * * Answer Density measures search-facing, answer-led content. WordPress * utility screens (account, checkout, login, legal pages, and similar) are * functional destinations rather than pages written to answer a search * query. Explicitly noindexed content is also outside the search-facing * population. Scanning these pages is useful for coverage, but grading them * would make the site score less truthful. * * The result is filterable so unusual WordPress builds can add or remove a * classification without replacing the detector. * * @param \WP_Post|int|null $post Post object or ID. * @return string Empty when eligible; otherwise a stable reason key. */ public static function exclusion_reason( $post ) { $post = is_numeric( $post ) ? get_post( (int) $post ) : $post; if ( ! $post instanceof \WP_Post ) { return ''; } $reason = in_array( $post->post_type, self::selected_post_types(), true ) ? '' : 'content_type_excluded'; // Respect explicit search-engine exclusions set by the two most common // SEO plugins. Rank Math stores an array; Yoast stores a scalar flag. $yoast_noindex = strtolower( trim( (string) get_post_meta( $post->ID, '_yoast_wpseo_meta-robots-noindex', true ) ) ); $rank_robots = get_post_meta( $post->ID, 'rank_math_robots', true ); $rank_noindex = is_array( $rank_robots ) ? in_array( 'noindex', array_map( 'strtolower', array_map( 'strval', $rank_robots ) ), true ) : false !== stripos( (string) $rank_robots, 'noindex' ); if ( in_array( $yoast_noindex, array( '1', 'noindex' ), true ) || $rank_noindex ) { $reason = 'noindex'; } if ( '' === $reason && 'page' === $post->post_type ) { $utility_ids = array_filter( array( (int) get_option( 'wp_page_for_privacy_policy', 0 ), ) ); // WooCommerce assigns these pages by ID, which is more reliable than // guessing from translated or customized slugs. if ( function_exists( 'wc_get_page_id' ) ) { foreach ( array( 'cart', 'checkout', 'myaccount', 'terms' ) as $wc_page ) { $wc_id = (int) wc_get_page_id( $wc_page ); if ( $wc_id > 0 ) { $utility_ids[] = $wc_id; } } } // Easy Digital Downloads uses configured page IDs for the same class // of transactional screens. if ( function_exists( 'edd_get_option' ) ) { foreach ( array( 'purchase_page', 'success_page', 'failure_page', 'purchase_history_page' ) as $edd_page ) { $edd_id = (int) edd_get_option( $edd_page, 0 ); if ( $edd_id > 0 ) { $utility_ids[] = $edd_id; } } } if ( in_array( (int) $post->ID, array_unique( array_map( 'intval', $utility_ids ) ), true ) ) { $reason = 'utility_page'; } // Catch ordinary and custom utility pages that are not registered by a // commerce plugin. Segment boundaries avoid excluding editorial pages // such as /accounting-guide/. if ( '' === $reason ) { $utility_slug = '#(^|-)(privacy|terms|conditions|cookie|cookies|legal|disclaimer|refund|returns|checkout|cart|basket|account|my-account|login|log-in|register|signup|sign-up|thank|thanks|confirmation|receipt|order|orders|transaction|affiliate|affiliates|contact|sitemap|success|failed|password|reset-password|unsubscribe)(-|$)#i'; if ( preg_match( $utility_slug, (string) $post->post_name ) ) { $reason = 'utility_page'; } } // Shortcode detection covers sites that renamed the assigned page or // created the flow manually. if ( '' === $reason ) { $utility_shortcodes = array( 'woocommerce_cart', 'woocommerce_checkout', 'woocommerce_my_account', 'download_checkout', 'purchase_history', 'edd_receipt', 'edd_login', 'edd_register', ); foreach ( $utility_shortcodes as $shortcode ) { if ( has_shortcode( (string) $post->post_content, $shortcode ) ) { $reason = 'utility_page'; break; } } } } /** * Filter the Answer Density exclusion reason for one post. * * Return an empty string to include the post, or a stable reason key to * exclude it from the aggregate score and work queue. * * @param string $reason Exclusion reason, or empty when eligible. * @param \WP_Post $post Post being classified. */ return (string) apply_filters( 'asgm_answer_density_exclusion_reason', $reason, $post ); } /** * 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 review queues immediately reflect the dismissal. The * raw score is deliberately unchanged: hiding a suggestion must not create * a better Answer Density score without changing the page. * * @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 ); } /** * Hide every currently actionable heading for a post from the dashboard. * * The dashboard presents one row per post, so its Hide action must hide the * row rather than silently advance to the post's next flagged heading. The * raw score remains unchanged; this is task-list state, not score inflation. * * @param int $post_id Post ID. * @return array Updated scan result. */ public static function dismiss_all( $post_id ) { $post_id = (int) $post_id; $result = get_post_meta( $post_id, self::POSTMETA_KEY, true ); if ( ! self::is_current_result( $result ) ) { $result = self::scan_post( $post_id ); } $list = self::get_dismissed( $post_id ); foreach ( (array) ( $result['issues'] ?? array() ) as $issue ) { $key = self::normalize_heading( (string) ( $issue['heading'] ?? '' ) ); if ( '' !== $key && ! in_array( $key, $list, true ) ) { $list[] = $key; } } update_post_meta( $post_id, self::DISMISS_KEY, array_values( array_unique( $list ) ) ); return self::scan_post( $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 ); } /** * Restore every hidden Answer Density suggestion for a post. * * Dashboard rows are hidden as a whole, so restoring them must also be a * post-level action. Deleting the preference does not alter content; the * following scan simply makes the original suggestions actionable again. * * @param int $post_id Post ID. * @return array Updated scan result. */ public static function undismiss_all( $post_id ) { $post_id = (int) $post_id; delete_post_meta( $post_id, self::DISMISS_KEY ); return self::scan_post( $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 ); $opener_structure = self::opener_structure( $tail ); // 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' => '' ); } // A numbered/bulleted list immediately after the heading is itself the // answer. Gutenberg serializer comments sit between the heading and the // actual
    /
      ; testing the raw HTML directly made those lists look // like one long, unanswered paragraph and offered to rewrite them. if ( in_array( self::opener_structure( $body_html ), array( 'list', 'table' ), true ) ) { 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; } self::$last_reason = ''; $is_answer = self::sentence_is_direct_answer( $s, $heading ); $is_filler = self::sentence_is_filler( $s ); list( $is_answer, $is_filler ) = self::calibrate( $s, (string) $heading, (int) $i, $is_answer, $is_filler ); 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] ?? '', ); } /** * Calibration rules, checked against a hand-reviewed sample of question * headings. Together they made the check agree with a careful human * reader far more often. * * 1. "X is Y", action-verb and fact-first sentences must share a content * word with the heading; shape alone let intro sentences pass. * 2. Hedges and signposts ("It depends", "Here is") never count. * 3. A short first sentence under a "?" heading is a real answer. * 4. Wider yes/no openers, "It is a..." under What/Who, and more * imperative verbs count as direct. * * @return array{0:bool,1:bool} [ is_answer, is_filler ] */ private static function calibrate( $s, $heading, $index, $is_answer, $is_filler ) { $why = self::$last_reason; if ( $is_answer && in_array( $why, array( 'copula', 'action_verb', 'fact_first' ), true ) && '' !== $heading && ! self::shares_heading_word( $heading, $s ) ) { $is_answer = false; } if ( preg_match( '/^(The\s+)?(\w+\s+){0,2}(answer|match|choice|option|right\s+\w+)\s+(depends|varies)\b|^(That|This|It)\s+depends\b|^Here\s+(is|are|.s)\b|^Use\s+this\b|^(The\s+)?(useful|important|interesting|key)\s+(part|thing|bit)\s+is\s+what\b|^(Understanding|Knowing)\b/i', $s ) ) { $is_answer = false; $is_filler = true; } $is_question = '?' === substr( trim( $heading ), -1 ); if ( ! $is_answer && 0 === $index && $is_question && str_word_count( $s ) <= 8 && ! $is_filler ) { $is_answer = true; } if ( ! $is_answer && preg_match( '/^(Absolutely|Definitely|Certainly|Genuinely|Not\s+(for|if|unless|yet|by|in|on|at|much)|Only\s+(if|when)|You\s+do|Yours|Both|Neither|Either)\b/i', $s ) ) { $is_answer = true; } if ( 0 === $index && preg_match( '/^(what|who)\s+(is|are)\b/i', $heading ) && preg_match( '/^(It|They)\s+(is|are|.s)\s+(a|an|the|\w+ing)\b/i', $s ) ) { $is_answer = true; $is_filler = false; } if ( ! $is_answer && 0 === $index && preg_match( '/^(Fix|Plan|Answer|Treat|Think|Go|Try|Look|Let|Pay|Call|Leave|Turn|Switch|Wait|Expect|Budget|Draft|Publish|Post|Link|Point|Log|Sign)\s/', $s ) ) { $is_answer = true; } return array( $is_answer, $is_filler ); } /** Whether the first 15 words of a sentence share a content word with the heading. */ private static function shares_heading_word( $heading, $sentence ) { $words = implode( ' ', array_slice( preg_split( '/\s+/', (string) $sentence ), 0, 15 ) ); return (bool) array_intersect( self::content_words( $heading ), self::content_words( $words ) ); } /** Content-word stems (first 5 letters) with question and stop words removed. */ private static function content_words( $text ) { static $stop = null; if ( null === $stop ) { $stop = array_flip( explode( ' ', 'what how why when where who which can should is are does do will would could the a an of to for in on and or your you my i we it its this that with vs be by did not than more most really actually any there their best need get use' ) ); } $out = array(); foreach ( preg_split( '/[^a-z0-9\.]+/', strtolower( (string) $text ) ) as $w ) { $w = trim( $w, '.' ); if ( strlen( $w ) > 2 && ! isset( $stop[ $w ] ) ) { $out[] = substr( $w, 0, 5 ); } } return array_unique( $out ); } /** * Identify the first real content structure after a question heading. * * Gutenberg comments are metadata, not visible content, so skip them when * deciding whether the answer begins with prose, a list, a table, or another * block. This value is also sent to the UI so it never presents a flattened * list as though it were a paragraph that will be replaced. * * @param string $body_html HTML immediately following a heading. * @return string list|table|paragraph|block|text|empty */ public static function opener_structure( $body_html ) { $probe = ltrim( (string) $body_html ); // Skip block serializer comments until the first visible node. Opening // list/table comments describe that visible node, so retain their type. for ( $i = 0; $i < 8 && preg_match( '#^\s*#i', $probe, $comment ); $i++ ) { $is_closing = ! empty( $comment[1] ); $type = strtolower( (string) $comment[2] ); if ( ! $is_closing ) { if ( 'list' === $type ) { return 'list'; } if ( 'table' === $type ) { return 'table'; } if ( 'paragraph' === $type ) { return 'paragraph'; } return 'block'; } $probe = ltrim( substr( $probe, strlen( $comment[0] ) ) ); } if ( '' === $probe ) { return 'empty'; } if ( preg_match( '#^<(?:ul|ol)\b#i', $probe ) ) { return 'list'; } if ( preg_match( '#^]*>\s*#i', $inside_wrappers, $wrapper ); $i++ ) { $inside_wrappers = ltrim( substr( $inside_wrappers, strlen( $wrapper[0] ) ) ); $inside_wrappers = preg_replace( '#^(?:\s*)+#s', '', $inside_wrappers ); if ( preg_match( '#^ $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, $h['text'] ); $opener_structure = self::opener_structure( $body ); $list_items = 0; if ( 'list' === $opener_structure ) { $list_items = preg_match_all( '# $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, 'opener_structure' => $opener_structure, 'list_items' => (int) $list_items, ); 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, 'opener_structure' => $opener_structure, 'list_items' => (int) $list_items, ); 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, ); } $exclusion_reason = self::exclusion_reason( $post ); if ( '' !== $exclusion_reason ) { $result = array( 'post_id' => (int) $post_id, 'score_version' => self::SCORE_VERSION, 'scanned_at' => gmdate( 'c' ), 'word_count' => method_exists( __NAMESPACE__ . '\\ContentGaps', 'page_word_count' ) ? ContentGaps::page_word_count( $post ) : str_word_count( wp_strip_all_tags( (string) $post->post_content ) ), 'question_headings' => 0, 'direct_answers' => 0, 'buried_answers' => 0, 'unanswered_answers' => 0, 'dismissed_issues' => 0, 'answer_density_score' => 0, 'applicable' => false, 'excluded' => true, 'exclusion_reason' => $exclusion_reason, 'issues' => array(), 'above_fold_ratio' => 0, ); update_post_meta( $post_id, self::POSTMETA_KEY, $result ); update_post_meta( $post_id, self::SCAN_TS_KEY, time() ); wp_cache_delete( self::ROWS_CACHE_KEY, self::CACHE_GROUP ); delete_option( self::SUMMARY_OPT ); return $result; } // "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; /* * Page builders keep the page outside post_content, so a builder page * would otherwise score zero for answering nothing while the live page * is full of headings and answers. Ask the builder directly before * concluding the page is empty. */ if ( '' === trim( wp_strip_all_tags( (string) $content ) ) && class_exists( __NAMESPACE__ . '\\ContentGaps' ) && method_exists( __NAMESPACE__ . '\\ContentGaps', 'builder_rendered_content' ) ) { $builder = ContentGaps::builder_rendered_content( $post ); if ( '' !== $builder ) { $content = $builder; } } $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; } // Eligibility can change without the content changing (for example when // a page is assigned as WooCommerce My Account, or an SEO plugin toggles // noindex). Reconcile that state on read so the editor never shows an old // score for a newly excluded utility page. $reason = self::exclusion_reason( (int) $post_id ); $was_excluded = ! empty( $saved['excluded'] ); if ( ( '' !== $reason ) !== $was_excluded ) { return self::scan_post( (int) $post_id ); } 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' => self::selected_post_types(), '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 ); $post_types = self::selected_post_types(); // 1) Never-scanned posts (no rotation stamp) take priority. $never = get_posts( array( 'post_type' => $post_types, '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' => $post_types, '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; $post_types = self::selected_post_types(); // 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 ) { $type_placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); $sql_args = array_merge( array( self::POSTMETA_KEY, 'publish' ), $post_types ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "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 ( {$type_placeholders} )", ...$sql_args ) ); 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; // eligible posts with no question headings (not graded) $excluded_posts = 0; // utility/noindex pages deliberately outside the score $sum_score = 0; $weak = 0; $weakest = array(); $hidden_rows = array(); $perfect = 0; $needs_improvement = 0; $actionable = 0; $hidden = 0; $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']; } // Re-evaluate the live page classification as well as the stored flag. // This removes a newly assigned My Account/Checkout/noindex page from // the average immediately, without requiring a full-content rescan. if ( ! empty( $data['excluded'] ) || '' !== self::exclusion_reason( (int) $r->post_id ) ) { $excluded_posts++; continue; } $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; $has_open_issue = ! empty( $data['issues'] ) && is_array( $data['issues'] ); if ( $score >= 100 ) { $perfect++; } else { $needs_improvement++; if ( $has_open_issue ) { $actionable++; } else { $hidden++; } } // A dismissed issue is no longer an actionable weak post. Keep its raw // score in the site average, but do not immediately put it back into the // dashboard task list the user just dismissed. if ( $score < 50 && $has_open_issue ) { $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 ( $has_open_issue ) { $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'] : '', 'opener_structure' => isset( $f['opener_structure'] ) ? (string) $f['opener_structure'] : 'text', 'list_items' => isset( $f['list_items'] ) ? (int) $f['list_items'] : 0, ); } if ( $has_open_issue ) { $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, ); } elseif ( $score < 100 && ! empty( $data['dismissed_issues'] ) ) { $hidden_rows[] = array( 'post_id' => (int) $r->post_id, 'score' => $score, 'dismissed_issues' => (int) $data['dismissed_issues'], ); } } usort( $weakest, function ( $a, $b ) { return $a['score'] - $b['score']; } ); usort( $hidden_rows, function ( $a, $b ) { return $a['score'] - $b['score']; } ); // The dashboard must expose every page that can lower the average, not // only the sub-50 pages. Keep a generous bounded list for very large sites. $weakest = array_slice( $weakest, 0, 100 ); $hidden_rows = array_slice( $hidden_rows, 0, 100 ); // 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, 'excluded_posts' => $excluded_posts, 'eligible_scanned' => $applicable_posts + $na_posts, 'total_scanned' => $total_scanned, 'weak_posts' => $weak, 'perfect_posts' => $perfect, 'needs_improvement_posts' => $needs_improvement, 'actionable_posts' => $actionable, 'hidden_posts' => $hidden, 'score_shortfall' => max( 0, ( 100 * $applicable_posts ) - $sum_score ), 'weakest' => $weakest, 'hidden' => $hidden_rows, 'last_scanned_at' => $last_scan, 'post_types' => $post_types, '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'] || ! array_key_exists( 'perfect_posts', $s ) || ! array_key_exists( 'needs_improvement_posts', $s ) || ! array_key_exists( 'excluded_posts', $s ) ) { 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, self::selected_post_types(), 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 ); } }