settings = $settings; } /** * Register hooks. */ public function hooks() { add_filter( 'robots_txt', array( $this, 'filter_robots_txt' ), 10, 2 ); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'update_option_' . Oorank_Settings::OPTION, array( $this, 'refresh_static_robots' ) ); add_action( 'send_headers', array( $this, 'maybe_send_noai_header' ) ); add_action( 'send_headers', array( $this, 'maybe_send_usage_header' ) ); } /** * Emit X-Robots-Tag: noai, noimageai when a blocked crawler is the one * asking. robots.txt is the primary signal; this is a supplementary hint * some engines honour, so it is opt-in via headers.noai and only ever sent * to a bot the owner already blocked. */ public function maybe_send_noai_header() { if ( headers_sent() ) { return; } $settings = $this->settings->get(); if ( empty( $settings['headers']['noai'] ) ) { return; } if ( null === $this->match_blocked_ua( $this->request_user_agent() ) ) { return; } header( 'X-Robots-Tag: noai, noimageai', false ); } /** * The Content-Signal expression describing the current bot decision. * * Content Signals (the robots.txt extension) and ai-preferences (the IETF * work on the same problem) both answer a question a per-bot Disallow * cannot: what a crawler we have never heard of may do with the page. The * three signals map onto the registry's categories — search onto `search`, * ai-input onto the on-demand `assistant` fetchers, ai-train onto * `training` plus `dataset` — so the expression is derived from the same * decision the owner already made per bot, never configured twice. * * A class reads `no` only when every bot in it is blocked. Any single * allowed crawler makes a blanket `no` a lie about the site's own settings. * * @return string Expression, or '' when the signal is switched off. */ public function content_signal() { $settings = $this->settings->get(); if ( empty( $settings['headers']['signals'] ) ) { return ''; } // Discouraged sites publish "User-agent: * / Disallow: /" from core. // Adding our own wildcard group with Allow: / would contradict it, and // on equal specificity an allow wins — the signal is not worth // re-opening a site the owner closed. if ( ! get_option( 'blog_public' ) ) { return ''; } $signal = array( 'search=' . ( $this->category_allows( array( 'search' ) ) ? 'yes' : 'no' ), 'ai-input=' . ( $this->category_allows( array( 'assistant' ) ) ? 'yes' : 'no' ), 'ai-train=' . ( $this->category_allows( array( 'training', 'dataset' ) ) ? 'yes' : 'no' ), ); /** * Filter the Content-Signal expression. * * @param string $signal Comma-separated expression. * @param Oorank_Bot_Manager $manager This manager. */ return apply_filters( 'oorank_content_signal', implode( ', ', $signal ), $this ); } /** * Whether any bot in the given categories is allowed. * * @param string[] $categories Category slugs. * @return bool True when a category is empty, since "no such crawler is * known" is not the same statement as "this is refused". */ private function category_allows( array $categories ) { $registry = self::registry(); $states = $this->states(); $found = false; foreach ( $registry as $slug => $entry ) { if ( empty( $entry['category'] ) || ! in_array( $entry['category'], $categories, true ) ) { continue; } $found = true; if ( ! isset( $states[ $slug ] ) || self::BLOCK !== $states[ $slug ] ) { return true; } } return ! $found; } /** * Emit the ai-preferences `Content-Usage` header when training is refused * site-wide. * * Sent to every requester, not only to a bot that identifies itself: the * header states a preference about the resource, so a crawler arriving * under an unknown user agent is exactly who it is for. Nothing is sent * while training is still allowed — a header saying "yes" adds bytes to * every response and says only what the absence of it already says. */ public function maybe_send_usage_header() { if ( headers_sent() ) { return; } $settings = $this->settings->get(); if ( empty( $settings['headers']['signals'] ) ) { return; } if ( $this->category_allows( array( 'training', 'dataset' ) ) ) { return; } header( 'Content-Usage: ai-train=n', false ); } /** * The requesting user agent, sanitized. * * @return string */ public function request_user_agent() { if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) { return ''; } return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ); } /** * Match a user-agent string against the blocked bots' tokens. * * Google-Extended and Applebot-Extended are robots.txt directives rather * than real user agents, so they never match here — by design. * * @param string $user_agent Raw user-agent string. * @return string|null Matching bot slug, or null. */ public function match_blocked_ua( $user_agent ) { if ( '' === $user_agent ) { return null; } $registry = self::registry(); foreach ( $this->blocked() as $slug ) { if ( empty( $registry[ $slug ]['ua'] ) ) { continue; } if ( false !== stripos( $user_agent, $registry[ $slug ]['ua'] ) ) { return $slug; } } return null; } /** * Keep an already-managed physical robots.txt in step with the settings. * * Only touches the file when it already carries our markers — writing into * an unmanaged robots.txt stays an explicit, opt-in action. */ public function refresh_static_robots() { if ( ! $this->has_static_robots() ) { return; } $contents = file_get_contents( $this->robots_file() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local read, no HTTP. if ( ! is_string( $contents ) || false === strpos( $contents, '# BEGIN ' . self::MARKER ) ) { return; } $this->write_static_robots(); } /** * Append our directives to WordPress' virtual robots.txt. * * @param string $output Robots.txt body assembled so far. * @param string $is_public The blog_public option value. * @return string */ public function filter_robots_txt( $output, $is_public ) { unset( $is_public ); return rtrim( (string) $output ) . "\n\n" . $this->directives() . "\n"; } /** * The robots.txt block describing the current bot states. * * Only blocked bots get a User-agent group — an absent group already means * "allowed", so listing allows would only bloat the file. When nothing is * blocked we still emit the comment, so the file visibly reflects the * setting either way. * * @return string Directive block, no trailing newline. */ public function directives() { $registry = self::registry(); $lines = array( '# Oorank — AI bot access' ); $blocked = $this->blocked(); if ( empty( $blocked ) ) { $lines[] = '# All known AI crawlers are allowed.'; } foreach ( $blocked as $slug ) { if ( ! isset( $registry[ $slug ]['ua'] ) ) { continue; } $lines[] = ''; $lines[] = 'User-agent: ' . $registry[ $slug ]['ua']; $lines[] = 'Disallow: /'; } $link = $this->llms_reference(); if ( '' !== $link ) { $lines[] = ''; $lines[] = '# The machine-readable index of this site.'; $lines[] = 'Llms-Txt: ' . $link; } $signal = $this->content_signal(); if ( '' !== $signal ) { $lines[] = ''; $lines[] = '# Content signals for crawlers not named above (Content Signals Policy).'; $lines[] = 'User-agent: *'; $lines[] = 'Content-Signal: ' . $signal; // A group with no rule line is discarded by some parsers, taking // the signal with it. Allow: / is the rule Cloudflare's own // default block uses, and it changes nothing on a public site. $lines[] = 'Allow: /'; } /** * Filter the robots.txt directive block Oorank emits. * * @param string $block The rendered block. * @param Oorank_Bot_Manager $manager This manager. */ return apply_filters( 'oorank_robots_directives', implode( "\n", $lines ), $this ); } /** * The llms.txt URL to advertise in robots.txt, if the owner wants it there. * * `Sitemap:` taught crawlers to look in robots.txt for an index, and * `Llms-Txt:` is the same move for this one. It is not a ratified * directive, which is why it is a plain unknown field that every parser * ignores, and why it can be switched off. * * @return string URL, or '' when disabled. */ private function llms_reference() { $settings = $this->settings->get(); if ( isset( $settings['llms']['robots_link'] ) && ! $settings['llms']['robots_link'] ) { return ''; } return home_url( '/llms.txt' ); } /** * Absolute path of a physical robots.txt in the site root. * * @return string */ public function robots_file() { return oorank_home_path() . 'robots.txt'; } /** * Whether a physical robots.txt exists. When it does the web server serves * it directly and WordPress' virtual robots.txt (and our filter) never * runs — the admin warns and offers to write into the file instead. * * @return bool */ public function has_static_robots() { return file_exists( $this->robots_file() ); } /** * Write (or refresh) our marker-delimited block inside the physical * robots.txt, leaving everything outside the markers untouched. * * @return true|WP_Error */ public function write_static_robots() { require_once ABSPATH . 'wp-admin/includes/misc.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; $file = $this->robots_file(); global $wp_filesystem; if ( ! WP_Filesystem() || ! $wp_filesystem ) { return new WP_Error( 'oorank_fs', __( 'Could not access the filesystem to write robots.txt.', 'oorank' ), array( 'status' => 500 ) ); } // insert_with_markers() needs the file to exist before it will edit it. if ( ! $wp_filesystem->exists( $file ) && ! $wp_filesystem->put_contents( $file, '', FS_CHMOD_FILE ) ) { return new WP_Error( 'oorank_fs', __( 'Could not create robots.txt in the site root.', 'oorank' ), array( 'status' => 500 ) ); } if ( ! $wp_filesystem->is_writable( $file ) ) { return new WP_Error( 'oorank_fs', __( 'robots.txt exists but is not writable.', 'oorank' ), array( 'status' => 500 ) ); } $block = explode( "\n", $this->directives() ); if ( ! insert_with_markers( $file, self::MARKER, $block ) ) { return new WP_Error( 'oorank_fs', __( 'Could not write the Oorank block into robots.txt.', 'oorank' ), array( 'status' => 500 ) ); } return true; } /** * Register the static-robots.txt write route (nonce + cap gated). */ public function register_routes() { register_rest_route( Oorank_Rest::NAMESPACE, '/bots/robots-write', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_write_robots' ), 'permission_callback' => static function () { return Oorank_Roles::can(); }, ) ); } /** * POST /bots/robots-write handler. * * @return WP_REST_Response|WP_Error */ public function rest_write_robots() { $result = $this->write_static_robots(); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( array( 'written' => true, 'file' => $this->robots_file(), ) ); } /** * The registry of AI crawlers free core knows about. * * Keyed by a sanitize_key()-safe slug used in oorank_settings.bots. The * `ua` value is the token written to robots.txt — note Google-Extended and * Applebot-Extended are robots-only directives, not real user agents, so * blocking them opts out of training without touching classic Search. * * @return array */ public static function registry() { $registry = array( 'gptbot' => array( 'name' => 'GPTBot', 'ua' => 'GPTBot', 'operator' => 'OpenAI', 'docs' => 'https://platform.openai.com/docs/bots', 'category' => 'training', ), 'oai-searchbot' => array( 'name' => 'OAI-SearchBot', 'ua' => 'OAI-SearchBot', 'operator' => 'OpenAI', 'docs' => 'https://platform.openai.com/docs/bots', 'category' => 'search', ), 'chatgpt-user' => array( 'name' => 'ChatGPT-User', 'ua' => 'ChatGPT-User', 'operator' => 'OpenAI', 'docs' => 'https://platform.openai.com/docs/bots', 'category' => 'assistant', ), 'claudebot' => array( 'name' => 'ClaudeBot', 'ua' => 'ClaudeBot', 'operator' => 'Anthropic', 'docs' => 'https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler', 'category' => 'training', ), 'claude-searchbot' => array( 'name' => 'Claude-SearchBot', 'ua' => 'Claude-SearchBot', 'operator' => 'Anthropic', 'docs' => 'https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler', 'category' => 'search', ), 'claude-user' => array( 'name' => 'Claude-User', 'ua' => 'Claude-User', 'operator' => 'Anthropic', 'docs' => 'https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler', 'category' => 'assistant', ), 'perplexitybot' => array( 'name' => 'PerplexityBot', 'ua' => 'PerplexityBot', 'operator' => 'Perplexity', 'docs' => 'https://docs.perplexity.ai/guides/bots', 'category' => 'search', ), 'perplexity-user' => array( 'name' => 'Perplexity-User', 'ua' => 'Perplexity-User', 'operator' => 'Perplexity', 'docs' => 'https://docs.perplexity.ai/guides/bots', 'category' => 'assistant', ), 'google-extended' => array( 'name' => 'Google-Extended', 'ua' => 'Google-Extended', 'operator' => 'Google', 'docs' => 'https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers', 'category' => 'training', ), 'ccbot' => array( 'name' => 'CCBot', 'ua' => 'CCBot', 'operator' => 'Common Crawl', 'docs' => 'https://commoncrawl.org/ccbot', 'category' => 'dataset', ), 'bytespider' => array( 'name' => 'Bytespider', 'ua' => 'Bytespider', 'operator' => 'ByteDance', 'docs' => 'https://support.bytedance.com/en/docs/bytespider', 'category' => 'training', ), 'amazonbot' => array( 'name' => 'Amazonbot', 'ua' => 'Amazonbot', 'operator' => 'Amazon', 'docs' => 'https://developer.amazon.com/amazonbot', 'category' => 'training', ), 'applebot-extended' => array( 'name' => 'Applebot-Extended', 'ua' => 'Applebot-Extended', 'operator' => 'Apple', 'docs' => 'https://support.apple.com/en-us/119829', 'category' => 'training', ), 'meta-externalagent' => array( 'name' => 'Meta-ExternalAgent', 'ua' => 'Meta-ExternalAgent', 'operator' => 'Meta', 'docs' => 'https://developers.facebook.com/docs/sharing/webmasters/web-crawlers/', 'category' => 'training', ), 'duckassistbot' => array( 'name' => 'DuckAssistBot', 'ua' => 'DuckAssistBot', 'operator' => 'DuckDuckGo', 'docs' => 'https://duckduckgo.com/duckduckgo-help-pages/results/duckassistbot/', 'category' => 'assistant', ), 'ai2bot' => array( 'name' => 'AI2Bot', 'ua' => 'AI2Bot', 'operator' => 'Allen Institute for AI', 'docs' => 'https://allenai.org/crawler', 'category' => 'dataset', ), 'cohere-ai' => array( 'name' => 'cohere-ai', 'ua' => 'cohere-ai', 'operator' => 'Cohere', 'docs' => 'https://cohere.com/', 'category' => 'training', ), 'imagesiftbot' => array( 'name' => 'ImagesiftBot', 'ua' => 'ImagesiftBot', 'operator' => 'ImageSift', 'docs' => 'https://imagesift.com/about', 'category' => 'dataset', ), 'diffbot' => array( 'name' => 'Diffbot', 'ua' => 'Diffbot', 'operator' => 'Diffbot', 'docs' => 'https://www.diffbot.com/', 'category' => 'dataset', ), ); /** * Filter the AI-bot registry. * * Extension seam: add or amend crawlers without forking free core. * Entries must supply name, ua, operator, docs and category keys. * * @param array $registry Registry keyed by bot slug. */ return apply_filters( 'oorank_bot_registry', $registry ); } /** * Registry slugs, in registry order. * * @return string[] */ public static function slugs() { return array_keys( self::registry() ); } /** * A single registry entry. * * @param string $slug Bot slug. * @return array|null Entry, or null when unknown. */ public static function get_bot( $slug ) { $registry = self::registry(); return isset( $registry[ $slug ] ) ? $registry[ $slug ] : null; } /** * Human-readable label for a category slug. * * @param string $category Category slug. * @return string */ public static function category_label( $category ) { $labels = array( 'training' => __( 'AI training', 'oorank' ), 'search' => __( 'AI search', 'oorank' ), 'assistant' => __( 'On-demand fetch', 'oorank' ), 'dataset' => __( 'Crawl dataset', 'oorank' ), ); return isset( $labels[ $category ] ) ? $labels[ $category ] : $category; } /** * Human-readable label for a preset slug. * * @param string $preset Preset slug. * @return string */ public static function preset_label( $preset ) { $labels = array( 'allow_all' => __( 'Allow all', 'oorank' ), 'block_all' => __( 'Block all', 'oorank' ), 'custom' => __( 'Custom', 'oorank' ), ); return isset( $labels[ $preset ] ) ? $labels[ $preset ] : $preset; } /** * Current allow/block state for every registered bot. * * Unknown or missing values fall back to allow — free core never blocks a * crawler the owner did not choose to block. * * @return array slug => 'allow'|'block'. */ public function states() { $settings = $this->settings->get(); $stored = isset( $settings['bots'] ) && is_array( $settings['bots'] ) ? $settings['bots'] : array(); $states = array(); foreach ( self::slugs() as $slug ) { $state = isset( $stored[ $slug ] ) ? $stored[ $slug ] : self::ALLOW; $states[ $slug ] = ( self::BLOCK === $state ) ? self::BLOCK : self::ALLOW; } return $states; } /** * Whether a bot is blocked. * * @param string $slug Bot slug. * @return bool */ public function is_blocked( $slug ) { $states = $this->states(); return isset( $states[ $slug ] ) && self::BLOCK === $states[ $slug ]; } /** * Slugs of every blocked bot. * * @return string[] */ public function blocked() { return array_keys( array_filter( $this->states(), array( $this, 'is_block_state' ) ) ); } /** * Slugs of every allowed bot. * * @return string[] */ public function allowed() { $states = $this->states(); return array_keys( array_diff( $states, array( self::BLOCK ) ) ); } /** * Which preset the current state matches: allow_all, block_all or custom. * * @return string */ public function active_preset() { $states = array_values( $this->states() ); $unique = array_unique( $states ); $is_only = ( 1 === count( $unique ) ) ? reset( $unique ) : ''; if ( self::ALLOW === $is_only ) { return 'allow_all'; } if ( self::BLOCK === $is_only ) { return 'block_all'; } return 'custom'; } /** * Callback for array_filter() — true for the block state. * * @param string $state Stored state. * @return bool */ private function is_block_state( $state ) { return self::BLOCK === $state; } }