settings = $settings; $this->types = $types; $this->resolver = $resolver; } /** * Registers hooks. */ public function register() { add_filter( 'robots_txt', array( $this, 'robots_txt' ), 20, 2 ); add_action( 'init', array( $this, 'serve_llms' ), 2 ); if ( $this->staging_guard_active() ) { add_filter( 'wp_robots', array( $this, 'staging_robots' ), 99 ); add_action( 'send_headers', array( $this, 'staging_header' ) ); } } /** * Known AI crawlers: user agent => description. * * @return array */ public static function ai_bots() { return array( 'GPTBot' => __( 'OpenAI: collects pages to train ChatGPT models', 'orbitstem-seo' ), 'OAI-SearchBot' => __( 'OpenAI: ChatGPT search (blocking it can keep you out of ChatGPT search answers)', 'orbitstem-seo' ), 'ChatGPT-User' => __( 'OpenAI: fetches a page when a ChatGPT user asks for it', 'orbitstem-seo' ), 'ClaudeBot' => __( 'Anthropic: collects pages to train Claude models', 'orbitstem-seo' ), 'Claude-User' => __( 'Anthropic: fetches a page when a Claude user asks for it', 'orbitstem-seo' ), 'Google-Extended' => __( 'Google: use of your pages for Gemini (does not affect Google Search)', 'orbitstem-seo' ), 'Applebot-Extended' => __( 'Apple: use of your pages for Apple Intelligence (does not affect Apple search)', 'orbitstem-seo' ), 'PerplexityBot' => __( 'Perplexity: AI answer engine', 'orbitstem-seo' ), 'CCBot' => __( 'Common Crawl: public dataset used to train many AI models', 'orbitstem-seo' ), 'Bytespider' => __( 'ByteDance: AI training', 'orbitstem-seo' ), 'meta-externalagent' => __( 'Meta: AI training', 'orbitstem-seo' ), 'Amazonbot' => __( 'Amazon: Alexa and AI answers', 'orbitstem-seo' ), ); } /** * Whether a robots.txt file in the site folder hides these settings. * * @return bool */ public static function physical_file() { return file_exists( ABSPATH . 'robots.txt' ); } /** * Adds Orbitstem's lines to the virtual robots.txt. * * @param string $output Robots.txt so far. * @param string $is_public "1" when search engines may index the site. * @return string */ public function robots_txt( $output, $is_public ) { $output = rtrim( (string) $output ) . "\n"; if ( $this->staging_guard_active() ) { return $output . "\n# Orbitstem SEO: this looks like a staging or development copy, so crawlers are asked to stay out.\nUser-agent: *\nDisallow: /\n"; } $extra = trim( (string) $this->settings->get( 'robots_extra' ) ); if ( '' !== $extra ) { // Rules written without a "User-agent:" line are for every crawler, as the settings page explains. $first = strtolower( (string) strtok( preg_replace( '/^(#[^\n]*\n)+/', '', $extra . "\n" ), "\n" ) ); if ( 0 !== strpos( $first, 'user-agent:' ) ) { $extra = "User-agent: *\n" . $extra; } $output .= "\n# Orbitstem SEO: extra rules from Settings\n" . $extra . "\n"; } $blocked = (array) $this->settings->get( 'ai_bots_block' ); if ( $blocked ) { $output .= "\n# Orbitstem SEO: AI crawlers blocked in Settings\n"; foreach ( $blocked as $agent ) { $output .= "User-agent: {$agent}\nDisallow: /\n\n"; } } if ( '0' !== (string) $is_public ) { foreach ( \Orbitstem\SEO\Sitemap\ExtraSitemaps::urls( $this->settings ) as $url ) { $output .= 'Sitemap: ' . $url . "\n"; } } return $output; } /** * Serves /llms.txt: a short Markdown guide to the site for AI tools. */ public function serve_llms() { if ( ! $this->settings->get( 'llms_txt' ) || empty( $_SERVER['REQUEST_URI'] ) || file_exists( ABSPATH . 'llms.txt' ) ) { return; } $path = (string) wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ); $want = (string) wp_parse_url( home_url( '/llms.txt' ), PHP_URL_PATH ); if ( $want !== $path ) { return; } status_header( 200 ); header( 'Content-Type: text/plain; charset=utf-8' ); header( 'X-Robots-Tag: noindex' ); echo $this->llms_txt(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- plain text built from cleaned titles and URLs. exit; } /** * The llms.txt text: site name, summary, then key pages and recent posts. * * @return string */ public function llms_txt() { $clean = function ( $text ) { return trim( preg_replace( '/[\r\n\[\]]+/', ' ', TemplateEngine::plain( (string) $text ) ) ); }; $out = '# ' . $clean( get_bloginfo( 'name' ) ) . "\n\n"; $tagline = $clean( get_bloginfo( 'description' ) ); if ( '' !== $tagline ) { $out .= '> ' . $tagline . "\n\n"; } $groups = array( __( 'Pages', 'orbitstem-seo' ) => array( 'post_type' => 'page', 'orderby' => 'menu_order title', 'order' => 'ASC', 'posts_per_page' => 30, ), __( 'Recent posts', 'orbitstem-seo' ) => array( 'post_type' => 'post', 'posts_per_page' => 30, ), ); foreach ( $groups as $heading => $query ) { if ( ! $this->types->supports_post_type( $query['post_type'] ) ) { continue; } $lines = array(); foreach ( get_posts( $query + array( 'post_status' => 'publish', 'has_password' => false, 'no_found_rows' => true, ) ) as $post ) { $data = $this->resolver->for_post( $post ); if ( ! $data || $data['noindex'] || in_array( (int) $post->ID, \Orbitstem\SEO\Schema\Woo::private_page_ids(), true ) ) { continue; // Hidden pages, and the shop's cart, checkout and account pages. } // A canonical on another site says this copy is not the one to read. $canonical = wp_parse_url( (string) $data['canonical'] ); if ( is_array( $canonical ) && ! empty( $canonical['host'] ) && ! \Orbitstem\SEO\Links\Url::same_site( $canonical ) ) { continue; } $line = '- [' . $clean( $post->post_title ) . '](' . esc_url_raw( $data['url'] ) . ')'; if ( '' !== $data['description'] ) { $line .= ': ' . $clean( $data['description'] ); } $lines[] = $line; } if ( $lines ) { $out .= '## ' . $heading . "\n\n" . implode( "\n", $lines ) . "\n\n"; } } return rtrim( $out ) . "\n"; } /** * Whether this site is certainly not a live site: WordPress was told so * (WP_ENVIRONMENT_TYPE is staging, development or local), or the address * cannot be reached from the internet (localhost, .test, .local and the * other reserved names). Other addresses are never guessed from: real sites * use names like dev.to, test.com or an IP address. * * @return bool */ public static function looks_like_staging() { // No translated text here: this runs on plugins_loaded, before translations may load. // The reason shown on screens is built later, by staging_reason(). return '' !== self::staging_kind(); } /** * What makes this site a non-live copy: 'env', 'host' or ''. * * @return string */ private static function staging_kind() { if ( in_array( wp_get_environment_type(), array( 'staging', 'development', 'local' ), true ) ) { return 'env'; } $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); if ( 'localhost' === $host || preg_match( '/\.(local|test|localhost|invalid|example)$/', $host ) ) { return 'host'; } return ''; } /** * Why this site counts as a non-live copy ('' when it does not), for screens. * Meant for screens, so register() never calls it. * * @return string */ public static function staging_reason() { // Translations may only load from init on; code that asks earlier gets plain English. $translate = did_action( 'init' ) > 0; switch ( self::staging_kind() ) { case 'env': /* translators: %s: environment type such as "staging" */ return sprintf( $translate ? __( 'WordPress is set up as a "%s" site (WP_ENVIRONMENT_TYPE in wp-config.php).', 'orbitstem-seo' ) : 'WordPress is set up as a "%s" site (WP_ENVIRONMENT_TYPE in wp-config.php).', wp_get_environment_type() ); case 'host': /* translators: %s: host name */ return sprintf( $translate ? __( 'The address %s cannot be reached from the internet.', 'orbitstem-seo' ) : 'The address %s cannot be reached from the internet.', strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ); } return ''; } /** * A staging-like address that is not certain enough to act on by itself: * a staging word as the first part of a subdomain of another domain, such * as staging.example.com (but not dev.to or stage-door.co.uk). Only used to * suggest setting WP_ENVIRONMENT_TYPE. * * @return string Host name, or '' when it does not look like staging. */ public static function staging_like_address() { $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $labels = explode( '.', $host ); if ( count( $labels ) < 3 || filter_var( $host, FILTER_VALIDATE_IP ) ) { return ''; } if ( ! preg_match( '/^(staging|stage|stg|dev|develop|development|test|testing|qa|uat|preview|sandbox)\d*$/', $labels[0] ) ) { return ''; } // "dev.co.uk": what follows is only a public suffix such as co.uk, so dev.co.uk is itself the main domain. if ( 3 === count( $labels ) && strlen( $labels[1] ) <= 3 && 2 === strlen( $labels[2] ) ) { return ''; } return $host; } /** * Whether staging protection is on and applies here. * * @param Settings $settings Settings. * @return bool */ public static function guard_applies( Settings $settings ) { /** * Filters whether staging protection applies. Return true to protect a * staging copy WordPress does not know about, or false to switch it off. * * @param bool $active Whether it applies. */ return (bool) apply_filters( 'orbitstem_seo_staging_guard', $settings->get( 'staging_guard' ) && self::looks_like_staging() ); } /** * Whether staging protection is on and applies here. * * @return bool */ public function staging_guard_active() { return self::guard_applies( $this->settings ); } /** * Adds noindex, nofollow to every page of a staging copy. * * @param array $robots Robots directives. * @return array */ public function staging_robots( array $robots ) { $robots['noindex'] = true; $robots['nofollow'] = true; unset( $robots['index'], $robots['follow'], $robots['max-image-preview'] ); return $robots; } /** * Also tells crawlers in the HTTP header (covers files and feeds). */ public function staging_header() { if ( ! headers_sent() ) { header( 'X-Robots-Tag: noindex, nofollow', false ); } } }