> Agent token => label, company, purpose. */ function citolume_reachability_agents() { return array( 'OAI-SearchBot' => array( 'company' => 'OpenAI', 'purpose' => 'search', ), 'ChatGPT-User' => array( 'company' => 'OpenAI', 'purpose' => 'user', ), 'GPTBot' => array( 'company' => 'OpenAI', 'purpose' => 'training', ), 'Claude-SearchBot' => array( 'company' => 'Anthropic', 'purpose' => 'search', ), 'Claude-User' => array( 'company' => 'Anthropic', 'purpose' => 'user', ), 'ClaudeBot' => array( 'company' => 'Anthropic', 'purpose' => 'training', ), 'PerplexityBot' => array( 'company' => 'Perplexity', 'purpose' => 'search', ), 'Perplexity-User' => array( 'company' => 'Perplexity', 'purpose' => 'user', ), 'Google-Extended' => array( 'company' => 'Google', 'purpose' => 'training', ), 'Bytespider' => array( 'company' => 'ByteDance', 'purpose' => 'training', ), 'CCBot' => array( 'company' => 'Common Crawl', 'purpose' => 'training', ), ); } /** * The robots.txt this site actually serves, and where it came from. * * A physical file in the web root wins: the web server answers with it * before WordPress ever runs, silently shadowing anything a plugin filters * into the virtual response. Reading the served truth beats reasoning about * what WordPress would have said. * * @return array{text: string, source: string} Source is 'physical' or 'virtual'. */ function citolume_robots_txt() { $physical = ABSPATH . 'robots.txt'; if ( file_exists( $physical ) && is_readable( $physical ) ) { return array( 'text' => (string) file_get_contents( $physical ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local file. 'source' => 'physical', ); } // Mirror core's virtual robots.txt: its default body passed through the // same filter every robots plugin hooks. $public = (string) get_option( 'blog_public' ); $default = "User-agent: *\nDisallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n"; if ( '0' === $public ) { $default = "User-agent: *\nDisallow: /\n"; } return array( 'text' => (string) apply_filters( 'robots_txt', $default, $public ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own filter, read not added. 'source' => 'virtual', ); } /** * One agent's verdict from a robots.txt body. * * Group semantics per RFC 9309, kept small: groups may name several agents, * the most specific matching group wins over the `*` group, repeated groups * for the same agent combine, and within the winning group the root path is * what decides. A group that only fences paths (`Disallow: /wp-admin/`) * leaves the site reachable — that is 'no_rule', permitted by default, not * an "allow" the file never said. * * @param string $robots The robots.txt body. * @param string $agent Agent token, e.g. 'GPTBot'. * @return string 'allowed' | 'blocked' | 'no_rule'. */ function citolume_robots_verdict( $robots, $agent ) { $agent = strtolower( $agent ); $groups = array( 'specific' => array(), 'wildcard' => array(), ); $current = array(); $had_rule = false; foreach ( preg_split( '/\r\n|\r|\n/', $robots ) as $line ) { $line = trim( preg_replace( '/#.*$/', '', $line ) ); if ( '' === $line || ! preg_match( '/^([a-z\-]+)\s*:\s*(.*)$/i', $line, $m ) ) { continue; } $field = strtolower( $m[1] ); $value = trim( $m[2] ); if ( 'user-agent' === $field ) { // A user-agent line after rules starts a new group; consecutive // user-agent lines share the group that follows. if ( $had_rule ) { $current = array(); $had_rule = false; } $current[] = strtolower( $value ); continue; } if ( 'allow' !== $field && 'disallow' !== $field ) { continue; } $had_rule = true; foreach ( $current as $group_agent ) { $bucket = null; if ( '*' === $group_agent ) { $bucket = 'wildcard'; } elseif ( false !== strpos( $agent, $group_agent ) || false !== strpos( $group_agent, $agent ) ) { $bucket = 'specific'; } if ( $bucket ) { $groups[ $bucket ][] = array( 'rule' => $field, 'path' => $value, ); } } } foreach ( array( 'specific', 'wildcard' ) as $bucket ) { if ( ! $groups[ $bucket ] ) { continue; } $root_allow = false; $root_disallow = false; foreach ( $groups[ $bucket ] as $rule ) { if ( '/' === $rule['path'] ) { if ( 'allow' === $rule['rule'] ) { $root_allow = true; } else { $root_disallow = true; } } // An empty Disallow is the spec's way of saying "everything allowed". if ( 'disallow' === $rule['rule'] && '' === $rule['path'] ) { $root_allow = true; } } // Equally specific Allow beats Disallow at the same path, per the RFC. if ( $root_allow ) { return 'allowed'; } if ( $root_disallow ) { return 'blocked'; } // The winning group exists but only fences paths: reachable by // default, which is a different fact from an explicit Allow. return 'no_rule'; } return 'no_rule'; } /** * Things between the crawler and PHP that we can observe — and the honest * admission where we cannot see their configuration. * * @return array> Each: what was seen, what it may mean. */ function citolume_reachability_notes() { $notes = array(); if ( isset( $_SERVER['HTTP_CF_RAY'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- presence check only, value unused. $notes[] = array( 'seen' => __( 'Cloudflare is in front of this site.', 'citolume' ), 'note' => __( 'Its bot settings can block AI crawlers before WordPress ever runs. We cannot read that configuration, so we cannot tell — check the Cloudflare dashboard if crawlers you allow are not arriving.', 'citolume' ), ); } $security = array( 'WORDFENCE_VERSION' => 'Wordfence', 'SUCURISCAN' => 'Sucuri Security', 'AIO_WP_SECURITY_VERSION' => 'All In One WP Security', ); foreach ( $security as $constant => $name ) { if ( defined( $constant ) ) { $notes[] = array( 'seen' => sprintf( /* translators: %s: security plugin name. */ __( '%s is active.', 'citolume' ), $name ), 'note' => __( 'Security plugins can rate-limit or block bots independently of robots.txt. We cannot read its rules, so we cannot tell from here.', 'citolume' ), ); } } return $notes; } /** * The full reachability report, cached for half a day. * * @param bool $force Recheck now, ignoring the cache. * @return array */ function citolume_reachability( $force = false ) { $cached = get_transient( 'citolume_reachability' ); if ( ! $force && is_array( $cached ) ) { return $cached; } $robots = citolume_robots_txt(); $agents = array(); $blocked = 0; foreach ( citolume_reachability_agents() as $token => $meta ) { $verdict = citolume_robots_verdict( $robots['text'], $token ); if ( 'blocked' === $verdict ) { ++$blocked; } $agents[] = array( 'agent' => $token, 'company' => $meta['company'], 'purpose' => $meta['purpose'], 'verdict' => $verdict, ); } // The names, not just the count: the dashboard's Reachable caption names // which crawlers are blocked, as the platform's does. A number alone sends // the reader to another screen to find out which. $blocked_names = array(); foreach ( $agents as $agent ) { if ( 'blocked' === $agent['verdict'] ) { $blocked_names[] = $agent['agent']; } } $report = array( 'source' => $robots['source'], 'agents' => $agents, 'blocked' => $blocked, 'blocked_names' => $blocked_names, 'total' => count( $agents ), 'notes' => citolume_reachability_notes(), 'checked_at' => time(), ); set_transient( 'citolume_reachability', $report, 12 * HOUR_IN_SECONDS ); return $report; }