*/ public const CATEGORIES = [ 'search', 'assistant', 'training', 'other' ]; /** * Memoized flat catalogue. * * @var array>|null */ private static ?array $all = null; /** * Every known AI crawler, keyed by its robots.txt user-agent token. * * @return array> Token => `vendor`, `category`, `docs`, `label`. */ public static function all(): array { if ( null !== self::$all ) { return self::$all; } $all = []; foreach ( self::vendors() as $vendor => $spec ) { foreach ( $spec['bots'] as $token => $category ) { $all[ $token ] = [ 'vendor' => $vendor, 'category' => $category, 'docs' => $spec['docs'], 'label' => $token, ]; } } self::$all = $all; return self::$all; } /** * The policy presets a site can pick from. * * @return array> Preset key => `label`, `description`, `rules`, `content_signal`. */ public static function presets(): array { $citable_rules = [ 'search' => 'allow', 'assistant' => 'allow', 'training' => 'disallow', 'other' => 'allow', ]; $citable_signal = [ 'search' => 'yes', 'ai_input' => 'yes', 'ai_train' => 'no', ]; return [ 'citable' => [ 'label' => 'Citable (recommended)', 'description' => 'AI search, assistants and general-purpose fetchers may read and cite your pages. Bulk training crawlers are turned away.', 'rules' => $citable_rules, 'content_signal' => $citable_signal, ], 'search_only' => [ 'label' => 'Search only', 'description' => 'AI search indexers and general-purpose fetchers may read the site. Assistants and training crawlers are turned away.', 'rules' => [ 'search' => 'allow', 'assistant' => 'disallow', 'training' => 'disallow', 'other' => 'allow', ], 'content_signal' => [ 'search' => 'yes', 'ai_input' => 'no', 'ai_train' => 'no', ], ], 'open' => [ 'label' => 'Open', 'description' => 'Every AI crawler is welcome, model training included.', 'rules' => [ 'search' => 'allow', 'assistant' => 'allow', 'training' => 'allow', 'other' => 'allow', ], 'content_signal' => [ 'search' => 'yes', 'ai_input' => 'yes', 'ai_train' => 'yes', ], ], 'closed' => [ 'label' => 'Closed', 'description' => 'No AI crawler is allowed. Classic search engines are not listed here, so they stay untouched.', 'rules' => [ 'search' => 'disallow', 'assistant' => 'disallow', 'training' => 'disallow', 'other' => 'disallow', ], 'content_signal' => [ 'search' => 'no', 'ai_input' => 'no', 'ai_train' => 'no', ], ], 'custom' => [ 'label' => 'Custom', 'description' => 'Choose bot by bot, starting from the Citable defaults.', 'rules' => $citable_rules, 'content_signal' => $citable_signal, ], ]; } /** * Resolves a preset plus per-bot overrides into one decision per crawler. * * Both sides of the comparison go through `normalize_key()`, because stored * override keys have already been through `sanitize_key()`. * * @param string $preset Preset key. An unknown preset falls back to `citable`. * @param array $overrides Token => `allow`, `disallow` or `inherit`. `inherit` is ignored. * @return array Token => `allow` or `disallow`, for every registered crawler. */ public static function resolve( string $preset, array $overrides = [] ): array { $rules = self::preset( $preset )['rules']; $forced = []; foreach ( $overrides as $token => $state ) { if ( 'allow' === $state || 'disallow' === $state ) { $forced[ self::normalize_key( (string) $token ) ] = $state; } } $resolved = []; foreach ( self::all() as $token => $meta ) { $resolved[ $token ] = $forced[ self::normalize_key( $token ) ] ?? ( $rules[ $meta['category'] ] ?? 'disallow' ); } return $resolved; } /** * Normalizes a user-agent token into the form settings store it under. * * `Options` keeps per-bot overrides in a `map` node, whose keys go through * `sanitize_key()`: lowercased, with everything outside `[a-z0-9_-]` dropped. * Comparing raw registry tokens against stored keys would therefore never * match for a token carrying a dot or an uppercase letter, so every lookup * normalizes both sides through here. * * @param string $token User-agent token or stored settings key. * @return string Normalized key, e.g. `meta-externalagent`. */ public static function normalize_key( string $token ): string { return (string) preg_replace( '/[^a-z0-9_-]/', '', strtolower( $token ) ); } /** * Builds the Content-Signal values for a preset, in wire format. * * @param string $preset Preset key. An unknown preset falls back to `citable`. * @param array $custom Settings values keyed `search`, `ai_input`, `ai_train`. Used by the `custom` preset only. * @return array `search`, `ai-input` and `ai-train` => `yes` or `no`. */ public static function content_signal( string $preset, array $custom = [] ): array { $presets = self::presets(); $key = isset( $presets[ $preset ] ) ? $preset : 'citable'; $signal = $presets[ $key ]['content_signal']; if ( 'custom' === $key ) { foreach ( array_keys( $signal ) as $field ) { if ( isset( $custom[ $field ] ) && in_array( $custom[ $field ], [ 'yes', 'no' ], true ) ) { $signal[ $field ] = $custom[ $field ]; } } } return [ 'search' => $signal['search'], 'ai-input' => $signal['ai_input'], 'ai-train' => $signal['ai_train'], ]; } /** * Finds the registered crawler a user-agent string belongs to. * * Tokens are tried longest first so `Claude-User` wins over `ClaudeBot`, and * the boundary lookarounds keep a short token from matching inside a longer * one, e.g. `Applebot` inside `Applebot-Extended`. * * @param string $user_agent Raw user-agent header. * @return string|null Registry token, or null when the agent is not a known AI crawler. */ public static function match_user_agent( string $user_agent ): ?string { $user_agent = strtolower( trim( $user_agent ) ); if ( '' === $user_agent ) { return null; } $tokens = array_keys( self::all() ); usort( $tokens, static function ( string $a, string $b ): int { return strlen( $b ) <=> strlen( $a ); } ); foreach ( $tokens as $token ) { $pattern = '/(?>> Vendor => token => meta. */ public static function grouped_by_vendor(): array { $grouped = []; foreach ( self::all() as $token => $meta ) { $grouped[ $meta['vendor'] ][ $token ] = $meta; } ksort( $grouped, SORT_NATURAL | SORT_FLAG_CASE ); foreach ( $grouped as $vendor => $bots ) { ksort( $bots ); $grouped[ $vendor ] = $bots; } return $grouped; } /** * One preset, falling back to `citable` when the key is unknown. * * @param string $preset Preset key. * @return array Preset spec. */ private static function preset( string $preset ): array { $presets = self::presets(); return $presets[ $preset ] ?? $presets['citable']; } /** * Source data: vendor => documentation URL and its crawler tokens. * * @return array> Vendor => `docs` and `bots` (token => category). */ private static function vendors(): array { return [ 'OpenAI' => [ 'docs' => 'https://platform.openai.com/docs/bots', 'bots' => [ 'GPTBot' => 'training', 'OAI-SearchBot' => 'search', 'ChatGPT-User' => 'assistant', ], ], 'Anthropic' => [ 'docs' => 'https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler', 'bots' => [ 'ClaudeBot' => 'training', 'Claude-SearchBot' => 'search', 'Claude-User' => 'assistant', ], ], 'Google' => [ 'docs' => 'https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers', 'bots' => [ 'Google-Extended' => 'training', 'GoogleOther' => 'other', 'Google-CloudVertexBot' => 'training', ], ], 'Apple' => [ 'docs' => 'https://support.apple.com/en-us/119829', 'bots' => [ 'Applebot' => 'search', 'Applebot-Extended' => 'training', ], ], 'Perplexity' => [ 'docs' => 'https://docs.perplexity.ai/guides/bots', 'bots' => [ 'PerplexityBot' => 'search', 'Perplexity-User' => 'assistant', ], ], 'Meta' => [ 'docs' => 'https://developers.facebook.com/docs/sharing/webmasters/web-crawlers', 'bots' => [ 'Meta-ExternalAgent' => 'training', 'Meta-ExternalFetcher' => 'assistant', 'FacebookBot' => 'training', ], ], 'Amazon' => [ 'docs' => 'https://developer.amazon.com/amazonbot', 'bots' => [ 'Amazonbot' => 'search', ], ], 'DuckDuckGo' => [ 'docs' => 'https://duckduckgo.com/duckduckgo-help-pages/results/duckassistbot/', 'bots' => [ 'DuckAssistBot' => 'search', ], ], 'You.com' => [ 'docs' => 'https://about.you.com/youbot/', 'bots' => [ 'YouBot' => 'search', ], ], 'Mistral AI' => [ 'docs' => 'https://docs.mistral.ai/robots/', 'bots' => [ 'MistralAI-User' => 'assistant', ], ], 'Cohere' => [ 'docs' => 'https://darkvisitors.com/agents/cohere-training-data-crawler', 'bots' => [ 'cohere-training-data-crawler' => 'training', ], ], 'Common Crawl' => [ 'docs' => 'https://commoncrawl.org/ccbot', 'bots' => [ 'CCBot' => 'training', ], ], 'ByteDance' => [ 'docs' => 'https://darkvisitors.com/agents/bytespider', 'bots' => [ 'Bytespider' => 'training', 'TikTokSpider' => 'training', ], ], 'Huawei' => [ 'docs' => 'https://aspiegel.com/petalbot', 'bots' => [ 'PetalBot' => 'search', 'PanguBot' => 'training', ], ], 'Diffbot' => [ 'docs' => 'https://darkvisitors.com/agents/diffbot', 'bots' => [ 'Diffbot' => 'training', ], ], 'Hive' => [ 'docs' => 'https://imagesift.com/about', 'bots' => [ 'ImagesiftBot' => 'training', ], ], 'Webz.io' => [ 'docs' => 'https://webz.io/blog/machine-learning/what-is-the-omgili-bot-and-why-is-it-crawling-your-website/', 'bots' => [ 'omgili' => 'training', 'omgilibot' => 'training', 'Webzio-Extended' => 'training', ], ], 'Timpi' => [ 'docs' => 'https://timpi.io/', 'bots' => [ 'Timpibot' => 'search', ], ], 'Ai2' => [ 'docs' => 'https://allenai.org/crawler', 'bots' => [ 'Ai2Bot' => 'training', 'Ai2Bot-Dolma' => 'training', ], ], 'Semrush' => [ 'docs' => 'https://www.semrush.com/bot/', 'bots' => [ 'SemrushBot-OCOB' => 'assistant', ], ], 'iAsk' => [ 'docs' => 'https://darkvisitors.com/agents/iaskspider', 'bots' => [ 'iaskspider' => 'search', ], ], 'Andi' => [ 'docs' => 'https://darkvisitors.com/agents/andibot', 'bots' => [ 'Andibot' => 'search', ], ], 'QuillBot' => [ 'docs' => 'https://darkvisitors.com/agents/quillbot', 'bots' => [ 'QuillBot' => 'training', ], ], ]; } }