'CCBot', 'tier' => 'training', 'owner' => 'Common Crawl', 'description' => 'Web corpus for AI training' ), array( 'name' => 'GPTBot', 'tier' => 'training', 'owner' => 'OpenAI', 'description' => 'Training data collection' ), array( 'name' => 'Google-Extended', 'tier' => 'training', 'owner' => 'Google', 'description' => 'Gemini training data' ), array( 'name' => 'FacebookBot', 'tier' => 'training', 'owner' => 'Meta', 'description' => 'AI training crawler' ), array( 'name' => 'Bytespider', 'tier' => 'training', 'owner' => 'ByteDance', 'description' => 'TikTok/Doubao training' ), array( 'name' => 'AI2Bot', 'tier' => 'training', 'owner' => 'Ai2', 'description' => 'Open language model training crawler' ), array( 'name' => 'Ai2Bot-Dolma', 'tier' => 'training', 'owner' => 'Ai2', 'description' => 'Dolma dataset training crawler' ), array( 'name' => 'aiHitBot', 'tier' => 'training', 'owner' => 'aiHit', 'description' => 'ML company-data crawler' ), array( 'name' => 'anthropic-ai', 'tier' => 'training', 'owner' => 'Anthropic', 'description' => 'Legacy Anthropic training crawler' ), array( 'name' => 'Brightbot', 'tier' => 'training', 'owner' => 'Bright Data', 'description' => 'LLM training data collection' ), array( 'name' => 'cohere-training-data-crawler', 'tier' => 'training', 'owner' => 'Cohere', 'description' => 'Training data collection' ), array( 'name' => 'Cotoyogi', 'tier' => 'training', 'owner' => 'ROIS', 'description' => 'Japanese LLM training crawler' ), array( 'name' => 'DeepSeekBot', 'tier' => 'training', 'owner' => 'DeepSeek', 'description' => 'Model training crawler' ), array( 'name' => 'EchoboxBot', 'tier' => 'training', 'owner' => 'Echobox', 'description' => 'AI product data collection' ), array( 'name' => 'Factset_spyderbot', 'tier' => 'training', 'owner' => 'FactSet', 'description' => 'AI model training crawler' ), array( 'name' => 'FriendlyCrawler', 'tier' => 'training', 'owner' => 'Unknown', 'description' => 'Machine learning dataset crawler' ), array( 'name' => 'GoogleOther', 'tier' => 'training', 'owner' => 'Google', 'description' => 'R&D crawls including AI' ), array( 'name' => 'GoogleOther-Image', 'tier' => 'training', 'owner' => 'Google', 'description' => 'Image crawls for R&D/AI' ), array( 'name' => 'GoogleOther-Video', 'tier' => 'training', 'owner' => 'Google', 'description' => 'Video crawls for R&D/AI' ), array( 'name' => 'ICC-Crawler', 'tier' => 'training', 'owner' => 'NICT', 'description' => 'AI research training crawler' ), array( 'name' => 'ImagesiftBot', 'tier' => 'training', 'owner' => 'ImageSift', 'description' => 'Image scraping for AI' ), array( 'name' => 'img2dataset', 'tier' => 'training', 'owner' => 'LAION', 'description' => 'Image dataset scraper for LLMs' ), array( 'name' => 'ISSCyberRiskCrawler', 'tier' => 'training', 'owner' => 'ISS', 'description' => 'ML model training crawler' ), array( 'name' => 'meta-externalagent', 'tier' => 'training', 'owner' => 'Meta', 'description' => 'Llama training crawler' ), array( 'name' => 'omgili', 'tier' => 'training', 'owner' => 'Webz.io', 'description' => 'Data resold for AI training' ), array( 'name' => 'PanguBot', 'tier' => 'training', 'owner' => 'Huawei', 'description' => 'PanGu LLM training crawler' ), array( 'name' => 'SBIntuitionsBot', 'tier' => 'training', 'owner' => 'SB Intuitions', 'description' => 'Japanese LLM training crawler' ), array( 'name' => 'Scrapy', 'tier' => 'training', 'owner' => 'Zyte', 'description' => 'Scraping framework used for AI training' ), array( 'name' => 'TikTokSpider', 'tier' => 'training', 'owner' => 'ByteDance', 'description' => 'LLM training crawler' ), array( 'name' => 'Timpibot', 'tier' => 'training', 'owner' => 'Timpi', 'description' => 'LLM training crawler' ), array( 'name' => 'VelenPublicWebCrawler', 'tier' => 'training', 'owner' => 'Velen', 'description' => 'ML dataset crawler' ), array( 'name' => 'Webzio-Extended', 'tier' => 'training', 'owner' => 'Webz.io', 'description' => 'AI training data crawler' ), array( 'name' => 'YandexAdditional', 'tier' => 'training', 'owner' => 'Yandex', 'description' => 'YandexGPT training crawler' ), // Search tier (blocking these hurts discoverability). array( 'name' => 'ChatGPT-User', 'tier' => 'search', 'owner' => 'OpenAI', 'description' => 'ChatGPT browse/search mode' ), array( 'name' => 'PerplexityBot', 'tier' => 'search', 'owner' => 'Perplexity', 'description' => 'Perplexity search crawler' ), array( 'name' => 'ClaudeBot', 'tier' => 'search', 'owner' => 'Anthropic', 'description' => 'Claude web search' ), array( 'name' => 'Applebot-Extended', 'tier' => 'search', 'owner' => 'Apple', 'description' => 'Siri/Apple Intelligence search' ), array( 'name' => 'cohere-ai', 'tier' => 'search', 'owner' => 'Cohere', 'description' => 'Cohere RAG search' ), array( 'name' => 'AddSearchBot', 'tier' => 'search', 'owner' => 'AddSearch', 'description' => 'AI search crawler' ), array( 'name' => 'amazon-kendra', 'tier' => 'search', 'owner' => 'Amazon', 'description' => 'Kendra AI enterprise search' ), array( 'name' => 'Andibot', 'tier' => 'search', 'owner' => 'Andi', 'description' => 'Generative AI search engine' ), array( 'name' => 'Anomura', 'tier' => 'search', 'owner' => 'Direqt', 'description' => 'AI search data collection' ), array( 'name' => 'Applebot', 'tier' => 'search', 'owner' => 'Apple', 'description' => 'Siri/Spotlight search indexing' ), array( 'name' => 'atlassian-bot', 'tier' => 'search', 'owner' => 'Atlassian', 'description' => 'Rovo AI search and assistants' ), array( 'name' => 'AzureAI-SearchBot', 'tier' => 'search', 'owner' => 'Microsoft', 'description' => 'Azure AI Search indexing' ), array( 'name' => 'bedrockbot', 'tier' => 'search', 'owner' => 'Amazon', 'description' => 'Bedrock knowledge-base (RAG) ingestion' ), array( 'name' => 'Bravebot', 'tier' => 'search', 'owner' => 'Brave', 'description' => 'Brave Search/AI indexing' ), array( 'name' => 'Claude-SearchBot', 'tier' => 'search', 'owner' => 'Anthropic', 'description' => 'Claude search-result quality crawler' ), array( 'name' => 'Cloudflare-AutoRAG', 'tier' => 'search', 'owner' => 'Cloudflare', 'description' => 'AutoRAG search indexing' ), array( 'name' => 'DuckAssistBot', 'tier' => 'search', 'owner' => 'DuckDuckGo', 'description' => 'DuckAssist AI answers' ), array( 'name' => 'ExaBot', 'tier' => 'search', 'owner' => 'Exa', 'description' => 'AI search index for agents' ), array( 'name' => 'iaskspider', 'tier' => 'search', 'owner' => 'iAsk', 'description' => 'iAsk AI answers crawler' ), array( 'name' => 'IbouBot', 'tier' => 'search', 'owner' => 'Ibou', 'description' => 'AI search result generation' ), array( 'name' => 'LinkupBot', 'tier' => 'search', 'owner' => 'Linkup', 'description' => 'AI search crawler' ), array( 'name' => 'meta-webindexer', 'tier' => 'search', 'owner' => 'Meta', 'description' => 'Meta AI search index' ), array( 'name' => 'PetalBot', 'tier' => 'search', 'owner' => 'Huawei', 'description' => 'Petal assistant/AI search' ), array( 'name' => 'PhindBot', 'tier' => 'search', 'owner' => 'Phind', 'description' => 'Phind AI answers' ), array( 'name' => 'TavilyBot', 'tier' => 'search', 'owner' => 'Tavily', 'description' => 'Search API for AI agents' ), // Agentic tier (blocking these prevents agent interactions). array( 'name' => 'OAI-SearchBot', 'tier' => 'agentic', 'owner' => 'OpenAI', 'description' => 'OpenAI search agent' ), array( 'name' => 'Claude-Web', 'tier' => 'agentic', 'owner' => 'Anthropic', 'description' => 'Claude computer-use agent' ), array( 'name' => 'Amazonbot', 'tier' => 'agentic', 'owner' => 'Amazon', 'description' => 'Alexa/shopping agent' ), array( 'name' => 'YouBot', 'tier' => 'agentic', 'owner' => 'You.com', 'description' => 'You.com AI agent' ), array( 'name' => 'Diffbot', 'tier' => 'agentic', 'owner' => 'Diffbot', 'description' => 'Structured data extraction agent' ), array( 'name' => 'AmazonBuyForMe', 'tier' => 'agentic', 'owner' => 'Amazon', 'description' => 'Buy-for-me shopping agent' ), array( 'name' => 'Amzn-User', 'tier' => 'agentic', 'owner' => 'Amazon', 'description' => 'Assistant-triggered page fetch' ), array( 'name' => 'bigsur.ai', 'tier' => 'agentic', 'owner' => 'Big Sur AI', 'description' => 'Assistant page fetcher' ), array( 'name' => 'ChatGPT Agent', 'tier' => 'agentic', 'owner' => 'OpenAI', 'description' => 'ChatGPT agent mode browsing' ), array( 'name' => 'Claude-Code', 'tier' => 'agentic', 'owner' => 'Anthropic', 'description' => 'Claude coding agent fetches' ), array( 'name' => 'Claude-User', 'tier' => 'agentic', 'owner' => 'Anthropic', 'description' => 'User-triggered Claude fetch' ), array( 'name' => 'Cursor', 'tier' => 'agentic', 'owner' => 'Anysphere', 'description' => 'Cursor coding agent fetches' ), array( 'name' => 'Devin', 'tier' => 'agentic', 'owner' => 'Cognition', 'description' => 'Devin coding agent fetches' ), array( 'name' => 'Gemini-Deep-Research', 'tier' => 'agentic', 'owner' => 'Google', 'description' => 'Gemini deep research fetches' ), array( 'name' => 'Google-Agent', 'tier' => 'agentic', 'owner' => 'Google', 'description' => 'Google AI agent browsing' ), array( 'name' => 'Google-Firebase', 'tier' => 'agentic', 'owner' => 'Google', 'description' => 'Firebase AI app fetches' ), array( 'name' => 'Google-NotebookLM', 'tier' => 'agentic', 'owner' => 'Google', 'description' => 'NotebookLM source fetch' ), array( 'name' => 'GoogleAgent-Mariner', 'tier' => 'agentic', 'owner' => 'Google', 'description' => 'Project Mariner browsing agent' ), array( 'name' => 'GoogleAgent-URLContext', 'tier' => 'agentic', 'owner' => 'Google', 'description' => 'Gemini URL-context fetch' ), array( 'name' => 'kagi-fetcher', 'tier' => 'agentic', 'owner' => 'Kagi', 'description' => 'Kagi assistant fetch' ), array( 'name' => 'Kimi-User', 'tier' => 'agentic', 'owner' => 'Moonshot AI', 'description' => 'Kimi assistant fetch' ), array( 'name' => 'Manus-User', 'tier' => 'agentic', 'owner' => 'Manus', 'description' => 'Manus agent fetch' ), array( 'name' => 'meta-externalfetcher', 'tier' => 'agentic', 'owner' => 'Meta', 'description' => 'Meta assistant link fetch' ), array( 'name' => 'MistralAI-User', 'tier' => 'agentic', 'owner' => 'Mistral', 'description' => 'Le Chat user-triggered fetch' ), array( 'name' => 'NovaAct', 'tier' => 'agentic', 'owner' => 'Amazon', 'description' => 'Nova Act browsing agent' ), array( 'name' => 'Operator', 'tier' => 'agentic', 'owner' => 'OpenAI', 'description' => 'Operator browsing agent' ), array( 'name' => 'Perplexity-User', 'tier' => 'agentic', 'owner' => 'Perplexity', 'description' => 'User-triggered Perplexity fetch' ), array( 'name' => 'TongyiBot', 'tier' => 'agentic', 'owner' => 'Alibaba', 'description' => 'Qwen/Tongyi assistant fetch' ), array( 'name' => 'YiyanBot', 'tier' => 'agentic', 'owner' => 'Baidu', 'description' => 'Ernie assistant fetch' ), ); /** * Public accessor for the AI bot taxonomy (used by Telemetry for * classified traffic counters, in addition to the robots.txt audit here). * * @return array[] */ public static function get_ai_bots() { return self::$ai_bots; } /** * Run all checks (cached). * * @param bool $force Bypass the cache. * @return array[] Checks: {id, label, status, detail}. */ public function run_checks( $force = false ) { if ( ! $force ) { $cached = get_transient( Cache::LOCAL_VITALS ); if ( is_array( $cached ) && ! empty( $cached ) ) { return $cached; } } // Re-detect artifacts the site provides from another source, so the // results below annotate (and the serve handlers respect) fresh state. if ( $force || false === get_option( Existing_Content::OPTION, false ) ) { $detector = Plugin::instance()->existing_content; if ( $detector instanceof Existing_Content ) { $detector->refresh(); } } $checks = array(); $checks[] = $this->check_markdown_endpoint(); $checks[] = $this->annotate_external( $this->check_llms_txt(), 'llms_txt' ); $checks[] = $this->annotate_external( $this->check_llms_full_txt(), 'llms_full_txt' ); $checks[] = $this->annotate_external( $this->check_mcp_discovery(), 'mcp_json' ); $checks[] = $this->check_mcp_initialize(); $checks[] = $this->check_sitemap(); $checks[] = $this->check_robots_ai_bots(); set_transient( Cache::LOCAL_VITALS, $checks, self::CACHE_TTL ); return $checks; } /** * Prefix a check's detail when the artifact is provided by the site * itself (physical file or another plugin) rather than generated by * this plugin. * * @param array $check Check row. * @param string $key Existing_Content artifact key. * @return array */ private function annotate_external( $check, $key ) { if ( Existing_Content::is_external( $key ) ) { $check['detail'] = __( 'Already provided by your site (not generated by this plugin) - the existing content is respected and untouched.', 'ael-42a' ) . ' ' . $check['detail']; } return $check; } // --------------------------------------------------------------- checks. /** * Markdown endpoint self-test. * * @return array */ private function check_markdown_endpoint() { $target = $this->sample_singular_url(); if ( null === $target ) { return $this->result( 'markdown_endpoint', __( 'Markdown endpoint', 'ael-42a' ), 'warn', __( 'No published post or page found to test against.', 'ael-42a' ) ); } $url = add_query_arg( 'format', 'markdown', $target ); $response = $this->loopback_get( $url ); if ( is_wp_error( $response ) ) { return $this->result( 'markdown_endpoint', __( 'Markdown endpoint', 'ael-42a' ), 'fail', sprintf( /* translators: %s: error message. */ __( 'Self-request failed: %s', 'ael-42a' ), $response->get_error_message() ) ); } $code = wp_remote_retrieve_response_code( $response ); $type = wp_remote_retrieve_header( $response, 'content-type' ); $body = wp_remote_retrieve_body( $response ); if ( 200 !== $code ) { return $this->result( 'markdown_endpoint', __( 'Markdown endpoint', 'ael-42a' ), 'fail', sprintf( /* translators: %d: HTTP status. */ __( 'Expected HTTP 200, got %d. A cache or security layer may be intercepting ?format=markdown.', 'ael-42a' ), $code ) ); } if ( false === stripos( (string) $type, 'text/markdown' ) ) { return $this->result( 'markdown_endpoint', __( 'Markdown endpoint', 'ael-42a' ), 'warn', sprintf( /* translators: %s: content type. */ __( 'Unexpected Content-Type "%s" - a full-page cache may be serving the HTML variant.', 'ael-42a' ), (string) $type ) ); } if ( 0 !== strpos( ltrim( $body ), '---' ) ) { return $this->result( 'markdown_endpoint', __( 'Markdown endpoint', 'ael-42a' ), 'warn', __( 'Response is missing the YAML frontmatter block.', 'ael-42a' ) ); } return $this->result( 'markdown_endpoint', __( 'Markdown endpoint', 'ael-42a' ), 'pass', sprintf( /* translators: %s: URL. */ __( 'Serving text/markdown with frontmatter at %s', 'ael-42a' ), $url ) ); } /** * llms.txt self-test. * * @return array */ private function check_llms_txt() { return $this->check_text_file( 'llms_txt', __( 'llms.txt', 'ael-42a' ), home_url( '/llms.txt' ) ); } /** * llms-full.txt self-test. * * @return array */ private function check_llms_full_txt() { return $this->check_text_file( 'llms_full_txt', __( 'llms-full.txt', 'ael-42a' ), home_url( '/llms-full.txt' ) ); } /** * Shared llms*.txt probe. * * @param string $id Check id. * @param string $label Check label. * @param string $url URL to fetch. * @return array */ private function check_text_file( $id, $label, $url ) { $response = $this->loopback_get( $url ); if ( is_wp_error( $response ) ) { return $this->result( $id, $label, 'fail', sprintf( /* translators: %s: error message. */ __( 'Self-request failed: %s', 'ael-42a' ), $response->get_error_message() ) ); } $code = wp_remote_retrieve_response_code( $response ); $body = ltrim( wp_remote_retrieve_body( $response ) ); if ( 200 !== $code ) { return $this->result( $id, $label, 'fail', sprintf( /* translators: 1: URL, 2: HTTP status. */ __( '%1$s returned HTTP %2$d. Rewrites may be broken or a server rule intercepts the path.', 'ael-42a' ), $url, $code ) ); } if ( 0 === stripos( $body, 'result( $id, $label, 'fail', __( 'Got an HTML page instead of plain text - likely a redirect or custom 404.', 'ael-42a' ) ); } if ( 0 !== strpos( $body, '# ' ) ) { return $this->result( $id, $label, 'warn', __( 'File served but does not start with a markdown H1.', 'ael-42a' ) ); } return $this->result( $id, $label, 'pass', sprintf( /* translators: 1: URL, 2: size in bytes. */ __( '%1$s serving %2$d bytes of markdown.', 'ael-42a' ), $url, strlen( $body ) ) ); } /** * /.well-known/mcp.json self-test. * * @return array */ private function check_mcp_discovery() { $url = home_url( '/.well-known/mcp.json' ); $response = $this->loopback_get( $url ); if ( is_wp_error( $response ) ) { return $this->result( 'mcp_discovery', __( 'MCP discovery (mcp.json)', 'ael-42a' ), 'fail', sprintf( /* translators: %s: error message. */ __( 'Self-request failed: %s', 'ael-42a' ), $response->get_error_message() ) ); } $code = wp_remote_retrieve_response_code( $response ); $json = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $json ) ) { return $this->result( 'mcp_discovery', __( 'MCP discovery (mcp.json)', 'ael-42a' ), 'fail', sprintf( /* translators: 1: URL, 2: HTTP status. */ __( '%1$s did not return a valid discovery document (HTTP %2$d). /.well-known/ may be reserved by the server.', 'ael-42a' ), $url, $code ) ); } // Accept every discovery shape in the wild: `endpoint` (common // convention), `url` (SEP-1649 server card), or a SEP-1960 manifest // wrapping either in `servers`. $endpoint = ''; foreach ( array( $json, isset( $json['servers'][0] ) && is_array( $json['servers'][0] ) ? $json['servers'][0] : array() ) as $doc ) { if ( ! empty( $doc['endpoint'] ) ) { $endpoint = (string) $doc['endpoint']; break; } if ( ! empty( $doc['url'] ) ) { $endpoint = (string) $doc['url']; break; } } if ( '' === $endpoint ) { // A respected external document with an unknown shape is not a // failure of this plugin - report it as a warning, not a fail. $status = Existing_Content::is_external( 'mcp_json' ) ? 'warn' : 'fail'; return $this->result( 'mcp_discovery', __( 'MCP discovery (mcp.json)', 'ael-42a' ), $status, __( 'Discovery document served but does not advertise an MCP endpoint (no endpoint/url field).', 'ael-42a' ) ); } return $this->result( 'mcp_discovery', __( 'MCP discovery (mcp.json)', 'ael-42a' ), 'pass', sprintf( /* translators: %s: endpoint URL. */ __( 'Discovery document advertises %s', 'ael-42a' ), $endpoint ) ); } /** * MCP initialize round-trip self-test. * * @return array */ private function check_mcp_initialize() { $url = rest_url( '42a/v1/mcp' ); $payload = array( 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => array( 'protocolVersion' => Mcp_Server::PROTOCOL_VERSION, 'capabilities' => new \stdClass(), 'clientInfo' => array( 'name' => 'ael-42a-selftest', 'version' => AEL42A_VERSION, ), ), ); $response = wp_remote_post( $url, array( 'timeout' => 10, 'sslverify' => false, // Loopback request - local certs are often self-signed. 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $payload ), ) ); if ( is_wp_error( $response ) ) { return $this->result( 'mcp_initialize', __( 'MCP server (initialize)', 'ael-42a' ), 'fail', sprintf( /* translators: %s: error message. */ __( 'Self-request failed: %s', 'ael-42a' ), $response->get_error_message() ) ); } $code = wp_remote_retrieve_response_code( $response ); $json = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $json ) || empty( $json['result']['protocolVersion'] ) ) { return $this->result( 'mcp_initialize', __( 'MCP server (initialize)', 'ael-42a' ), 'fail', sprintf( /* translators: 1: URL, 2: HTTP status. */ __( 'initialize round-trip against %1$s failed (HTTP %2$d). Is the REST API blocked?', 'ael-42a' ), $url, $code ) ); } return $this->result( 'mcp_initialize', __( 'MCP server (initialize)', 'ael-42a' ), 'pass', sprintf( /* translators: %s: protocol version. */ __( 'MCP server answered initialize with protocol %s', 'ael-42a' ), (string) $json['result']['protocolVersion'] ) ); } /** * Sitemap detection: core wp_sitemaps, Yoast, RankMath, or a live /sitemap.xml. * * @return array */ private function check_sitemap() { if ( defined( 'WPSEO_VERSION' ) ) { return $this->result( 'sitemap', __( 'XML sitemap', 'ael-42a' ), 'pass', __( 'Yoast SEO is active and provides sitemaps.', 'ael-42a' ) ); } if ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) ) { return $this->result( 'sitemap', __( 'XML sitemap', 'ael-42a' ), 'pass', __( 'Rank Math is active and provides sitemaps.', 'ael-42a' ) ); } if ( function_exists( 'wp_sitemaps_get_server' ) ) { $server = wp_sitemaps_get_server(); if ( $server && method_exists( $server, 'sitemaps_enabled' ) && $server->sitemaps_enabled() ) { return $this->result( 'sitemap', __( 'XML sitemap', 'ael-42a' ), 'pass', __( 'WordPress core sitemaps are enabled (/wp-sitemap.xml).', 'ael-42a' ) ); } } $response = $this->loopback_get( home_url( '/sitemap.xml' ) ); if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) { return $this->result( 'sitemap', __( 'XML sitemap', 'ael-42a' ), 'pass', __( '/sitemap.xml responds with HTTP 200.', 'ael-42a' ) ); } return $this->result( 'sitemap', __( 'XML sitemap', 'ael-42a' ), 'warn', __( 'No sitemap detected. Enable core sitemaps or an SEO plugin so crawlers and agents can enumerate your pages.', 'ael-42a' ) ); } /** * robots.txt AI-bot audit against the 3-tier taxonomy. * * @return array */ private function check_robots_ai_bots() { $label = __( 'robots.txt AI-bot access', 'ael-42a' ); $response = $this->loopback_get( home_url( '/robots.txt' ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return $this->result( 'robots_ai_bots', $label, 'pass', __( 'No robots.txt served (or unreachable) - no AI bots are blocked. WordPress usually serves a permissive virtual robots.txt.', 'ael-42a' ) ); } $groups = $this->parse_robots_groups( wp_remote_retrieve_body( $response ) ); $blocked_important = array(); $blocked_training = array(); foreach ( self::$ai_bots as $bot ) { if ( ! $this->is_bot_blocked( $groups, $bot['name'] ) ) { continue; } if ( 'training' === $bot['tier'] ) { $blocked_training[] = $bot['name'] . ' (' . $bot['owner'] . ')'; } else { $blocked_important[] = sprintf( '%s - %s tier, %s: %s', $bot['name'], $bot['tier'], $bot['owner'], $bot['description'] ); } } if ( ! empty( $blocked_important ) ) { return $this->result( 'robots_ai_bots', $label, 'fail', sprintf( /* translators: %s: list of bots. */ __( 'robots.txt blocks AI search/agentic bots, which hurts your visibility in AI answers: %s', 'ael-42a' ), implode( '; ', $blocked_important ) ) ); } if ( ! empty( $blocked_training ) ) { return $this->result( 'robots_ai_bots', $label, 'pass', sprintf( /* translators: %s: list of bots. */ __( 'Only training-tier crawlers are blocked (%s) - that is a legitimate policy choice and does not hurt AI search visibility.', 'ael-42a' ), implode( ', ', $blocked_training ) ) ); } return $this->result( 'robots_ai_bots', $label, 'pass', sprintf( /* translators: %d: number of known AI bots checked. */ __( 'None of the %d known AI bots (training, search, agentic tiers) are blocked by robots.txt.', 'ael-42a' ), count( self::$ai_bots ) ) ); } // -------------------------------------------------------------- helpers. /** * Parse robots.txt into user-agent groups. * * @param string $body robots.txt body. * @return array Map of lowercased user-agent => array of disallow paths. */ private function parse_robots_groups( $body ) { $groups = array(); $current_agents = array(); $last_was_agent = false; foreach ( preg_split( '/\r\n|\r|\n/', (string) $body ) as $line ) { $line = trim( preg_replace( '/#.*$/', '', $line ) ); if ( '' === $line || false === strpos( $line, ':' ) ) { continue; } list( $field, $value ) = array_map( 'trim', explode( ':', $line, 2 ) ); $field = strtolower( $field ); if ( 'user-agent' === $field ) { if ( ! $last_was_agent ) { $current_agents = array(); } $agent = strtolower( $value ); $current_agents[] = $agent; if ( ! isset( $groups[ $agent ] ) ) { $groups[ $agent ] = array(); } $last_was_agent = true; } elseif ( 'disallow' === $field ) { foreach ( $current_agents as $agent ) { $groups[ $agent ][] = $value; } $last_was_agent = false; } elseif ( 'allow' === $field || 'crawl-delay' === $field || 'sitemap' === $field ) { if ( 'sitemap' !== $field ) { $last_was_agent = false; } } } return $groups; } /** * Whether a bot is blocked from the site root. * * The group matching the bot's own user-agent wins over the `*` group * (per robots.txt precedence). "Blocked" means a `Disallow: /` rule. * * @param array $groups Parsed groups. * @param string $bot Bot user-agent name. * @return bool */ private function is_bot_blocked( $groups, $bot ) { $key = strtolower( $bot ); if ( isset( $groups[ $key ] ) ) { $rules = $groups[ $key ]; } elseif ( isset( $groups['*'] ) ) { $rules = $groups['*']; } else { return false; } foreach ( $rules as $path ) { if ( '/' === trim( $path ) ) { return true; } } return false; } /** * Pick a published singular URL to self-test the markdown endpoint with. * * @return string|null */ private function sample_singular_url() { $front_id = (int) get_option( 'page_on_front' ); if ( $front_id > 0 ) { $front = get_post( $front_id ); if ( $front instanceof \WP_Post && 'publish' === $front->post_status ) { return get_permalink( $front ); } } foreach ( array( 'post', 'page' ) as $type ) { $query = new \WP_Query( array( 'post_type' => $type, 'post_status' => 'publish', 'posts_per_page' => 1, 'no_found_rows' => true, ) ); if ( ! empty( $query->posts ) ) { return get_permalink( $query->posts[0] ); } } return null; } /** * Loopback GET with a short timeout and relaxed SSL (self-requests on * local/staging hosts frequently use self-signed certificates). * * @param string $url URL. * @return array|\WP_Error */ private function loopback_get( $url ) { return wp_remote_get( $url, array( 'timeout' => 10, 'sslverify' => false, 'redirection' => 3, 'headers' => array( 'X-42A-AEL-Selftest' => AEL42A_VERSION ), ) ); } /** * Shape one check result. * * @param string $id Check id. * @param string $label Human label. * @param string $status pass|warn|fail. * @param string $detail Detail text. * @return array */ private function result( $id, $label, $status, $detail ) { return array( 'id' => $id, 'label' => $label, 'status' => $status, 'detail' => $detail, ); } }