Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.43% covered (success)
99.43%
174 / 175
94.12% covered (success)
94.12%
16 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ai_Crawlers
99.43% covered (success)
99.43%
174 / 175
94.12% covered (success)
94.12%
16 / 17
46
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%
63 / 63
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_blocked_slugs
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 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
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 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_agent, type ].
79     *
80     * `slug` is the stable key persisted in {@see self::OPTION} and sent by the GEO
81     * tab; `user_agent` is the token written to the `User-agent:` robots line;
82     * `label` is the human name shown in the UI; `type` is `answer` (fetches to
83     * cite in live AI answers) or `training` (collects to train models).
84     *
85     * @return array<string, array<string, string>>
86     */
87    public static function get_catalog() {
88        // Labels are the name a user is most likely to recognize, with the exact
89        // robots.txt user-agent token in parentheses so a technical user can verify
90        // the emitted directive (and to disambiguate the search/training pairs, e.g.
91        // ChatGPT's GPTBot vs OAI-SearchBot).
92        $catalog = array(
93            // Answer-engine crawlers fetch pages so AI assistants can cite them in
94            // live answers. Blocking them costs AI visibility.
95            'oai-searchbot'      => array(
96                'label'      => __( 'ChatGPT Search (OAI-SearchBot)', 'jetpack-seo' ),
97                'user_agent' => 'OAI-SearchBot',
98                'type'       => 'answer',
99            ),
100            'claude-searchbot'   => array(
101                'label'      => __( 'Claude Search (Claude-SearchBot)', 'jetpack-seo' ),
102                'user_agent' => 'Claude-SearchBot',
103                'type'       => 'answer',
104            ),
105            'perplexitybot'      => array(
106                'label'      => __( 'Perplexity (PerplexityBot)', 'jetpack-seo' ),
107                'user_agent' => 'PerplexityBot',
108                'type'       => 'answer',
109            ),
110            'amzn-searchbot'     => array(
111                'label'      => __( 'Amazon Alexa (Amzn-SearchBot)', 'jetpack-seo' ),
112                'user_agent' => 'Amzn-SearchBot',
113                'type'       => 'answer',
114            ),
115            // Training crawlers collect content to train AI models. Blocking them doesn't
116            // affect search results or Google's AI Overviews. Google-Extended also covers
117            // Gemini app answers.
118            'gptbot'             => array(
119                'label'      => __( 'ChatGPT (GPTBot)', 'jetpack-seo' ),
120                'user_agent' => 'GPTBot',
121                'type'       => 'training',
122            ),
123            'claudebot'          => array(
124                'label'      => __( 'Claude (ClaudeBot)', 'jetpack-seo' ),
125                'user_agent' => 'ClaudeBot',
126                'type'       => 'training',
127            ),
128            'google-extended'    => array(
129                'label'      => __( 'Google Gemini (Google-Extended)', 'jetpack-seo' ),
130                'user_agent' => 'Google-Extended',
131                'type'       => 'training',
132            ),
133            'applebot-extended'  => array(
134                'label'      => __( 'Apple Intelligence (Applebot-Extended)', 'jetpack-seo' ),
135                'user_agent' => 'Applebot-Extended',
136                'type'       => 'training',
137            ),
138            'meta-externalagent' => array(
139                'label'      => __( 'Meta AI (meta-externalagent)', 'jetpack-seo' ),
140                'user_agent' => 'meta-externalagent',
141                'type'       => 'training',
142            ),
143            'bytespider'         => array(
144                'label'      => __( 'ByteDance (Bytespider)', 'jetpack-seo' ),
145                'user_agent' => 'Bytespider',
146                'type'       => 'training',
147            ),
148            'ccbot'              => array(
149                'label'      => __( 'Common Crawl (CCBot)', 'jetpack-seo' ),
150                'user_agent' => 'CCBot',
151                'type'       => 'training',
152            ),
153            'amazonbot'          => array(
154                // Amazon's own docs: Amazonbot "may be used to train Amazon AI
155                // models" (Amzn-SearchBot is the answer-engine bot above).
156                'label'      => __( 'Amazon (Amazonbot)', 'jetpack-seo' ),
157                'user_agent' => 'Amazonbot',
158                'type'       => 'training',
159            ),
160        );
161
162        return $catalog;
163    }
164
165    /**
166     * Whether WordPress.com's site-wide data-sharing opt-out is enabled.
167     *
168     * Its `robots_txt` filter remains authoritative over per-bot settings.
169     *
170     * @return bool
171     */
172    public static function has_data_sharing_opt_out() {
173        return (bool) get_option( 'wpcom_data_sharing_opt_out' );
174    }
175
176    /**
177     * The catalog slugs WordPress.com's data-sharing opt-out blocks while it's on.
178     *
179     * Mirrors the user-agent list in jetpack-mu-wpcom's blog-privacy `robots_txt` filter.
180     *
181     * @return string[]
182     */
183    public static function get_data_sharing_blocked_slugs() {
184        return array(
185            'amazonbot',
186            'applebot-extended',
187            'bytespider',
188            'ccbot',
189            'claudebot',
190            'google-extended',
191            'gptbot',
192            'meta-externalagent',
193            'perplexitybot',
194        );
195    }
196
197    /**
198     * The crawlers the owner blocked, per group.
199     *
200     * @return array<string, string|string[]> `'all'` or a list of slugs for each group.
201     */
202    public static function get_blocked() {
203        return self::sanitize_blocked( get_option( self::OPTION, array() ) );
204    }
205
206    /**
207     * Normalizes a stored or submitted {@see self::OPTION} value: both groups present, each
208     * `'all'` or a list of slugs. Drops unknown slugs and slugs from the other group, and
209     * saves a list naming every crawler in its group as `'all'`.
210     *
211     * @param mixed      $value   The value to normalize.
212     * @param array|null $catalog Optional {@see self::get_catalog()} result, to avoid building it again.
213     * @return array<string, string|string[]>
214     */
215    public static function sanitize_blocked( $value, $catalog = null ) {
216        $value   = is_array( $value ) ? $value : array();
217        $catalog = is_array( $catalog ) ? $catalog : self::get_catalog();
218        $groups  = array_fill_keys( self::GROUPS, array() );
219        foreach ( $catalog as $slug => $info ) {
220            if ( isset( $groups[ $info['type'] ] ) ) {
221                $groups[ $info['type'] ][] = $slug;
222            }
223        }
224
225        $blocked = array();
226        foreach ( $groups as $type => $slugs ) {
227            $group = $value[ $type ] ?? array();
228            if ( 'all' === $group ) {
229                $blocked[ $type ] = 'all';
230                continue;
231            }
232            $list             = is_array( $group )
233                ? array_values( array_intersect( $slugs, array_filter( $group, 'is_string' ) ) )
234                : array();
235            $blocked[ $type ] = ( $slugs && count( $list ) === count( $slugs ) ) ? 'all' : $list;
236        }
237
238        return $blocked;
239    }
240
241    /**
242     * Run {@see self::migrate_legacy_overrides()} the first time {@see self::OPTION} is read while missing.
243     *
244     * @return void
245     */
246    public static function register_legacy_migration() {
247        // After register_setting()'s default (priority 10), which would replace the migrated value.
248        add_filter( 'default_option_' . self::OPTION, array( __CLASS__, 'migrate_legacy_overrides' ), self::MIGRATION_PRIORITY );
249    }
250
251    /**
252     * `default_option_` callback: copies {@see self::LEGACY_OPTION} into {@see self::OPTION}, then deletes it.
253     *
254     * A training crawler saved as allowed means the owner worked on that group, so its unsaved
255     * siblings stay blocked. With no old option there is nothing to write.
256     *
257     * @param mixed $default_value The value `get_option()` would return for the missing option.
258     * @return mixed The migrated value, or `$default_value` when there is nothing to migrate.
259     */
260    public static function migrate_legacy_overrides( $default_value ) {
261        // The catalog loads translations, which must wait for `init`.
262        if ( ! did_action( 'init' ) ) {
263            return $default_value;
264        }
265
266        $legacy = get_option( self::LEGACY_OPTION );
267        if ( ! is_array( $legacy ) ) {
268            return $default_value;
269        }
270
271        $catalog          = self::get_catalog();
272        $answer_blocked   = array();
273        $training_allowed = array();
274        foreach ( $legacy as $slug => $value ) {
275            $type = $catalog[ $slug ]['type'] ?? null;
276            if ( 'answer' === $type && $value ) {
277                $answer_blocked[] = $slug;
278            } elseif ( 'training' === $type && ! $value ) {
279                $training_allowed[] = $slug;
280            }
281        }
282
283        $training = array_keys( wp_list_filter( $catalog, array( 'type' => 'training' ) ) );
284        $blocked  = self::sanitize_blocked(
285            array(
286                'answer'   => $answer_blocked,
287                'training' => $training_allowed ? array_diff( $training, $training_allowed ) : array(),
288            ),
289            $catalog
290        );
291        // add_option() applies this filter again. Re-armed after, for other sites read in this request.
292        remove_filter( 'default_option_' . self::OPTION, array( __CLASS__, 'migrate_legacy_overrides' ), self::MIGRATION_PRIORITY );
293        $added = add_option( self::OPTION, $blocked, '', true );
294        self::register_legacy_migration();
295        if ( $added ) {
296            delete_option( self::LEGACY_OPTION );
297        }
298
299        return $blocked;
300    }
301
302    /**
303     * Whether a given crawler is currently blocked. Unknown slugs return false.
304     *
305     * @param string $slug Catalog slug.
306     * @return bool
307     */
308    public static function is_blocked( $slug ) {
309        return in_array( $slug, self::get_blocked_slugs(), true );
310    }
311
312    /**
313     * The catalog slugs currently blocked. While WordPress.com's data-sharing opt-out
314     * is on, its list replaces the owner's choices, as it does in robots.txt.
315     *
316     * @return string[]
317     */
318    public static function get_blocked_slugs() {
319        if ( self::has_data_sharing_opt_out() ) {
320            return self::get_data_sharing_blocked_slugs();
321        }
322
323        $catalog = self::get_catalog();
324        $blocked = self::sanitize_blocked( get_option( self::OPTION, array() ), $catalog );
325        $slugs   = array();
326        foreach ( $catalog as $slug => $info ) {
327            $group = $blocked[ $info['type'] ] ?? array();
328            if ( 'all' === $group || in_array( $slug, $group, true ) ) {
329                $slugs[] = $slug;
330            }
331        }
332        return $slugs;
333    }
334
335    /**
336     * Whether the site allows search-engine (and therefore AI-crawler) indexing.
337     *
338     * When `blog_public` is off, WordPress disallows everything in robots.txt, so
339     * per-crawler controls are moot — the GEO tab surfaces this instead of showing
340     * toggles that can't take effect.
341     *
342     * @return bool
343     */
344    public static function search_engines_allowed() {
345        return (int) get_option( 'blog_public', 1 ) === 1;
346    }
347
348    /**
349     * Whether the site is served from a WordPress.com staging subdomain
350     * (`*.wpcomstaging.com`) where the platform blocks all crawling regardless of
351     * the site's own settings — so AI-crawler controls can't take effect.
352     *
353     * @return bool
354     */
355    public static function is_crawl_restricted_subdomain() {
356        $host   = (string) wp_parse_url( home_url(), PHP_URL_HOST );
357        $suffix = '.wpcomstaging.com';
358        return strlen( $host ) > strlen( $suffix )
359            && substr( $host, -strlen( $suffix ) ) === $suffix;
360    }
361
362    /**
363     * Whether a static `robots.txt` file exists in the WordPress installation
364     * directory. These controls only change WordPress's virtual robots.txt and
365     * cannot modify that file.
366     *
367     * @return bool
368     */
369    public static function has_static_robots_txt() {
370        return Static_Files::exists( 'robots.txt' );
371    }
372
373    /**
374     * Whether this is a path-based multisite network.
375     *
376     * Such networks share one origin-level robots.txt, so per-site controls
377     * cannot be represented safely.
378     *
379     * @return bool
380     */
381    public static function is_path_based_multisite() {
382        return is_multisite() && ! is_subdomain_install();
383    }
384
385    /**
386     * Append `Disallow: /` blocks for each blocked AI crawler to robots.txt.
387     *
388     * @param string $output The robots.txt content assembled so far.
389     * @param bool   $public Whether the site is public (`blog_public`). Unused: the
390     *                       per-bot blocks apply either way.
391     * @return string The robots.txt content with AI-crawler directives appended.
392     */
393    public static function append_directives( $output, $public ) {
394        unset( $public );
395
396        // WordPress.com's existing privacy filter owns the site-wide opt-out. Defer
397        // to it instead of emitting duplicate directives from this per-bot filter.
398        if ( self::is_path_based_multisite() || self::has_data_sharing_opt_out() ) {
399            return $output;
400        }
401
402        $catalog = self::get_catalog();
403        $blocked = self::get_blocked_slugs();
404        if ( empty( $blocked ) ) {
405            return $output;
406        }
407
408        $lines = array( '', '# AI crawlers blocked via Jetpack SEO.' );
409        foreach ( $blocked as $slug ) {
410            $lines[] = 'User-agent: ' . $catalog[ $slug ]['user_agent'];
411            $lines[] = 'Disallow: /';
412            $lines[] = '';
413        }
414
415        return $output . implode( "\n", $lines ) . "\n";
416    }
417
418    /**
419     * The GEO tab's crawler bootstrap payload: the catalog (camelCased for JS), the
420     * crawlers the owner blocked, the crawlers the data-sharing opt-out blocks, and the
421     * environment flags that decide whether the controls render or are replaced by an explanation.
422     *
423     * @return array
424     */
425    public static function get_bootstrap_data() {
426        $catalog = array();
427        foreach ( self::get_catalog() as $slug => $info ) {
428            $catalog[] = array(
429                'slug'      => $slug,
430                'label'     => $info['label'],
431                'userAgent' => $info['user_agent'],
432                'type'      => $info['type'],
433            );
434        }
435
436        return array(
437            'catalog'              => $catalog,
438            'blocked'              => self::get_blocked(),
439            'searchEnginesVisible' => self::search_engines_allowed(),
440            'restrictedSubdomain'  => self::is_crawl_restricted_subdomain(),
441            'staticRobotsTxt'      => self::has_static_robots_txt(),
442            'dataSharingOptOut'    => self::has_data_sharing_opt_out(),
443            'dataSharingBlocked'   => self::get_data_sharing_blocked_slugs(),
444            'pathBasedMultisite'   => self::is_path_based_multisite(),
445            'privacySettingsUrl'   => self::privacy_settings_url(),
446            'robotsTxtUrl'         => home_url( '/robots.txt' ),
447        );
448    }
449
450    /**
451     * URL of the site visibility settings (private, Coming Soon, "Prevent third-party
452     * sharing"), linked from the dashboard wherever one of them governs a toggle.
453     *
454     * Points at wp-admin → Settings → Reading, which is the same-origin home of those
455     * options on the sites where they exist (WordPress.com). Left as a package method
456     * so a Calypso deep link can replace it without touching the client.
457     *
458     * @return string
459     */
460    public static function privacy_settings_url() {
461        return admin_url( 'options-reading.php' );
462    }
463}