get_cached();
if ( $cached ) {
return $cached;
}
}
$this->html_cache = array();
$checks = array_merge(
$this->check_reachability(),
$this->check_structured_data(),
$this->check_llms_txt(),
$this->check_crawler_access(),
$this->check_content_structure(),
$this->check_freshness(),
$this->check_identity()
);
$result = $this->summarize( $checks );
set_transient( self::TRANSIENT_KEY, $result, DAY_IN_SECONDS );
return $result;
}
/**
* Turn raw checks into a score, grade and ordered fix list.
*
* Each check: id, label, status (pass|warn|fail), weight (int, 0 = advisory
* only, does not affect the score), fix (string), impact (int 1-3).
*
* @param array $checks Raw checks.
* @return array
*/
private function summarize( $checks ) {
$earned = 0;
$total = 0;
$fixes = array();
foreach ( $checks as $check ) {
$weight = isset( $check['weight'] ) ? (int) $check['weight'] : 1;
$total += $weight;
$ratio = ( 'pass' === $check['status'] ) ? 1 : ( ( 'warn' === $check['status'] ) ? 0.5 : 0 );
$earned += $weight * $ratio;
if ( 'pass' !== $check['status'] && ! empty( $check['fix'] ) ) {
$fixes[] = array(
'label' => $check['label'],
'fix' => $check['fix'],
'impact' => isset( $check['impact'] ) ? (int) $check['impact'] : 1,
'status' => $check['status'],
);
}
}
$score = ( $total > 0 ) ? (int) round( ( $earned / $total ) * 100 ) : 0;
// Prioritize fixes by impact (desc), then failing before warning.
usort(
$fixes,
function ( $a, $b ) {
if ( $a['impact'] !== $b['impact'] ) {
return $b['impact'] - $a['impact'];
}
$rank = array(
'fail' => 0,
'warn' => 1,
);
return $rank[ $a['status'] ] - $rank[ $b['status'] ];
}
);
return array(
'score' => $score,
'grade' => self::grade( $score ),
'checks' => $checks,
'fixes' => $fixes,
'generated' => time(),
);
}
/**
* Letter grade for a score.
*
* @param int $score 0-100.
* @return string
*/
public static function grade( $score ) {
if ( $score >= 90 ) {
return 'A';
}
if ( $score >= 75 ) {
return 'B';
}
if ( $score >= 60 ) {
return 'C';
}
if ( $score >= 40 ) {
return 'D';
}
return 'F';
}
/*
* ---------------------------------------------------------------------
* Individual checks
* ---------------------------------------------------------------------
*/
/**
* Can the audit load the home page at all? If not, the page-based checks
* below are unreliable, so say so explicitly instead of blaming the schema.
*
* @return array
*/
private function check_reachability() {
$this->fetch_html( home_url( '/' ) );
$error = $this->html_cache[ home_url( '/' ) ]['error'];
if ( '' === $error ) {
return array();
}
return array(
array(
'id' => 'reachable',
'label' => __( 'Audit could load your home page', 'evoka' ),
'status' => 'fail',
'weight' => 0,
'impact' => 3,
'fix' => sprintf(
/* translators: %s: error message */
__( 'Evoka could not load your home page (%s), so the structured-data results below may be wrong. Make sure the site is up and not in maintenance mode, and that your host allows WordPress to request its own pages (loopback requests - see Tools -> Site Health).', 'evoka' ),
$error
),
),
);
}
/**
* Is AI-relevant structured data present? Identity schema is checked on the
* home page; author and FAQ schema only appear on individual posts/pages,
* so those are checked on a sample of singular URLs as well.
*
* @return array
*/
private function check_structured_data() {
$home_types = $this->detect_schema_types( $this->fetch_html( home_url( '/' ) ) );
$sample_types = $home_types;
$sample_urls = $this->sample_singular_urls();
foreach ( $sample_urls as $url ) {
$sample_types = array_merge( $sample_types, $this->detect_schema_types( $this->fetch_html( $url ) ) );
}
$sample_types = array_values( array_unique( $sample_types ) );
$checks = array();
$checks[] = array(
'id' => 'schema_org',
'label' => __( 'Organization / Website identity schema', 'evoka' ),
'status' => ( in_array( 'Organization', $home_types, true ) || in_array( 'WebSite', $home_types, true ) ) ? 'pass' : 'fail',
'weight' => 12,
'impact' => 3,
'fix' => __( 'Add Organization and WebSite JSON-LD so AI engines know who you are and can build an entity for your brand. Enable it in Evoka -> Settings.', 'evoka' ),
);
$checks[] = array(
'id' => 'schema_author',
'label' => __( 'Author credentials (Person / E-E-A-T)', 'evoka' ),
'status' => in_array( 'Person', $sample_types, true ) ? 'pass' : 'warn',
'weight' => 8,
'impact' => 2,
'fix' => empty( $sample_urls )
? __( 'Publish posts with a named author. Evoka adds Person schema with author credentials to every post; AI answer engines weight author expertise (E-E-A-T) heavily when deciding who to cite.', 'evoka' )
: __( 'Add Person schema with author credentials. AI answer engines weight author expertise (E-E-A-T) heavily when deciding who to cite. Enable structured data in Evoka -> Settings and fill in Users -> Profile -> Author credentials.', 'evoka' ),
);
$checks[] = array(
'id' => 'schema_faq',
'label' => __( 'FAQ / Q&A structured data', 'evoka' ),
'status' => in_array( 'FAQPage', $sample_types, true ) ? 'pass' : 'warn',
'weight' => 6,
'impact' => 2,
'fix' => __( 'Add FAQPage schema to key pages. Question/answer pairs are the format answer engines extract most readily. Use question-style H2/H3 headings followed by a short answer, then run Bulk structured data in Evoka to add the FAQ schema automatically.', 'evoka' ),
);
// Advisory: richer entity signals strengthen the Organization node but
// are optional, so this never costs score - only a nudge.
$org = null;
foreach ( (array) ( $this->decode_graph( $this->fetch_html( home_url( '/' ) ) ) ) as $node ) {
if ( isset( $node['@type'] ) && 'Organization' === $node['@type'] ) {
$org = $node;
break;
}
}
if ( in_array( 'Organization', $home_types, true ) && $org && empty( $org['sameAs'] ) && empty( $org['knowsAbout'] ) ) {
$checks[] = array(
'id' => 'schema_entity',
'label' => __( 'Organization entity signals (sameAs / knowsAbout)', 'evoka' ),
'status' => 'warn',
'weight' => 0,
'impact' => 1,
'fix' => __( 'Add your social/profile links and a few topics you\'re known for in Evoka -> Settings. These help AI engines connect your site to the same entity elsewhere on the web.', 'evoka' ),
);
}
return $checks;
}
/**
* Decode every JSON-LD block on a page into a flat list of top-level nodes.
*
* @param string $html Page HTML.
* @return array
*/
private function decode_graph( $html ) {
if ( '' === $html || ! preg_match_all( '##is', $html, $matches ) ) {
return array();
}
$nodes = array();
foreach ( $matches[1] as $json ) {
$data = json_decode( trim( $json ), true );
if ( isset( $data['@graph'] ) && is_array( $data['@graph'] ) ) {
$nodes = array_merge( $nodes, $data['@graph'] );
} elseif ( is_array( $data ) ) {
$nodes[] = $data;
}
}
return $nodes;
}
/**
* Does llms.txt exist and resolve?
*
* @return array
*/
private function check_llms_txt() {
$url = home_url( '/llms.txt' );
$ok = $this->url_is_llms_txt( $url ); // A hand-made file also counts.
// Tailor the fix to the actual reason it isn't reachable.
if ( ! evoka_get_setting( 'llms_txt_enabled', 1 ) ) {
$fix = __( 'Publish an llms.txt that points AI crawlers to your most important content. Enable it in Evoka -> Settings (one click).', 'evoka' );
} elseif ( '' === (string) get_option( 'permalink_structure' ) ) {
$fix = __( 'llms.txt is enabled but cannot be reached because your permalinks are set to "Plain". Go to Settings -> Permalinks, choose "Post name" and save.', 'evoka' );
} else {
$fix = sprintf(
/* translators: %s: llms.txt URL */
__( 'llms.txt is enabled but %s did not return it. Re-save Settings -> Permalinks to refresh rewrite rules, and check that no physical llms.txt file or caching rule is in the way.', 'evoka' ),
$url
);
}
return array(
array(
'id' => 'llms_txt',
'label' => __( 'llms.txt file published', 'evoka' ),
'status' => $ok ? 'pass' : 'fail',
'weight' => 6,
'impact' => 2,
'fix' => $fix,
),
);
}
/**
* Are AI search crawlers allowed in the robots.txt crawlers actually read
* (the one at the host root)? Also flags a crawler policy that isn't
* being served at all.
*
* @return array
*/
private function check_crawler_access() {
$robots_url = Evoka_Crawler_Controls::robots_url();
$response = wp_remote_get( $robots_url, array( 'timeout' => 8 ) );
$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
// No robots.txt (404 etc.) means crawlers may fetch everything.
$body = ( 200 === $code ) ? (string) wp_remote_retrieve_body( $response ) : '';
// The crawlers that decide whether AI answers can cite you (training
// crawlers are deliberately excluded: blocking them doesn't stop citation).
// Googlebot is checked too because Google AI Overviews draw on its index.
$search_bots = Evoka_Crawler_Controls::search_bots();
$blocked = array();
foreach ( array_merge( $search_bots, array( 'Googlebot' ) ) as $bot ) {
if ( Evoka_Crawler_Controls::is_blocked( $body, $bot ) ) {
$blocked[] = $bot;
}
}
$all_ai_blocked = ! array_diff( $search_bots, $blocked );
$googlebot_blocked = in_array( 'Googlebot', $blocked, true );
if ( ! get_option( 'blog_public' ) ) {
$fix = __( 'Your site is set to discourage search engines (Settings -> Reading -> Search engine visibility), which blocks AI search crawlers too. Untick it when the site is ready to be found.', 'evoka' );
} else {
$fix = sprintf(
/* translators: 1: comma-separated bot names, 2: robots.txt URL */
__( 'These AI search crawlers are blocked in %2$s: %1$s. If you want to be cited, allow them (Evoka -> Settings -> Crawler policy: "Allow" or "Allow AI search, block AI training") and remove any conflicting rules from a physical robots.txt file.', 'evoka' ),
implode( ', ', $blocked ),
$robots_url
);
}
$checks = array(
array(
'id' => 'crawler_access',
'label' => __( 'AI search crawlers can access the site', 'evoka' ),
'status' => empty( $blocked ) ? 'pass' : ( ( $all_ai_blocked || $googlebot_blocked ) ? 'fail' : 'warn' ),
'weight' => 10,
'impact' => 3,
'fix' => $fix,
),
);
// Advisory: a non-default policy that crawlers never see.
$policy = evoka_get_setting( 'crawler_policy', 'allow' );
if ( 'allow' !== $policy && get_option( 'blog_public' ) && false === strpos( $body, Evoka_Crawler_Controls::MARKER ) ) {
$checks[] = array(
'id' => 'crawler_policy_applied',
'label' => __( 'Your AI crawler policy is in effect', 'evoka' ),
'status' => 'warn',
'weight' => 0,
'impact' => 2,
'fix' => sprintf(
/* translators: %s: robots.txt URL */
__( 'Your crawler policy is not being served: %s does not contain Evoka\'s rules. This happens when WordPress is installed in a subfolder, permalinks are "Plain", or a physical robots.txt file exists. Copy the rules shown in Evoka -> Settings into the robots.txt at your domain root.', 'evoka' ),
$robots_url
),
);
}
return $checks;
}
/**
* Lightweight content-structure signal across recent posts.
*
* @return array
*/
private function check_content_structure() {
$posts = get_posts(
array(
'numberposts' => 10,
'post_status' => 'publish',
)
);
if ( empty( $posts ) ) {
return array(
array(
'id' => 'content_structure',
'label' => __( 'Extractable, well-structured content', 'evoka' ),
'status' => 'warn',
'weight' => 8,
'impact' => 2,
'fix' => __( 'Publish content with clear H2/H3 headings and answer-first paragraphs so AI can extract facts cleanly.', 'evoka' ),
),
);
}
$with_headings = 0;
foreach ( $posts as $post ) {
if ( preg_match( '/]/i', (string) $post->post_content ) ) {
++$with_headings;
}
}
$ratio = $with_headings / count( $posts );
$status = ( $ratio >= 0.7 ) ? 'pass' : ( ( $ratio >= 0.3 ) ? 'warn' : 'fail' );
return array(
array(
'id' => 'content_structure',
'label' => __( 'Extractable, well-structured content', 'evoka' ),
'status' => $status,
'weight' => 8,
'impact' => 2,
'fix' => __( 'Several recent posts lack clear H2/H3 structure. Use descriptive subheadings and lead with the answer so AI engines can quote you.', 'evoka' ),
),
);
}
/**
* Is content kept fresh (dates present + recent updates)?
*
* @return array
*/
private function check_freshness() {
$recent = get_posts(
array(
'numberposts' => 1,
'post_status' => 'publish',
'orderby' => 'modified',
'order' => 'DESC',
)
);
$status = 'warn';
if ( ! empty( $recent ) ) {
$modified = get_post_modified_time( 'U', true, $recent[0] );
$age_days = ( time() - (int) $modified ) / DAY_IN_SECONDS;
$status = ( $age_days <= 90 ) ? 'pass' : ( ( $age_days <= 365 ) ? 'warn' : 'fail' );
}
return array(
array(
'id' => 'freshness',
'label' => __( 'Content freshness', 'evoka' ),
'status' => $status,
'weight' => 4,
'impact' => 1,
'fix' => __( 'Update cornerstone content regularly and keep visible dates. AI engines prefer current, maintained sources.', 'evoka' ),
),
);
}
/**
* Basic site identity signals (tagline, front page).
*
* @return array
*/
private function check_identity() {
$tagline = get_bloginfo( 'description' );
$generic = ( '' === trim( (string) $tagline ) || 'Just another WordPress site' === $tagline );
return array(
array(
'id' => 'identity',
'label' => __( 'Clear site identity / tagline', 'evoka' ),
'status' => $generic ? 'warn' : 'pass',
'weight' => 3,
'impact' => 1,
'fix' => __( 'Set a descriptive site tagline (Settings -> General). A clear identity helps AI engines understand and summarize your brand.', 'evoka' ),
),
);
}
/*
* ---------------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------------
*/
/**
* Singular URLs to sample for post-level schema: the latest post, plus the
* most recently modified post/page that has bulk-fixer FAQ enhancements.
*
* @return array
*/
private function sample_singular_urls() {
$ids = get_posts(
array(
'numberposts' => 1,
'post_status' => 'publish',
'fields' => 'ids',
)
);
$faq_ids = get_posts(
array(
'post_type' => array( 'post', 'page' ),
'numberposts' => 1,
'post_status' => 'publish',
'fields' => 'ids',
'orderby' => 'modified',
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => Evoka_Bulk_Fixer::META_KEY,
'value' => 's:3:"faq"',
'compare' => 'LIKE',
),
),
)
);
$urls = array();
foreach ( array_unique( array_merge( $ids, $faq_ids ) ) as $id ) {
$url = get_permalink( $id );
if ( $url ) {
$urls[] = $url;
}
}
/**
* Filter the singular URLs the audit inspects for post-level schema.
*
* @param array $urls Absolute URLs.
*/
return array_values( array_unique( (array) apply_filters( 'evoka_audit_sample_urls', $urls ) ) );
}
/**
* Fetch a page's HTML once per audit run. Records the error (if any) so
* reachability can be reported.
*
* @param string $url URL.
* @return string HTML, or '' on failure.
*/
private function fetch_html( $url ) {
if ( isset( $this->html_cache[ $url ] ) ) {
return $this->html_cache[ $url ]['html'];
}
$response = wp_remote_get(
$url,
array(
'timeout' => 10,
'user-agent' => 'Evoka-Audit/' . EVOKA_VERSION,
)
);
$html = '';
$error = '';
if ( is_wp_error( $response ) ) {
$error = $response->get_error_message();
} else {
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code >= 200 && $code < 300 ) {
$html = (string) wp_remote_retrieve_body( $response );
} else {
/* translators: %d: HTTP status code */
$error = sprintf( __( 'HTTP %d', 'evoka' ), $code );
}
}
$this->html_cache[ $url ] = array(
'html' => $html,
'error' => $error,
);
return $html;
}
/**
* Does a URL serve an llms.txt document (200 + Markdown H1)?
*
* @param string $url URL.
* @return bool
*/
private function url_is_llms_txt( $url ) {
$response = wp_remote_get( $url, array( 'timeout' => 8 ) );
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
return false;
}
$type = (string) wp_remote_retrieve_header( $response, 'content-type' );
$body = ltrim( (string) wp_remote_retrieve_body( $response ) );
return false === stripos( $type, 'html' ) && 0 === strpos( $body, '# ' );
}
/**
* Extract @type values from JSON-LD blocks in HTML.
*
* @param string $html Page HTML.
* @return array List of schema type strings.
*/
private function detect_schema_types( $html ) {
$types = array();
if ( '' === $html ) {
return $types;
}
if ( ! preg_match_all( '##is', $html, $matches ) ) {
return $types;
}
foreach ( $matches[1] as $json ) {
$data = json_decode( trim( $json ), true );
if ( null === $data ) {
continue;
}
$this->collect_types( $data, $types );
}
return array_values( array_unique( $types ) );
}
/**
* Recursively collect @type values from a decoded JSON-LD structure.
*
* @param mixed $node Decoded node.
* @param array $types Accumulator (by reference).
*/
private function collect_types( $node, &$types ) {
if ( is_array( $node ) ) {
if ( isset( $node['@type'] ) ) {
foreach ( (array) $node['@type'] as $t ) {
$types[] = (string) $t;
}
}
foreach ( $node as $value ) {
if ( is_array( $value ) ) {
$this->collect_types( $value, $types );
}
}
}
}
}