|null */ private static ?array $hosts = null; /** * Memoized flat UTM index. * * @var array|null */ private static ?array $utm = null; /** * Every known answer engine, keyed by slug. * * A slug is what ends up in the `agent` column of one referral event, so it * stays stable: renaming one would split a site's history in two. * * @return array> Slug => `label`, `hosts` and `utm`. */ public static function all(): array { return [ 'chatgpt' => [ 'label' => 'ChatGPT', 'hosts' => [ 'chatgpt.com', 'chat.openai.com' ], 'utm' => [ 'chatgpt.com', 'chatgpt', 'openai' ], ], 'perplexity' => [ 'label' => 'Perplexity', 'hosts' => [ 'perplexity.ai', 'www.perplexity.ai' ], 'utm' => [ 'perplexity' ], ], 'claude' => [ 'label' => 'Claude', 'hosts' => [ 'claude.ai' ], 'utm' => [ 'claude', 'anthropic' ], ], 'gemini' => [ 'label' => 'Gemini', 'hosts' => [ 'gemini.google.com', 'bard.google.com' ], 'utm' => [ 'gemini' ], ], 'copilot' => [ 'label' => 'Microsoft Copilot', 'hosts' => [ 'copilot.microsoft.com' ], 'utm' => [ 'copilot', 'bing-copilot' ], ], 'grok' => [ 'label' => 'Grok', 'hosts' => [ 'grok.com', 'x.ai' ], 'utm' => [ 'grok' ], ], 'meta' => [ 'label' => 'Meta AI', 'hosts' => [ 'meta.ai' ], 'utm' => [ 'meta.ai' ], ], 'mistral' => [ 'label' => 'Le Chat (Mistral)', 'hosts' => [ 'chat.mistral.ai' ], 'utm' => [ 'mistral', 'lechat' ], ], 'you' => [ 'label' => 'You.com', 'hosts' => [ 'you.com' ], 'utm' => [ 'you.com' ], ], 'phind' => [ 'label' => 'Phind', 'hosts' => [ 'phind.com', 'www.phind.com' ], 'utm' => [ 'phind' ], ], 'poe' => [ 'label' => 'Poe', 'hosts' => [ 'poe.com' ], 'utm' => [ 'poe.com' ], ], 'kagi' => [ 'label' => 'Kagi', 'hosts' => [ 'kagi.com' ], 'utm' => [ 'kagi' ], ], 'duckduckgo_ai' => [ 'label' => 'DuckDuckGo AI', 'hosts' => [ 'duck.ai' ], 'utm' => [ 'duck.ai' ], ], 'deepseek' => [ 'label' => 'DeepSeek', 'hosts' => [ 'chat.deepseek.com' ], 'utm' => [ 'deepseek' ], ], 'andi' => [ 'label' => 'Andi', 'hosts' => [ 'andisearch.com' ], 'utm' => [ 'andisearch' ], ], ]; } /** * Human readable name of one engine. * * An unknown slug is returned as it came in: a site can add its own hosts * in the settings, and those carry no label. * * @param string $slug Engine slug. * @return string Label, or the slug when the engine is not in the catalogue. */ public static function label( string $slug ): string { $all = self::all(); return isset( $all[ $slug ] ) ? (string) $all[ $slug ]['label'] : $slug; } /** * Every host the catalogue knows, for the browser-side beacon. * * @param array $extra_hosts Extra host => slug pairs from the settings. * @return array Hosts, deduplicated. */ public static function host_list( array $extra_hosts = [] ): array { $hosts = array_keys( self::host_index() ); foreach ( array_keys( self::normalize_extra( $extra_hosts ) ) as $host ) { $hosts[] = $host; } return array_values( array_unique( $hosts ) ); } /** * Every `utm_source` token the catalogue knows, for the browser-side beacon. * * @return array Tokens, lowercase and deduplicated. */ public static function utm_list(): array { return array_keys( self::utm_index() ); } /** * Finds the engine a visit came from. * * The referrer wins over the tag, because a host cannot be typed into a * campaign field by accident. A host matches when it is the engine's host * or a subdomain of it, so `www.perplexity.ai` is Perplexity while * `evil-perplexity.ai` is nobody. * * `$query['_own_host']` is the site's own host: a visit referred by the * site itself is someone clicking a link, never a citation, so the referrer * is dropped before any host is compared. The tag still gets its chance — * a site that stamps its own AI campaign URLs asked for that explicitly. * * @param string $referer Raw `Referer` header, or an empty string. * @param array $query Request query. Reads `utm_source` and `_own_host`. * @param array $extra_hosts Extra host => slug pairs from the settings. * @return string|null Engine slug, or null when nothing matched. */ public static function match_referrer( string $referer, array $query = [], array $extra_hosts = [] ): ?string { $host = self::normalize_host( $referer ); $own_host = self::normalize_host( (string) ( $query['_own_host'] ?? '' ) ); if ( '' !== $host && '' !== $own_host && self::host_matches( $host, $own_host ) ) { $host = ''; } if ( '' !== $host ) { $slug = self::lookup_host( $host, self::host_index() ); if ( null === $slug ) { $slug = self::lookup_host( $host, self::normalize_extra( $extra_hosts ) ); } if ( null !== $slug ) { return $slug; } } $utm = strtolower( trim( (string) ( $query['utm_source'] ?? '' ) ) ); if ( '' === $utm ) { return null; } $index = self::utm_index(); return $index[ $utm ] ?? null; } /** * Reduces a host or a URL to a bare lowercase host. * * Accepts both, so a site owner may paste either into the settings, and so * a `Referer` header can be handed straight to it. Anything that comes back * not looking like a host is dropped: a `Referer` is ASCII, an * internationalised domain reaches us punycoded, and a settings field is a * place people paste sentences. * * @param string $value Host or absolute URL. * @return string Lowercase host, or an empty string when there is none. */ public static function normalize_host( string $value ): string { $value = strtolower( trim( $value ) ); if ( '' === $value ) { return ''; } if ( ! str_contains( $value, '//' ) ) { $value = '//' . $value; } // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- The Bots classes stay free of WordPress calls so the catalogue can be unit tested on its own. $host = parse_url( $value, PHP_URL_HOST ); if ( ! is_string( $host ) ) { return ''; } $host = trim( $host, '.' ); return 1 === preg_match( '/^[a-z0-9_.\-]+$/', $host ) ? $host : ''; } /** * Whether a host is, or sits under, another host. * * The dot in the suffix test is what keeps `evil-perplexity.ai` from * passing for `perplexity.ai`. * * @param string $host Host to test. * @param string $domain Domain to test it against. * @return bool True when `$host` is `$domain` or a subdomain of it. */ private static function host_matches( string $host, string $domain ): bool { return $host === $domain || str_ends_with( $host, '.' . $domain ); } /** * Finds the engine a host belongs to in one host => slug index. * * Longer domains are tried first, so a more specific entry wins over the * parent domain it sits under. * * @param string $host Host to look up. * @param array $index Host => slug. * @return string|null Engine slug, or null when the host is not in the index. */ private static function lookup_host( string $host, array $index ): ?string { if ( isset( $index[ $host ] ) ) { return $index[ $host ]; } $domains = array_keys( $index ); usort( $domains, static function ( string $a, string $b ): int { return strlen( $b ) <=> strlen( $a ); } ); foreach ( $domains as $domain ) { if ( self::host_matches( $host, $domain ) ) { return $index[ $domain ]; } } return null; } /** * The catalogue's hosts, flattened into a lookup index. * * @return array Host => engine slug. */ private static function host_index(): array { if ( null !== self::$hosts ) { return self::$hosts; } $index = []; foreach ( self::all() as $slug => $engine ) { foreach ( $engine['hosts'] as $host ) { $index[ (string) $host ] = $slug; } } self::$hosts = $index; return self::$hosts; } /** * The catalogue's UTM tokens, flattened into a lookup index. * * @return array Token => engine slug. */ private static function utm_index(): array { if ( null !== self::$utm ) { return self::$utm; } $index = []; foreach ( self::all() as $slug => $engine ) { foreach ( $engine['utm'] as $token ) { $index[ strtolower( (string) $token ) ] = $slug; } } self::$utm = $index; return self::$utm; } /** * Cleans the extra host => slug pairs a site added in its settings. * * @param array $extra_hosts Host => slug, as stored. * @return array Normalized host => slug, empty entries dropped. */ private static function normalize_extra( array $extra_hosts ): array { $clean = []; foreach ( $extra_hosts as $host => $slug ) { $host = self::normalize_host( (string) $host ); $slug = trim( (string) $slug ); if ( '' !== $host && '' !== $slug ) { $clean[ $host ] = $slug; } } return $clean; } }