Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.35% covered (success)
99.35%
154 / 155
93.33% covered (success)
93.33%
14 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ai_Crawlers
99.35% covered (success)
99.35%
154 / 155
93.33% covered (success)
93.33%
14 / 15
35
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
 is_blocked_by_default
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 has_data_sharing_opt_out
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_blocked_by_data_sharing_policy
100.00% covered (success)
100.00%
16 / 16
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
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 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%
19 / 19
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 * 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-engine crawlers
12 * allowed); a bot with no stored override falls back to its default.
13 * This keeps the stored map sparse and means newly added training crawlers are
14 * covered automatically without a migration.
15 *
16 * Caveat: the `robots_txt` filter only feeds WordPress's *virtual* robots.txt.
17 * A detected static file in the WordPress installation is not modified — a
18 * limitation surfaced in the GEO 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. Registered with `show_in_rest` by
34     * {@see Dashboard_Data::register_rest_settings()} so the GEO tab can save it.
35     *
36     * @var string
37     */
38    const OPTION = 'jetpack_seo_ai_crawler_overrides';
39
40    /**
41     * The `robots_txt` priority: after WordPress.com Simple appends bare `Disallow`
42     * lines at 11, which would otherwise land in the last AI crawler group.
43     *
44     * @var int
45     */
46    const ROBOTS_TXT_PRIORITY = 12;
47
48    /**
49     * Wire the robots.txt filter.
50     *
51     * @return void
52     */
53    public static function init() {
54        add_filter( 'robots_txt', array( __CLASS__, 'append_directives' ), self::ROBOTS_TXT_PRIORITY, 2 );
55    }
56
57    /**
58     * The known AI crawler catalog: slug => [ label, user_agent, type ].
59     *
60     * `slug` is the stable key persisted in the override map and sent by the AI
61     * tab; `user_agent` is the token written to the `User-agent:` robots line;
62     * `label` is the human name shown in the UI; `type` is `answer` (fetches to
63     * cite in live AI answers) or `training` (collects to train models; some, like
64     * Google Gemini, also ground AI answers but are filed here). Training crawlers
65     * are blocked by default; answer-engine crawlers are allowed.
66     *
67     * @return array<string, array<string, string>>
68     */
69    public static function get_catalog() {
70        // Labels are the name a user is most likely to recognize, with the exact
71        // robots.txt user-agent token in parentheses so a technical user can verify
72        // the emitted directive (and to disambiguate the search/training pairs, e.g.
73        // ChatGPT's GPTBot vs OAI-SearchBot).
74        $catalog = array(
75            // Answer-engine crawlers fetch pages so AI assistants can cite them in
76            // live answers. Allowed by default — blocking them costs AI visibility.
77            'oai-searchbot'      => array(
78                'label'      => __( 'ChatGPT Search (OAI-SearchBot)', 'jetpack-seo' ),
79                'user_agent' => 'OAI-SearchBot',
80                'type'       => 'answer',
81            ),
82            'claude-searchbot'   => array(
83                'label'      => __( 'Claude Search (Claude-SearchBot)', 'jetpack-seo' ),
84                'user_agent' => 'Claude-SearchBot',
85                'type'       => 'answer',
86            ),
87            'perplexitybot'      => array(
88                'label'      => __( 'Perplexity (PerplexityBot)', 'jetpack-seo' ),
89                'user_agent' => 'PerplexityBot',
90                'type'       => 'answer',
91            ),
92            'amzn-searchbot'     => array(
93                'label'      => __( 'Amazon Alexa (Amzn-SearchBot)', 'jetpack-seo' ),
94                'user_agent' => 'Amzn-SearchBot',
95                'type'       => 'answer',
96            ),
97            // Training crawlers collect content to train AI models. Blocked by
98            // default. Some (e.g. Google Gemini) are also used to ground live AI
99            // answers, so blocking them protects privacy but can cost that
100            // visibility — filed here as training and blocked by default so the
101            // owner actively opts in.
102            'gptbot'             => array(
103                'label'      => __( 'ChatGPT (GPTBot)', 'jetpack-seo' ),
104                'user_agent' => 'GPTBot',
105                'type'       => 'training',
106            ),
107            'claudebot'          => array(
108                'label'      => __( 'Claude (ClaudeBot)', 'jetpack-seo' ),
109                'user_agent' => 'ClaudeBot',
110                'type'       => 'training',
111            ),
112            'google-extended'    => array(
113                'label'      => __( 'Google Gemini (Google-Extended)', 'jetpack-seo' ),
114                'user_agent' => 'Google-Extended',
115                'type'       => 'training',
116            ),
117            'applebot-extended'  => array(
118                'label'      => __( 'Apple Intelligence (Applebot-Extended)', 'jetpack-seo' ),
119                'user_agent' => 'Applebot-Extended',
120                'type'       => 'training',
121            ),
122            'meta-externalagent' => array(
123                'label'      => __( 'Meta AI (meta-externalagent)', 'jetpack-seo' ),
124                'user_agent' => 'meta-externalagent',
125                'type'       => 'training',
126            ),
127            'bytespider'         => array(
128                'label'      => __( 'ByteDance (Bytespider)', 'jetpack-seo' ),
129                'user_agent' => 'Bytespider',
130                'type'       => 'training',
131            ),
132            'ccbot'              => array(
133                'label'      => __( 'Common Crawl (CCBot)', 'jetpack-seo' ),
134                'user_agent' => 'CCBot',
135                'type'       => 'training',
136            ),
137            'amazonbot'          => array(
138                // Amazon's own docs: Amazonbot "may be used to train Amazon AI
139                // models" (Amzn-SearchBot is the answer-engine bot above).
140                'label'      => __( 'Amazon (Amazonbot)', 'jetpack-seo' ),
141                'user_agent' => 'Amazonbot',
142                'type'       => 'training',
143            ),
144        );
145
146        return $catalog;
147    }
148
149    /**
150     * Whether a catalog bot is blocked when the owner hasn't set an override for
151     * it: training crawlers are blocked by default; answer-engine crawlers are
152     * allowed.
153     *
154     * @param string $slug Catalog slug.
155     * @return bool
156     */
157    private static function is_blocked_by_default( $slug ) {
158        $catalog = self::get_catalog();
159        return isset( $catalog[ $slug ] ) && 'training' === $catalog[ $slug ]['type'];
160    }
161
162    /**
163     * Whether WordPress.com's site-wide data-sharing opt-out is enabled.
164     *
165     * Its `robots_txt` filter remains authoritative over per-bot settings.
166     *
167     * @return bool
168     */
169    public static function has_data_sharing_opt_out() {
170        return (bool) get_option( 'wpcom_data_sharing_opt_out' );
171    }
172
173    /**
174     * Whether the existing data-sharing policy forces a catalog bot to be blocked.
175     *
176     * @param string $slug Catalog slug.
177     * @return bool
178     */
179    private static function is_blocked_by_data_sharing_policy( $slug ) {
180        return self::has_data_sharing_opt_out()
181            && in_array(
182                $slug,
183                array(
184                    'amazonbot',
185                    'applebot-extended',
186                    'bytespider',
187                    'ccbot',
188                    'claudebot',
189                    'google-extended',
190                    'gptbot',
191                    'meta-externalagent',
192                    'perplexitybot',
193                ),
194                true
195            );
196    }
197
198    /**
199     * The resolved, sparse override map: `slug => bool` (true = blocked).
200     *
201     * Reads the stored option, drops unknown slugs, casts values to bool, and
202     * drops any entry that equals the bot's default policy — so the returned map
203     * contains only real deviations. A bot absent from this map falls back to its
204     * default in {@see self::is_blocked()}.
205     *
206     * @return array<string, bool>
207     */
208    public static function get_overrides() {
209        $stored = get_option( self::OPTION, array() );
210        if ( ! is_array( $stored ) ) {
211            return array();
212        }
213
214        $catalog   = self::get_catalog();
215        $overrides = array();
216        foreach ( $stored as $slug => $blocked ) {
217            if ( ! isset( $catalog[ $slug ] ) ) {
218                continue;
219            }
220            $blocked = (bool) $blocked;
221            // Only keep entries that actually deviate from the default policy
222            // (training blocked, answer-engine allowed) — resolved from the
223            // catalog we already hold rather than re-fetching it per slug.
224            if ( $blocked === ( 'training' === $catalog[ $slug ]['type'] ) ) {
225                continue;
226            }
227            $overrides[ $slug ] = $blocked;
228        }
229
230        return $overrides;
231    }
232
233    /**
234     * Whether a given crawler is currently blocked, applying an override when one
235     * exists and otherwise the bot's default policy. Unknown slugs return false.
236     *
237     * @param string $slug Catalog slug.
238     * @return bool
239     */
240    public static function is_blocked( $slug ) {
241        $catalog = self::get_catalog();
242        if ( ! isset( $catalog[ $slug ] ) ) {
243            return false;
244        }
245        if ( self::is_blocked_by_data_sharing_policy( $slug ) ) {
246            return true;
247        }
248
249        $overrides = self::get_overrides();
250        return array_key_exists( $slug, $overrides )
251            ? $overrides[ $slug ]
252            : self::is_blocked_by_default( $slug );
253    }
254
255    /**
256     * The catalog slugs currently blocked (override or default).
257     *
258     * @return string[]
259     */
260    public static function get_blocked_slugs() {
261        // Resolve the override map and catalog once, then fold in the per-type
262        // default — rather than calling is_blocked() per slug (which would re-read
263        // the option and rebuild the catalog on every robots.txt render).
264        $overrides = self::get_overrides();
265        $blocked   = array();
266        foreach ( self::get_catalog() as $slug => $info ) {
267            $is_blocked = self::is_blocked_by_data_sharing_policy( $slug )
268                || ( array_key_exists( $slug, $overrides )
269                    ? $overrides[ $slug ]
270                    : ( 'training' === $info['type'] ) );
271            if ( $is_blocked ) {
272                $blocked[] = $slug;
273            }
274        }
275        return $blocked;
276    }
277
278    /**
279     * Whether the site allows search-engine (and therefore AI-crawler) indexing.
280     *
281     * When `blog_public` is off, WordPress disallows everything in robots.txt, so
282     * per-crawler controls are moot — the GEO tab surfaces this instead of showing
283     * toggles that can't take effect.
284     *
285     * @return bool
286     */
287    public static function search_engines_allowed() {
288        return (int) get_option( 'blog_public', 1 ) === 1;
289    }
290
291    /**
292     * Whether the site is served from a WordPress.com staging subdomain
293     * (`*.wpcomstaging.com`) where the platform blocks all crawling regardless of
294     * the site's own settings — so AI-crawler controls can't take effect.
295     *
296     * @return bool
297     */
298    public static function is_crawl_restricted_subdomain() {
299        $host   = (string) wp_parse_url( home_url(), PHP_URL_HOST );
300        $suffix = '.wpcomstaging.com';
301        return strlen( $host ) > strlen( $suffix )
302            && substr( $host, -strlen( $suffix ) ) === $suffix;
303    }
304
305    /**
306     * Whether a static `robots.txt` file exists in the WordPress installation
307     * directory. These controls only change WordPress's virtual robots.txt and
308     * cannot modify that file.
309     *
310     * @return bool
311     */
312    public static function has_static_robots_txt() {
313        return Static_Files::exists( 'robots.txt' );
314    }
315
316    /**
317     * Whether this is a path-based multisite network.
318     *
319     * Such networks share one origin-level robots.txt, so per-site controls
320     * cannot be represented safely.
321     *
322     * @return bool
323     */
324    public static function is_path_based_multisite() {
325        return is_multisite() && ! is_subdomain_install();
326    }
327
328    /**
329     * Append `Disallow: /` blocks for each blocked AI crawler to robots.txt.
330     *
331     * @param string $output The robots.txt content assembled so far.
332     * @param bool   $public Whether the site is public (`blog_public`). Unused: the
333     *                       per-bot blocks apply either way.
334     * @return string The robots.txt content with AI-crawler directives appended.
335     */
336    public static function append_directives( $output, $public ) {
337        unset( $public );
338
339        // WordPress.com's existing privacy filter owns the site-wide opt-out. Defer
340        // to it instead of emitting duplicate directives from this per-bot filter.
341        if ( self::is_path_based_multisite() || self::has_data_sharing_opt_out() ) {
342            return $output;
343        }
344
345        $catalog = self::get_catalog();
346        $blocked = self::get_blocked_slugs();
347        if ( empty( $blocked ) ) {
348            return $output;
349        }
350
351        $lines = array( '', '# AI crawlers blocked via Jetpack SEO.' );
352        foreach ( $blocked as $slug ) {
353            $lines[] = 'User-agent: ' . $catalog[ $slug ]['user_agent'];
354            $lines[] = 'Disallow: /';
355            $lines[] = '';
356        }
357
358        return $output . implode( "\n", $lines ) . "\n";
359    }
360
361    /**
362     * The GEO tab's crawler bootstrap payload: the catalog (camelCased for JS), the
363     * current sparse override map, and the environment flags that decide whether
364     * the controls render or are replaced by an explanation.
365     *
366     * @return array
367     */
368    public static function get_bootstrap_data() {
369        $catalog = array();
370        foreach ( self::get_catalog() as $slug => $info ) {
371            $catalog[] = array(
372                'slug'      => $slug,
373                'label'     => $info['label'],
374                'userAgent' => $info['user_agent'],
375                'type'      => $info['type'],
376            );
377        }
378
379        return array(
380            'catalog'              => $catalog,
381            'overrides'            => (object) self::get_overrides(),
382            'searchEnginesVisible' => self::search_engines_allowed(),
383            'restrictedSubdomain'  => self::is_crawl_restricted_subdomain(),
384            'staticRobotsTxt'      => self::has_static_robots_txt(),
385            'dataSharingOptOut'    => self::has_data_sharing_opt_out(),
386            'pathBasedMultisite'   => self::is_path_based_multisite(),
387            'privacySettingsUrl'   => self::privacy_settings_url(),
388            'robotsTxtUrl'         => home_url( '/robots.txt' ),
389        );
390    }
391
392    /**
393     * URL of the settings screen holding "Prevent third-party sharing", linked from
394     * the GEO tab when that setting is governing crawler access.
395     *
396     * Points at wp-admin → Settings → Reading, which is the same-origin home of the
397     * option on the sites where it exists (WordPress.com). Left as a package method
398     * so a Calypso deep link can replace it without touching the client.
399     *
400     * @return string
401     */
402    public static function privacy_settings_url() {
403        return admin_url( 'options-reading.php' );
404    }
405}