inside an HTML comment from a real one, and every
* Gutenberg block delimiter is an HTML comment, so "comment out the old
* heading" quietly produced a false finding on ordinary sites. The same class
* handles script and style raw text, unquoted attribute values, and a ">" that
* lives inside a quoted attribute value, all of which the old patterns got
* wrong. WP_HTML_Tag_Processor is WordPress core from 6.2, and this plugin
* declares "Requires at least: 6.2", so the dependency costs nothing and no
* fallback path is carried.
*
* @package AISEOGodMode
*/
namespace AISEOGodMode;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Content_Health {
/** Scan state + results. */
const OPT = 'asgm_content_health';
/**
* Bump when inspection semantics change.
*
* Findings are cached in an option. Without a version marker, a site can
* keep showing a missing-description result produced by an older scanner
* even after the scanner learns to resolve generated SEO descriptions.
*/
const SCAN_VERSION = '2';
/**
* Posts parsed per batch call.
*
* Larger than Link_Health's 12 because nothing here leaves the server: this
* is a get_post() plus one pass of the HTML tag processor per item, not an
* HTTP round trip. The batching exists for the same reason regardless, so a
* 500-post site on shared hosting never sits inside one long request.
*/
const BATCH = 25;
/**
* Content Health is intentionally uncapped.
*
* The work is still performed in BATCH-sized requests, but the queue must
* contain every eligible published item. A cap made a "full scan" capable
* of missing featured images on larger stores and custom-post-type sites.
* Zero is retained in the response as the conventional "no limit" value.
*/
const MAX_POSTS = 0;
/** AI cost for inspecting one unique featured-image attachment. */
const FEATURED_ALT_CREDITS = 2;
/** Maximum local image payload sent for one vision request (4 MiB). */
const MAX_VISION_IMAGE_BYTES = 4194304;
/**
* Character count above which a title is reported as long.
*
* Deliberately described as a rule of thumb everywhere it surfaces. Google
* and Bing both truncate titles by PIXEL width in the rendered SERP, not by
* character count, so no character number is a real limit: "Illinois" and
* "lilliii" are the same length and nowhere near the same width. 60 is the
* common working figure because it lands near the truncation point for
* average-width text, and it is the number Bing Webmaster Tools itself uses
* for its "Title too long" warning.
*
* We do not claim a pixel figure anywhere in the UI, because we cannot
* measure pixels from PHP without knowing the font the SERP renders in.
*/
const TITLE_MAX = 60;
/**
* Longest description still reported as "very short" rather than fine.
*
* A one-word value in the meta field is a placeholder somebody typed to
* silence their SEO plugin, not a snippet. It is reported separately from a
* missing description and never folded into it: a page storing "Coffee"
* renders a real meta description, so listing it under "nothing stored"
* would be a false statement about the user's own site.
*/
const DESC_SHORT_MAX = 15;
/**
* Duplicate-description clusters drawn in the card.
*
* Purely a display limit, and it is applied where the drawing happens so
* the count next to it can disclose the rest. Every number the card
* headlines is taken from the full cluster list before this is applied.
*/
const DUPE_CLUSTERS_SHOWN = 20;
/**
* Maximum reusable-block nesting followed during one page inspection.
*
* Core prevents recursive reusable blocks from rendering forever. We need
* the same protection locally, plus a hard ceiling for malformed imports
* that form a very deep acyclic chain. Twelve nested patterns is already an
* extreme editor shape; 32 leaves ample room without making a scan an
* unbounded walk.
*/
const REUSABLE_BLOCK_MAX_DEPTH = 32;
/**
* Severity per finding, in one place.
*
* public_state() needs this twice, once to build the groups and once to
* count how many pages carry a real problem as opposed to something merely
* worth a look. Two lists that can drift apart would eventually disagree.
*/
const SEVERITY = array(
'h1_multiple' => 'problem',
'h1_in_body' => 'check',
'h1_missing' => 'problem',
'desc_missing' => 'problem',
'desc_short' => 'check',
'title_long' => 'check',
'alt_missing' => 'problem',
'alt_empty' => 'check',
'alt_featured' => 'problem',
);
/**
* "N credit(s) per page", read from the charged price so the text
* cannot drift from what the customer pays.
*/
private static function credits_per_page( $fix_type ) {
$cost = class_exists( __NAMESPACE__ . '\\MetadataGenerator' ) ? (int) MetadataGenerator::fix_cost( $fix_type ) : 1;
return $cost . ( 1 === $cost ? ' credit' : ' credits' ) . ' per page';
}
/** Read the stored state, shaped. */
public static function get_state() {
$raw = get_option( self::OPT, array() );
if ( ! is_array( $raw ) ) {
$raw = array();
}
// Never present findings produced by an older inspection algorithm.
// The UI will request a fresh scan instead of showing stale results.
if ( self::SCAN_VERSION !== (string) ( $raw['scan_version'] ?? '' ) ) {
return array(
'status' => 'never',
'queue' => array(),
'checked' => 0,
'total' => 0,
'published_total' => 0,
'post_types' => self::scannable_post_types(),
'pages' => array(),
'desc_index' => array(),
'has_content' => false,
'scanned_at' => '',
'scan_version' => self::SCAN_VERSION,
);
}
return array(
'status' => (string) ( $raw['status'] ?? 'never' ), // never|scanning|done
'queue' => ( isset( $raw['queue'] ) && is_array( $raw['queue'] ) ) ? $raw['queue'] : array(),
'checked' => (int) ( $raw['checked'] ?? 0 ),
'total' => (int) ( $raw['total'] ?? 0 ),
'published_total' => (int) ( $raw['published_total'] ?? 0 ),
'post_types' => ( isset( $raw['post_types'] ) && is_array( $raw['post_types'] ) ) ? $raw['post_types'] : self::scannable_post_types(),
'pages' => ( isset( $raw['pages'] ) && is_array( $raw['pages'] ) ) ? $raw['pages'] : array(),
'desc_index' => ( isset( $raw['desc_index'] ) && is_array( $raw['desc_index'] ) ) ? $raw['desc_index'] : array(),
'has_content' => ! empty( $raw['has_content'] ),
'scanned_at' => (string) ( $raw['scanned_at'] ?? '' ),
'scan_version' => self::SCAN_VERSION,
);
}
private static function save_state( $state ) {
update_option( self::OPT, $state, false );
}
/** Clear stale scan rows when the owner changes the shared content scope. */
public static function invalidate_for_scope_change() {
delete_option( self::OPT );
}
/**
* Start a scan: queue every eligible published content item, drop prior results.
*
* Costs two queries, one for the queue and one cached count for the honest
* site total. The parsing happens batch by batch, so this returns instantly
* even on a site with hundreds of posts.
*/
public static function start_scan() {
$post_types = self::scannable_post_types();
$ids = get_posts( array(
'post_type' => $post_types,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC',
'suppress_filters' => true,
) );
$ids = array_map( 'intval', (array) $ids );
if ( class_exists( __NAMESPACE__ . '\\Answer_Density' ) ) {
$ids = array_values( array_filter( $ids, static function ( $post_id ) {
return '' === Answer_Density::exclusion_reason( (int) $post_id );
} ) );
}
$state = array(
'status' => empty( $ids ) ? 'done' : 'scanning',
'scan_version' => self::SCAN_VERSION,
'queue' => $ids,
'checked' => 0,
'total' => count( $ids ),
// Only eligible search-facing content belongs in this denominator.
// Transactional/noindex items are intentionally outside the work
// queue, even when their post type is selected.
'published_total' => count( $ids ),
'post_types' => $post_types,
'pages' => array(),
'desc_index' => array(),
// Separate from total on purpose. total counts down as the queue
// drains in some designs; this one records "there was something to
// scan" so an empty site can never be reported as a clean site.
'has_content' => ! empty( $ids ),
'scanned_at' => current_time( 'mysql' ),
);
self::save_state( $state );
return self::public_state( $state );
}
/**
* Public, editor-visible content types Content Health can inspect.
*
* The saved list comes from WordPress' public, editor-visible post types,
* so it includes site-specific products, courses, listings, downloads and
* other custom types while excluding internal records.
*/
private static function scannable_post_types() {
$types = class_exists( __NAMESPACE__ . '\\Answer_Density' )
? Answer_Density::selected_post_types()
: array( 'post', 'page' );
$types = array_values( array_filter( array_map( 'sanitize_key', (array) $types ), static function ( $type ) {
if ( in_array( $type, array( 'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation' ), true ) ) {
return false;
}
return post_type_exists( $type );
} ) );
/** Filter the public content types included in a full Content Health scan. */
$types = apply_filters( 'aeogm_content_health_post_types', $types );
$types = array_values( array_unique( array_filter( array_map( 'sanitize_key', (array) $types ), 'post_type_exists' ) ) );
return ! empty( $types ) ? $types : array( 'post', 'page' );
}
/** Parse the next batch of queued posts. */
public static function run_batch() {
$state = self::get_state();
if ( 'scanning' !== $state['status'] || empty( $state['queue'] ) ) {
return self::public_state( $state );
}
$batch = array_splice( $state['queue'], 0, self::BATCH );
foreach ( $batch as $post_id ) {
$state['checked']++;
$post = get_post( (int) $post_id );
if ( ! $post ) {
continue;
}
$row = self::inspect( $post );
// The description index has to see every page, including the clean
// ones, or "duplicate across pages" cannot be answered: two pages
// sharing a description are both individually fine.
if ( '' !== $row['desc'] ) {
$hash = md5( self::normalise_desc( $row['desc'] ) );
if ( ! isset( $state['desc_index'][ $hash ] ) ) {
$state['desc_index'][ $hash ] = array( 'text' => $row['desc'], 'ids' => array() );
}
$state['desc_index'][ $hash ]['ids'][] = (int) $post->ID;
}
// Only pages with something to say are stored. On a healthy site
// this option stays near empty instead of carrying a row per page.
if ( ! empty( $row['flags'] ) ) {
unset( $row['desc'] );
$state['pages'][] = $row;
}
}
if ( empty( $state['queue'] ) ) {
$state['status'] = 'done';
}
self::save_state( $state );
return self::public_state( $state );
}
/**
* Everything we can determine about one post without rendering it.
*
* Reads post_content rather than the_content(). Published synced patterns
* are expanded because they are saved WordPress content, but shortcodes,
* dynamic blocks and theme output are not rendered. Running the content
* filters would pull in every other plugin's oEmbeds and callbacks on a
* scan of 500 posts, which is both slow and a great way to trigger somebody
* else's side effects. The cost of that choice is stated honestly in the H1
* copy: we can count saved content, not the heading a theme adds.
*/
private static function inspect( $post ) {
$content = self::expand_reusable_blocks( (string) $post->post_content );
$flags = array();
$html = self::parse_html( $content );
// ---- Headings ----
$h1_count = $html['h1'];
if ( $h1_count >= 2 ) {
$flags['h1_multiple'] = $h1_count;
} elseif ( 1 === $h1_count ) {
// One H1 in the body is the interesting case and the one we cannot
// settle from here. Nearly every theme prints the post title as an
// H1, which would make this the second. Reported as "worth
// checking" rather than as a defect, because a theme that does not
// print a title H1 makes this page correct.
$flags['h1_in_body'] = 1;
} elseif ( '' === self::trim_text( (string) $post->post_title ) ) {
// No H1 written and no title for a theme to promote into one. This
// is the only genuinely certain "missing H1" available locally.
$flags['h1_missing'] = 1;
}
// ---- Meta description ----
$desc = self::effective_description( $post->ID );
$desc_len = self::visible_length( $desc['text'] );
if ( 0 === $desc_len ) {
$flags['desc_missing'] = 1;
} elseif ( $desc_len <= self::DESC_SHORT_MAX ) {
$flags['desc_short'] = $desc_len;
}
// ---- Title length ----
$title = self::effective_title( $post );
$title_len = self::visible_length( $title );
if ( $title_len > self::TITLE_MAX ) {
$flags['title_long'] = $title_len;
}
// ---- Images ----
// Three separate findings, because they are three separate decisions.
// An image with no alt attribute is broken. An image with alt="" may be
// a correctly marked decorative image or an alt box the author left
// blank, and nothing stored can tell those apart. A featured image with
// no alt on the attachment is neither, and is fixed somewhere else.
if ( $html['img_no_alt'] > 0 ) {
$flags['alt_missing'] = $html['img_no_alt'];
}
$empty_alt_fingerprint = self::empty_alt_fingerprint_from_html( $html );
$reviewed_empty_alt = (string) get_post_meta( $post->ID, '_asgm_alt_empty_reviewed', true );
if ( $html['img_empty_alt'] > 0 && $reviewed_empty_alt !== $empty_alt_fingerprint ) {
$flags['alt_empty'] = $html['img_empty_alt'];
}
if ( self::featured_image_missing_alt( $post->ID ) ) {
$flags['alt_featured'] = 1;
}
return array(
'post_id' => (int) $post->ID,
'title' => (string) $post->post_title,
'type' => (string) $post->post_type,
'title_len' => $title_len,
'images' => (int) $html['img_total'],
'img_empty' => (int) $html['img_empty_alt'],
'desc' => ( $desc_len > 0 ) ? $desc['text'] : '',
'desc_len' => $desc_len,
'desc_text' => ( $desc_len > 0 && $desc_len <= self::DESC_SHORT_MAX ) ? $desc['text'] : '',
// Named so the "no description" row can say which plugin is holding
// a leftover, without ever counting that leftover as a pass.
'desc_stale' => (string) $desc['stale_label'],
'flags' => $flags,
);
}
/**
* Expand published core/block references without rendering the page.
*
* A synced pattern is not present as HTML in the host post. Gutenberg saves
* only ``, so scanning post_content verbatim
* misses every heading and image inside it. parse_blocks() gives us a safe
* structural walk and lets us resolve only this one core storage primitive.
* No shortcode, dynamic block, render callback or content filter runs.
*
* Cycles are cut at the repeated reference, matching the practical result
* of core's reusable-block recursion protection. Draft, private, trashed,
* password-protected and deleted patterns contribute nothing because they
* do not render for an ordinary visitor.
*
* @param string $content Saved block content.
* @param array $seen Reusable block IDs already on this branch.
* @param int $depth Current reusable block depth.
* @return string HTML fragments that are actually saved to render.
*/
private static function expand_reusable_blocks( $content, $seen = array(), $depth = 0 ) {
$content = (string) $content;
if ( '' === $content || $depth >= self::REUSABLE_BLOCK_MAX_DEPTH ) {
return '';
}
$blocks = parse_blocks( $content );
$out = '';
foreach ( (array) $blocks as $block ) {
$out .= self::expand_saved_block( $block, $seen, $depth );
}
return $out;
}
/** Expand one parsed block while preserving its saved static HTML. */
private static function expand_saved_block( $block, $seen, $depth ) {
if ( ! is_array( $block ) ) {
return '';
}
if ( 'core/block' === ( $block['blockName'] ?? null ) ) {
$ref = (int) ( $block['attrs']['ref'] ?? 0 );
if ( ! $ref || isset( $seen[ $ref ] ) || $depth >= self::REUSABLE_BLOCK_MAX_DEPTH ) {
return '';
}
$pattern = get_post( $ref );
if (
! $pattern
|| 'wp_block' !== $pattern->post_type
|| 'publish' !== $pattern->post_status
|| '' !== (string) $pattern->post_password
) {
return '';
}
$seen[ $ref ] = true;
return self::expand_reusable_blocks( (string) $pattern->post_content, $seen, $depth + 1 );
}
$inner_blocks = (array) ( $block['innerBlocks'] ?? array() );
$inner_content = (array) ( $block['innerContent'] ?? array() );
// Freeform HTML has no block wrapper and no innerContent on some older
// parse_blocks() shapes, so retain innerHTML as the final fallback.
if ( empty( $inner_content ) ) {
return (string) ( $block['innerHTML'] ?? '' );
}
$out = '';
$index = 0;
foreach ( $inner_content as $fragment ) {
if ( null !== $fragment ) {
$out .= (string) $fragment;
continue;
}
if ( isset( $inner_blocks[ $index ] ) ) {
$out .= self::expand_saved_block( $inner_blocks[ $index ], $seen, $depth );
}
$index++;
}
return $out;
}
/**
* One pass of the HTML tag processor: H1s and the image alt breakdown.
*
* Both findings come from the same walk because both need the same thing,
* a real tokenizer. The processor skips HTML comments, script and style raw
* text, and reads unquoted attribute values and values containing ">",
* which is the whole reason the regular expressions this replaced produced
* findings that were not on the page.
*
* It is a tokenizer and not a tree builder, though, so it walks happily into
* three kinds of markup a browser never renders, and every tag it finds in
* there was reported as a real finding on the page. Each is handled below
* against what Chrome actually does with it, because DOMDocument agrees with
* neither the tokenizer nor the browser and is no use as a referee.
*/
private static function parse_html( $content ) {
$out = array(
'h1' => 0,
'img_total' => 0,
'img_no_alt' => 0,
'img_empty_alt' => 0,
'empty_alt_srcs'=> array(),
);
if ( '' === self::trim_text( $content ) ) {
return $out;
}
// What we are inside of, if anything: '' | 'rawtext' | 'template'.
$skip = '';
$depth = 0;
$tags = new \WP_HTML_Tag_Processor( $content );
while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
$tag = $tags->get_tag();
$closer = $tags->is_tag_closer();
if ( 'rawtext' === $skip ) {
// Raw text really is text: a second pasted into an editor must not open anything here.
if ( $closer ) {
continue;
}
// Everything after is text to the end of the document,
// and no close tag exists that can end it. Verified in Chrome: a
// opened inside a still swallows the markup
// that follows the template's own closing tag.
if ( 'PLAINTEXT' === $tag ) {
break;
}
// The tag processor runs with the scripting flag off and descends
// into NOSCRIPT by design, which is correct for its job and wrong
// for ours: every browser and crawler worth reporting on has
// scripting on, so a lazy-load