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( '#]*application/ld\+json[^>]*>(.*?)#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( '#]*application/ld\+json[^>]*>(.*?)#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 ); } } } } }