Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.03% covered (success)
99.03%
205 / 207
88.89% covered (warning)
88.89%
16 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ai_Crawlers
99.03% covered (success)
99.03%
205 / 207
88.89% covered (warning)
88.89%
16 / 18
52
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_catalog
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
1 / 1
1
 has_data_sharing_opt_out
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_data_sharing_user_agents
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 get_data_sharing_blocked_slugs
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_blocked
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitize_blocked
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
 register_legacy_migration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 migrate_legacy_overrides
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
10
 is_blocked
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_blocked_slugs
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 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
 is_path_based_multisite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 append_directives
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 get_bootstrap_data
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 privacy_settings_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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 * The option `jetpack_seo_blocked_crawlers` holds the crawlers the owner blocked, per
10 * group. Every other crawler is allowed, so robots.txt doesn't change when the SEO page
11 * reaches a site (JETPACK-3135).
12 *
13 * Caveat: the `robots_txt` filter only feeds WordPress's *virtual* robots.txt.
14 * A detected static file in the WordPress installation is not modified — a
15 * limitation surfaced in the GEO tab (see {@see self::has_static_robots_txt()}).
16 *
17 * @package automattic/jetpack-seo-package
18 */
19
20namespace Automattic\Jetpack\SEO;
21
22/**
23 * Emits robots.txt directives that block opted-out AI crawlers.
24 */
25class Ai_Crawlers {
26
27    /**
28     * Option holding the blocked crawlers as `array( 'answer' => …, 'training' => … )`, each
29     * `'all'` (every crawler in the group, including ones added to the catalog later) or a
30     * list of slugs. A missing group or an empty list blocks nothing. Registered with
31     * `show_in_rest` by {@see Dashboard_Data::register_rest_settings()} so the GEO tab can save it.
32     *
33     * @var string
34     */
35    const OPTION = 'jetpack_seo_blocked_crawlers';
36
37    /**
38     * Option the GEO tab saved before {@see self::OPTION}: `slug => bool`, holding only changes
39     * from the old defaults (training crawlers blocked, answer engines allowed). Deleted once
40     * {@see self::migrate_legacy_overrides()} has copied it.
41     *
42     * @var string
43     */
44    const LEGACY_OPTION = 'jetpack_seo_ai_crawler_overrides';
45
46    /**
47     * Priority of {@see self::migrate_legacy_overrides()} on the `default_option_` filter.
48     *
49     * @var int
50     */
51    const MIGRATION_PRIORITY = 20;
52
53    /**
54     * The crawler groups, matching each catalog entry's `type`.
55     *
56     * @var string[]
57     */
58    const GROUPS = array( 'answer', 'training' );
59
60    /**
61     * The `robots_txt` priority: after WordPress.com Simple appends bare `Disallow`
62     * lines at 11, which would otherwise land in the last AI crawler group.
63     *
64     * @var int
65     */
66    const ROBOTS_TXT_PRIORITY = 12;
67
68    /**
69     * Wire the robots.txt filter.
70     *
71     * @return void
72     */
73    public static function init() {
74        add_filter( 'robots_txt', array( __CLASS__, 'append_directives' ), self::ROBOTS_TXT_PRIORITY, 2 );
75    }
76
77    /**
78     * The known AI crawler catalog: slug => [ label, user_agents, type ].
79     *
80     * `slug` is the stable key persisted in {@see self::OPTION} and sent by the GEO
81     * tab; `user_agents` are every robots.txt token the operator uses for that
82     * crawler, each written to its own `User-agent:` group;
83     * `label` is the human name shown in the UI; `type` is `answer` (fetches to
84     * cite in live AI answers) or `training` (collects to train models).
85     *
86     * @return array<string, array{label: string, user_agents: string[], type: string}>
87     */
88    public static function get_catalog() {
89        // Labels are the name a user is most likely to recognize. The GEO tab lists the
90        // exact tokens under each so a technical user can verify the emitted directives.
91        $catalog = array(
92            // Answer-engine crawlers fetch pages so AI assistants can cite them in
93            // live answers. Blocking them costs AI visibility.
94            'oai-searchbot'      => array(
95                'label'       => __( 'ChatGPT Search', 'jetpack-seo' ),
96                'user_agents' => array( 'OAI-SearchBot' ),
97                'type'        => 'answer',
98            ),
99            'claude-searchbot'   => array(
100                'label'       => __( 'Claude Search', 'jetpack-seo' ),
101                'user_agents' => array( 'Claude-SearchBot' ),
102                'type'        => 'answer',
103            ),
104            'perplexitybot'      => array(
105                'label'       => __( 'Perplexity', 'jetpack-seo' ),
106                'user_agents' => array( 'PerplexityBot' ),
107                'type'        => 'answer',
108            ),
109            'amzn-searchbot'     => array(
110                'label'       => __( 'Amazon Alexa', 'jetpack-seo' ),
111                'user_agents' => array( 'Amzn-SearchBot' ),
112                'type'        => 'answer',
113            ),
114            'meta-webindexer'    => array(
115                'label'       => __( 'Meta AI Search', 'jetpack-seo' ),
116                'user_agents' => array( 'meta-webindexer' ),
117                'type'        => 'answer',
118            ),
119            'duckassistbot'      => array(
120                'label'       => __( 'DuckDuckGo Search Assist', 'jetpack-seo' ),
121                'user_agents' => array( 'DuckAssistBot' ),
122                'type'        => 'answer',
123            ),
124            // Training crawlers collect content to train AI models. Blocking them doesn't
125            // affect search results or Google's AI Overviews. Google-Extended also covers
126            // Gemini app answers.
127            'gptbot'             => array(
128                'label'       => __( 'ChatGPT', 'jetpack-seo' ),
129                'user_agents' => array( 'GPTBot' ),
130                'type'        => 'training',
131            ),
132            'claudebot'          => array(
133                'label'       => __( 'Claude', 'jetpack-seo' ),
134                'user_agents' => array( 'ClaudeBot', 'anthropic-ai', 'Claude-Web' ),
135                'type'        => 'training',
136            ),
137            'google-extended'    => array(
138                'label'       => __( 'Google Gemini', 'jetpack-seo' ),
139                'user_agents' => array( 'Google-Extended' ),
140                'type'        => 'training',
141            ),
142            'applebot-extended'  => array(
143                'label'       => __( 'Apple Intelligence', 'jetpack-seo' ),
144                'user_agents' => array( 'Applebot-Extended' ),
145                'type'        => 'training',
146            ),
147            'meta-externalagent' => array(
148                'label'       => __( 'Meta AI', 'jetpack-seo' ),
149                'user_agents' => array( 'meta-externalagent', 'FacebookBot' ),
150                'type'        => 'training',
151            ),
152            'bytespider'         => array(
153                'label'       => __( 'ByteDance', 'jetpack-seo' ),
154                'user_agents' => array( 'Bytespider' ),
155                'type'        => 'training',
156            ),
157            'ccbot'              => array(
158                'label'       => __( 'Common Crawl', 'jetpack-seo' ),
159                'user_agents' => array( 'CCBot' ),
160                'type'        => 'training',
161            ),
162            'amazonbot'          => array(
163                // Amazon's own docs: Amazonbot "may be used to train Amazon AI
164                // models" (Amzn-SearchBot is the answer-engine bot above).
165                'label'       => __( 'Amazon', 'jetpack-seo' ),
166                'user_agents' => array( 'Amazonbot' ),
167                'type'        => 'training',
168            ),
169            'omgili'             => array(
170                // Webz.io crawls to sell web data, including for AI training.
171                'label'       => __( 'Webz.io', 'jetpack-seo' ),
172                'user_agents' => array( 'omgili', 'omgilibot', 'Webzio-Extended' ),
173                'type'        => 'training',
174            ),
175        );
176
177        return $catalog;
178    }
179
180    /**
181     * Whether WordPress.com's site-wide data-sharing opt-out is enabled.
182     *
183     * Its `robots_txt` filter remains authoritative over per-bot settings.
184     *
185     * @return bool
186     */
187    public static function has_data_sharing_opt_out() {
188        return (bool) get_option( 'wpcom_data_sharing_opt_out' );
189    }
190
191    /**
192     * The user agents WordPress.com's data-sharing opt-out blocks while it's on.
193     *
194     * A copy of the list in jetpack-mu-wpcom's blog-privacy `robots_txt` filter;
195     * AiCrawlersTest runs both filters together to keep them in step.
196     *
197     * @return string[]
198     */
199    private static function get_data_sharing_user_agents() {
200        return array(
201            'Amazonbot',
202            'anthropic-ai',
203            'Applebot-Extended',
204            'Bytespider',
205            'CCBot',
206            'ClaudeBot',
207            'FacebookBot',
208            'Google-Extended',
209            'GPTBot',
210            'meta-externalagent',
211            'omgili',
212            'omgilibot',
213            'PerplexityBot',
214            'SentiBot',
215            'sentibot',
216        );
217    }
218
219    /**
220     * The catalog slugs WordPress.com's data-sharing opt-out blocks while it's on: every
221     * crawler with a user agent on its list. {@see self::append_directives()} writes the
222     * crawler's other user agents, so the whole crawler is blocked.
223     *
224     * @return string[]
225     */
226    public static function get_data_sharing_blocked_slugs() {
227        $user_agents = self::get_data_sharing_user_agents();
228        $slugs       = array();
229        foreach ( self::get_catalog() as $slug => $info ) {
230            if ( array_intersect( $info['user_agents'], $user_agents ) ) {
231                $slugs[] = $slug;
232            }
233        }
234        return $slugs;
235    }
236
237    /**
238     * The crawlers the owner blocked, per group.
239     *
240     * @return array<string, string|string[]> `'all'` or a list of slugs for each group.
241     */
242    public static function get_blocked() {
243        return self::sanitize_blocked( get_option( self::OPTION, array() ) );
244    }
245
246    /**
247     * Normalizes a stored or submitted {@see self::OPTION} value: both groups present, each
248     * `'all'` or a list of slugs. Drops unknown slugs and slugs from the other group, and
249     * saves a list naming every crawler in its group as `'all'`.
250     *
251     * @param mixed      $value   The value to normalize.
252     * @param array|null $catalog Optional {@see self::get_catalog()} result, to avoid building it again.
253     * @return array<string, string|string[]>
254     */
255    public static function sanitize_blocked( $value, $catalog = null ) {
256        $value   = is_array( $value ) ? $value : array();
257        $catalog = is_array( $catalog ) ? $catalog : self::get_catalog();
258        $groups  = array_fill_keys( self::GROUPS, array() );
259        foreach ( $catalog as $slug => $info ) {
260            if ( isset( $groups[ $info['type'] ] ) ) {
261                $groups[ $info['type'] ][] = $slug;
262            }
263        }
264
265        $blocked = array();
266        foreach ( $groups as $type => $slugs ) {
267            $group = $value[ $type ] ?? array();
268            if ( 'all' === $group ) {
269                $blocked[ $type ] = 'all';
270                continue;
271            }
272            $list             = is_array( $group )
273                ? array_values( array_intersect( $slugs, array_filter( $group, 'is_string' ) ) )
274                : array();
275            $blocked[ $type ] = ( $slugs && count( $list ) === count( $slugs ) ) ? 'all' : $list;
276        }
277
278        return $blocked;
279    }
280
281    /**
282     * Run {@see self::migrate_legacy_overrides()} the first time {@see self::OPTION} is read while missing.
283     *
284     * @return void
285     */
286    public static function register_legacy_migration() {
287        // After register_setting()'s default (priority 10), which would replace the migrated value.
288        add_filter( 'default_option_' . self::OPTION, array( __CLASS__, 'migrate_legacy_overrides' ), self::MIGRATION_PRIORITY );
289    }
290
291    /**
292     * `default_option_` callback: copies {@see self::LEGACY_OPTION} into {@see self::OPTION}, then deletes it.
293     *
294     * A training crawler saved as allowed means the owner worked on that group, so its unsaved
295     * siblings stay blocked. With no old option there is nothing to write.
296     *
297     * @param mixed $default_value The value `get_option()` would return for the missing option.
298     * @return mixed The migrated value, or `$default_value` when there is nothing to migrate.
299     */
300    public static function migrate_legacy_overrides( $default_value ) {
301        // The catalog loads translations, which must wait for `init`.
302        if ( ! did_action( 'init' ) ) {
303            return $default_value;
304        }
305
306        $legacy = get_option( self::LEGACY_OPTION );
307        if ( ! is_array( $legacy ) ) {
308            return $default_value;
309        }
310
311        $catalog          = self::get_catalog();
312        $answer_blocked   = array();
313        $training_allowed = array();
314        foreach ( $legacy as $slug => $value ) {
315            $type = $catalog[ $slug ]['type'] ?? null;
316            if ( 'answer' === $type && $value ) {
317                $answer_blocked[] = $slug;
318            } elseif ( 'training' === $type && ! $value ) {
319                $training_allowed[] = $slug;
320            }
321        }
322
323        $training = array_keys( wp_list_filter( $catalog, array( 'type' => 'training' ) ) );
324        $blocked  = self::sanitize_blocked(
325            array(
326                'answer'   => $answer_blocked,
327                'training' => $training_allowed ? array_diff( $training, $training_allowed ) : array(),
328            ),
329            $catalog
330        );
331        // add_option() applies this filter again. Re-armed after, for other sites read in this request.
332        remove_filter( 'default_option_' . self::OPTION, array( __CLASS__, 'migrate_legacy_overrides' ), self::MIGRATION_PRIORITY );
333        $added = add_option( self::OPTION, $blocked, '', true );
334        self::register_legacy_migration();
335        if ( $added ) {
336            delete_option( self::LEGACY_OPTION );
337        }
338
339        return $blocked;
340    }
341
342    /**
343     * Whether a given crawler is currently blocked. Unknown slugs return false.
344     *
345     * @param string $slug Catalog slug.
346     * @return bool
347     */
348    public static function is_blocked( $slug ) {
349        return in_array( $slug, self::get_blocked_slugs(), true );
350    }
351
352    /**
353     * The catalog slugs currently blocked. While WordPress.com's data-sharing opt-out
354     * is on, its list replaces the owner's choices, as it does in robots.txt.
355     *
356     * @return string[]
357     */
358    public static function get_blocked_slugs() {
359        if ( self::has_data_sharing_opt_out() ) {
360            return self::get_data_sharing_blocked_slugs();
361        }
362
363        $catalog = self::get_catalog();
364        $blocked = self::sanitize_blocked( get_option( self::OPTION, array() ), $catalog );
365        $slugs   = array();
366        foreach ( $catalog as $slug => $info ) {
367            $group = $blocked[ $info['type'] ] ?? array();
368            if ( 'all' === $group || in_array( $slug, $group, true ) ) {
369                $slugs[] = $slug;
370            }
371        }
372        return $slugs;
373    }
374
375    /**
376     * Whether the site allows search-engine (and therefore AI-crawler) indexing.
377     *
378     * When `blog_public` is off, WordPress disallows everything in robots.txt, so
379     * per-crawler controls are moot — the GEO tab surfaces this instead of showing
380     * toggles that can't take effect.
381     *
382     * @return bool
383     */
384    public static function search_engines_allowed() {
385        return (int) get_option( 'blog_public', 1 ) === 1;
386    }
387
388    /**
389     * Whether the site is served from a WordPress.com staging subdomain
390     * (`*.wpcomstaging.com`) where the platform blocks all crawling regardless of
391     * the site's own settings — so AI-crawler controls can't take effect.
392     *
393     * @return bool
394     */
395    public static function is_crawl_restricted_subdomain() {
396        $host   = (string) wp_parse_url( home_url(), PHP_URL_HOST );
397        $suffix = '.wpcomstaging.com';
398        return strlen( $host ) > strlen( $suffix )
399            && substr( $host, -strlen( $suffix ) ) === $suffix;
400    }
401
402    /**
403     * Whether a static `robots.txt` file exists in the WordPress installation
404     * directory. These controls only change WordPress's virtual robots.txt and
405     * cannot modify that file.
406     *
407     * @return bool
408     */
409    public static function has_static_robots_txt() {
410        return Static_Files::exists( 'robots.txt' );
411    }
412
413    /**
414     * Whether this is a path-based multisite network.
415     *
416     * Such networks share one origin-level robots.txt, so per-site controls
417     * cannot be represented safely.
418     *
419     * @return bool
420     */
421    public static function is_path_based_multisite() {
422        return is_multisite() && ! is_subdomain_install();
423    }
424
425    /**
426     * Append `Disallow: /` blocks for each blocked AI crawler to robots.txt.
427     *
428     * @param string $output The robots.txt content assembled so far.
429     * @param bool   $public Whether the site is public (`blog_public`). Unused: the
430     *                       per-bot blocks apply either way.
431     * @return string The robots.txt content with AI-crawler directives appended.
432     */
433    public static function append_directives( $output, $public ) {
434        unset( $public );
435
436        if ( self::is_path_based_multisite() ) {
437            return $output;
438        }
439
440        // WordPress.com's privacy filter writes its own list while the opt-out is on, so
441        // write only the user agents it leaves out rather than duplicate its directives.
442        $skip        = self::has_data_sharing_opt_out() ? self::get_data_sharing_user_agents() : array();
443        $catalog     = self::get_catalog();
444        $user_agents = array();
445        foreach ( self::get_blocked_slugs() as $slug ) {
446            foreach ( $catalog[ $slug ]['user_agents'] as $user_agent ) {
447                if ( ! in_array( $user_agent, $skip, true ) ) {
448                    $user_agents[] = $user_agent;
449                }
450            }
451        }
452        if ( empty( $user_agents ) ) {
453            return $output;
454        }
455
456        $lines = array( '', '# AI crawlers blocked via Jetpack SEO.' );
457        foreach ( $user_agents as $user_agent ) {
458            $lines[] = 'User-agent: ' . $user_agent;
459            $lines[] = 'Disallow: /';
460            $lines[] = '';
461        }
462
463        return $output . implode( "\n", $lines ) . "\n";
464    }
465
466    /**
467     * The GEO tab's crawler bootstrap payload: the catalog (camelCased for JS), the
468     * crawlers the owner blocked, the crawlers the data-sharing opt-out blocks, and the
469     * environment flags that decide whether the controls render or are replaced by an explanation.
470     *
471     * @return array
472     */
473    public static function get_bootstrap_data() {
474        $catalog = array();
475        foreach ( self::get_catalog() as $slug => $info ) {
476            $catalog[] = array(
477                'slug'       => $slug,
478                'label'      => $info['label'],
479                'userAgents' => $info['user_agents'],
480                'type'       => $info['type'],
481            );
482        }
483
484        return array(
485            'catalog'              => $catalog,
486            'blocked'              => self::get_blocked(),
487            'searchEnginesVisible' => self::search_engines_allowed(),
488            'restrictedSubdomain'  => self::is_crawl_restricted_subdomain(),
489            'staticRobotsTxt'      => self::has_static_robots_txt(),
490            'dataSharingOptOut'    => self::has_data_sharing_opt_out(),
491            'dataSharingBlocked'   => self::get_data_sharing_blocked_slugs(),
492            'pathBasedMultisite'   => self::is_path_based_multisite(),
493            'privacySettingsUrl'   => self::privacy_settings_url(),
494            'robotsTxtUrl'         => home_url( '/robots.txt' ),
495        );
496    }
497
498    /**
499     * URL of the site visibility settings (private, Coming Soon, "Prevent third-party
500     * sharing"), linked from the dashboard wherever one of them governs a toggle.
501     *
502     * Points at wp-admin → Settings → Reading, which is the same-origin home of those
503     * options on the sites where they exist (WordPress.com). Left as a package method
504     * so a Calypso deep link can replace it without touching the client.
505     *
506     * @return string
507     */
508    public static function privacy_settings_url() {
509        return admin_url( 'options-reading.php' );
510    }
511}