Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.10% covered (warning)
87.10%
108 / 124
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ai_Crawlers
87.10% covered (warning)
87.10%
108 / 124
81.82% covered (warning)
81.82%
9 / 11
25.24
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_catalog
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
1
 is_blocked_by_default
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_overrides
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 is_blocked
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_blocked_slugs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 search_engines_allowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_crawl_restricted_subdomain
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 has_static_robots_txt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 append_directives
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 get_bootstrap_data
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * AI crawler access controls.
4 *
5 * Lets a site owner allow or block individual AI crawlers (training and
6 * answer-engine bots) by appending per-user-agent `Disallow` rules to the
7 * WordPress-generated robots.txt.
8 *
9 * Persistence uses a STORE-INTENT model rather than a literal blocked list. The
10 * durable option `jetpack_seo_ai_crawler_overrides` holds only *deviations* from
11 * each bot's default policy (training crawlers blocked, answer engines allowed);
12 * a bot with no stored override falls back to its default. This keeps the stored
13 * map sparse and means newly added training crawlers are covered automatically
14 * without a migration.
15 *
16 * Caveat: the `robots_txt` filter only feeds WordPress's *virtual* robots.txt.
17 * A site serving a physical robots.txt file bypasses it entirely — a real
18 * limitation surfaced in the AI tab (see {@see self::has_static_robots_txt()}).
19 *
20 * @package automattic/jetpack-seo-package
21 */
22
23namespace Automattic\Jetpack\SEO;
24
25/**
26 * Emits robots.txt directives that block opted-out AI crawlers.
27 */
28class Ai_Crawlers {
29
30    /**
31     * Option holding the sparse map of per-crawler overrides
32     * (`slug => bool`, true = blocked). Only entries that deviate from the bot's
33     * default policy are stored. Mirrored in the plugin's settings endpoint
34     * whitelist (`jp_group => 'seo-tools'`).
35     *
36     * @var string
37     */
38    const OPTION = 'jetpack_seo_ai_crawler_overrides';
39
40    /**
41     * Wire the robots.txt filter.
42     *
43     * @return void
44     */
45    public static function init() {
46        add_filter( 'robots_txt', array( __CLASS__, 'append_directives' ), 10, 2 );
47    }
48
49    /**
50     * The known AI crawler catalog: slug => [ label, user_agent, type ].
51     *
52     * `slug` is the stable key persisted in the override map and sent by the AI
53     * tab; `user_agent` is the token written to the `User-agent:` robots line;
54     * `label` is the human name shown in the UI; `type` is `answer` (fetches to
55     * cite in live AI answers, allowed by default) or `training` (collects to train
56     * models, blocked by default), which drives the AI tab's two sections and the
57     * per-type default.
58     *
59     * @return array<string, array<string, string>>
60     */
61    public static function get_catalog() {
62        $catalog = array(
63            // Answer-engine crawlers fetch pages so AI assistants can cite them in
64            // live answers. Allowed by default — blocking them costs AI visibility.
65            'oai-searchbot'      => array(
66                'label'      => __( 'ChatGPT Search (OpenAI)', 'jetpack-seo' ),
67                'user_agent' => 'OAI-SearchBot',
68                'type'       => 'answer',
69            ),
70            'claude-searchbot'   => array(
71                'label'      => __( 'Claude Search (Anthropic)', 'jetpack-seo' ),
72                'user_agent' => 'Claude-SearchBot',
73                'type'       => 'answer',
74            ),
75            'perplexitybot'      => array(
76                'label'      => __( 'Perplexity', 'jetpack-seo' ),
77                'user_agent' => 'PerplexityBot',
78                'type'       => 'answer',
79            ),
80            'amzn-searchbot'     => array(
81                'label'      => __( 'Amazon (Alexa)', 'jetpack-seo' ),
82                'user_agent' => 'Amzn-SearchBot',
83                'type'       => 'answer',
84            ),
85            // Training crawlers collect content to train AI models. Blocked by
86            // default — blocking protects content with no AI-visibility downside.
87            'gptbot'             => array(
88                'label'      => __( 'ChatGPT (OpenAI)', 'jetpack-seo' ),
89                'user_agent' => 'GPTBot',
90                'type'       => 'training',
91            ),
92            'claudebot'          => array(
93                'label'      => __( 'Claude (Anthropic)', 'jetpack-seo' ),
94                'user_agent' => 'ClaudeBot',
95                'type'       => 'training',
96            ),
97            'google-extended'    => array(
98                'label'      => __( 'Google AI (Gemini)', 'jetpack-seo' ),
99                'user_agent' => 'Google-Extended',
100                'type'       => 'training',
101            ),
102            'applebot-extended'  => array(
103                'label'      => __( 'Apple Intelligence', 'jetpack-seo' ),
104                'user_agent' => 'Applebot-Extended',
105                'type'       => 'training',
106            ),
107            'meta-externalagent' => array(
108                'label'      => __( 'Meta AI', 'jetpack-seo' ),
109                'user_agent' => 'meta-externalagent',
110                'type'       => 'training',
111            ),
112            'bytespider'         => array(
113                'label'      => __( 'ByteDance', 'jetpack-seo' ),
114                'user_agent' => 'Bytespider',
115                'type'       => 'training',
116            ),
117            'ccbot'              => array(
118                'label'      => __( 'Common Crawl', 'jetpack-seo' ),
119                'user_agent' => 'CCBot',
120                'type'       => 'training',
121            ),
122            'amazonbot'          => array(
123                // Amazon's own docs: Amazonbot "may be used to train Amazon AI
124                // models" (Amzn-SearchBot is the answer-engine bot above).
125                'label'      => __( 'Amazon', 'jetpack-seo' ),
126                'user_agent' => 'Amazonbot',
127                'type'       => 'training',
128            ),
129        );
130
131        return $catalog;
132    }
133
134    /**
135     * Whether a catalog bot is blocked when the owner hasn't set an override for
136     * it: training crawlers are blocked by default, answer engines allowed.
137     *
138     * @param string $slug Catalog slug.
139     * @return bool
140     */
141    private static function is_blocked_by_default( $slug ) {
142        $catalog = self::get_catalog();
143        return isset( $catalog[ $slug ] ) && 'training' === $catalog[ $slug ]['type'];
144    }
145
146    /**
147     * The resolved, sparse override map: `slug => bool` (true = blocked).
148     *
149     * Reads the stored option, drops unknown slugs, casts values to bool, and
150     * drops any entry that equals the bot's default policy — so the returned map
151     * contains only real deviations. A bot absent from this map falls back to its
152     * default in {@see self::is_blocked()}.
153     *
154     * @return array<string, bool>
155     */
156    public static function get_overrides() {
157        $stored = get_option( self::OPTION, array() );
158        if ( ! is_array( $stored ) ) {
159            return array();
160        }
161
162        $catalog   = self::get_catalog();
163        $overrides = array();
164        foreach ( $stored as $slug => $blocked ) {
165            if ( ! isset( $catalog[ $slug ] ) ) {
166                continue;
167            }
168            $blocked = (bool) $blocked;
169            // Only keep entries that actually deviate from the default policy
170            // (training blocked, answer allowed) — resolved from the catalog we
171            // already hold rather than re-fetching it per slug.
172            if ( $blocked === ( 'training' === $catalog[ $slug ]['type'] ) ) {
173                continue;
174            }
175            $overrides[ $slug ] = $blocked;
176        }
177
178        return $overrides;
179    }
180
181    /**
182     * Whether a given crawler is currently blocked, applying an override when one
183     * exists and otherwise the bot's default policy. Unknown slugs return false.
184     *
185     * @param string $slug Catalog slug.
186     * @return bool
187     */
188    public static function is_blocked( $slug ) {
189        $overrides = self::get_overrides();
190        return array_key_exists( $slug, $overrides )
191            ? $overrides[ $slug ]
192            : self::is_blocked_by_default( $slug );
193    }
194
195    /**
196     * The catalog slugs currently blocked (override or default).
197     *
198     * @return string[]
199     */
200    public static function get_blocked_slugs() {
201        // Resolve the override map and catalog once, then fold in the per-type
202        // default — rather than calling is_blocked() per slug (which would re-read
203        // the option and rebuild the catalog on every robots.txt render).
204        $overrides = self::get_overrides();
205        $blocked   = array();
206        foreach ( self::get_catalog() as $slug => $info ) {
207            $is_blocked = array_key_exists( $slug, $overrides )
208                ? $overrides[ $slug ]
209                : ( 'training' === $info['type'] );
210            if ( $is_blocked ) {
211                $blocked[] = $slug;
212            }
213        }
214        return $blocked;
215    }
216
217    /**
218     * Whether the site allows search-engine (and therefore AI-crawler) indexing.
219     *
220     * When `blog_public` is off, WordPress disallows everything in robots.txt, so
221     * per-crawler controls are moot — the AI tab surfaces this instead of showing
222     * toggles that can't take effect.
223     *
224     * @return bool
225     */
226    public static function search_engines_allowed() {
227        return (int) get_option( 'blog_public', 1 ) === 1;
228    }
229
230    /**
231     * Whether the site is served from a WordPress.com staging subdomain
232     * (`*.wpcomstaging.com`) where the platform blocks all crawling regardless of
233     * the site's own settings — so AI-crawler controls can't take effect.
234     *
235     * @return bool
236     */
237    public static function is_crawl_restricted_subdomain() {
238        $host   = (string) wp_parse_url( home_url(), PHP_URL_HOST );
239        $suffix = '.wpcomstaging.com';
240        return strlen( $host ) > strlen( $suffix )
241            && substr( $host, -strlen( $suffix ) ) === $suffix;
242    }
243
244    /**
245     * Whether a physical `robots.txt` exists at the web root. The web server
246     * serves that file directly, bypassing WordPress's virtual robots.txt — so our
247     * `robots_txt` filter (and therefore these settings) never run. Common on
248     * local, sandbox, and staging sites.
249     *
250     * @return bool
251     */
252    public static function has_static_robots_txt() {
253        return file_exists( ABSPATH . 'robots.txt' );
254    }
255
256    /**
257     * Append `Disallow: /` blocks for each blocked AI crawler to robots.txt.
258     *
259     * @param string $output The robots.txt content assembled so far.
260     * @param bool   $public Whether the site is public (`blog_public`). When
261     *                       false WordPress already disallows everything, but the
262     *                       explicit per-bot blocks are still valid and harmless.
263     * @return string The robots.txt content with AI-crawler directives appended.
264     */
265    public static function append_directives( $output, $public ) {
266        unset( $public );
267
268        $catalog = self::get_catalog();
269        $blocked = self::get_blocked_slugs();
270        if ( empty( $blocked ) ) {
271            return $output;
272        }
273
274        $lines = array( '', '# AI crawlers blocked via Jetpack SEO.' );
275        foreach ( $blocked as $slug ) {
276            $lines[] = 'User-agent: ' . $catalog[ $slug ]['user_agent'];
277            $lines[] = 'Disallow: /';
278            $lines[] = '';
279        }
280
281        return $output . implode( "\n", $lines ) . "\n";
282    }
283
284    /**
285     * The AI tab's crawler bootstrap payload: the catalog (camelCased for JS), the
286     * current sparse override map, and the environment flags that decide whether
287     * the controls render or are replaced by an explanation.
288     *
289     * @return array
290     */
291    public static function get_bootstrap_data() {
292        $catalog = array();
293        foreach ( self::get_catalog() as $slug => $info ) {
294            $catalog[] = array(
295                'slug'      => $slug,
296                'label'     => $info['label'],
297                'userAgent' => $info['user_agent'],
298                'type'      => $info['type'],
299            );
300        }
301
302        return array(
303            'catalog'              => $catalog,
304            'overrides'            => (object) self::get_overrides(),
305            'searchEnginesVisible' => self::search_engines_allowed(),
306            'restrictedSubdomain'  => self::is_crawl_restricted_subdomain(),
307            'staticRobotsTxt'      => self::has_static_robots_txt(),
308        );
309    }
310}