and filters robots.txt. * * Both hooks are server-side, so they work for AI crawlers that * do NOT execute JavaScript (GPTBot, ClaudeBot, PerplexityBot, etc.). */ defined( 'ABSPATH' ) || exit; class ARSCAN_Injector { private static ?ARSCAN_Injector $instance = null; public static function get_instance(): ARSCAN_Injector { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } private function __construct() { add_action( 'wp_head', array( $this, 'inject_jsonld' ), 5 ); add_filter( 'robots_txt', array( $this, 'filter_robots_txt' ), 10, 2 ); } // ----------------------------------------------------------------------- // JSON-LD injection // ----------------------------------------------------------------------- public function inject_jsonld(): void { $s = get_option( ARSCAN_OPTION_KEY, arscan_default_settings() ); if ( empty( $s['inject_jsonld'] ) ) { return; } $schema = $this->build_schema( $s ); if ( empty( $schema ) ) { return; } $json = wp_json_encode( $schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); echo "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode output is safe for inline script content; esc_html would corrupt the JSON echo '' . "\n"; echo "\n\n"; } private function build_schema( array $s ): array { $type = arscan_effective_schema_type(); $schema = array( '@context' => 'https://schema.org', '@type' => $type, ); if ( ! empty( $s['business_name'] ) ) { $schema['name'] = $s['business_name']; } if ( ! empty( $s['business_url'] ) ) { $schema['url'] = $s['business_url']; } if ( ! empty( $s['description'] ) ) { $schema['description'] = $s['description']; } if ( ! empty( $s['phone'] ) ) { $schema['telephone'] = $s['phone']; } if ( ! empty( $s['email'] ) ) { $schema['email'] = $s['email']; } // Address $has_address = ! empty( $s['address'] ) || ! empty( $s['city'] ) || ! empty( $s['state'] ); if ( $has_address ) { $postal_address = array( '@type' => 'PostalAddress' ); if ( ! empty( $s['address'] ) ) $postal_address['streetAddress'] = $s['address']; if ( ! empty( $s['city'] ) ) $postal_address['addressLocality'] = $s['city']; if ( ! empty( $s['state'] ) ) $postal_address['addressRegion'] = $s['state']; if ( ! empty( $s['zip'] ) ) $postal_address['postalCode'] = $s['zip']; if ( ! empty( $s['country'] ) ) $postal_address['addressCountry'] = $s['country']; $schema['address'] = $postal_address; } // Opening hours if ( ! empty( $s['hours'] ) ) { $schema['openingHours'] = $s['hours']; } // Services if ( ! empty( $s['services'] ) ) { $service_list = array_filter( array_map( 'trim', explode( ',', $s['services'] ) ) ); if ( ! empty( $service_list ) ) { // hasOfferCatalog is broadly applicable across schema types $schema['hasOfferCatalog'] = array( '@type' => 'OfferCatalog', 'name' => 'Services', 'itemListElement' => array_values( array_map( function( $svc ) { return array( '@type' => 'Offer', 'itemOffered' => array( '@type' => 'Service', 'name' => $svc, ), ); }, $service_list ) ), ); } } // sameAs — llms.txt and agent-facts.html if they exist $same_as = array(); if ( file_exists( ABSPATH . 'llms.txt' ) ) { $same_as[] = home_url( '/llms.txt' ); } if ( file_exists( ABSPATH . 'agent-facts.html' ) ) { $same_as[] = home_url( '/agent-facts.html' ); } if ( ! empty( $same_as ) ) { $schema['sameAs'] = count( $same_as ) === 1 ? $same_as[0] : $same_as; } return $schema; } // ----------------------------------------------------------------------- // robots.txt filter // ----------------------------------------------------------------------- public function filter_robots_txt( string $output, bool $public ): string { $s = get_option( ARSCAN_OPTION_KEY, arscan_default_settings() ); if ( empty( $s['inject_robots'] ) ) { return $output; } $blocks = array( '', '# AI crawlers explicitly allowed', '# OpenAI', 'User-agent: GPTBot', 'Allow: /', 'User-agent: ChatGPT-User', 'Allow: /', 'User-agent: OAI-SearchBot', 'Allow: /', '', '# Anthropic', 'User-agent: ClaudeBot', 'Allow: /', 'User-agent: anthropic-ai', 'Allow: /', '', '# Perplexity', 'User-agent: PerplexityBot', 'Allow: /', '', '# Google AI training and retrieval', 'User-agent: Google-Extended', 'Allow: /', '', '# Meta', 'User-agent: meta-externalagent', 'Allow: /', '', '# Common Crawl', 'User-agent: CCBot', 'Allow: /', '', '# ByteDance', 'User-agent: Bytespider', 'Allow: /', '', '# Cohere', 'User-agent: cohere-ai', 'Allow: /', '', '# You.com', 'User-agent: YouBot', 'Allow: /', '', '# Apple', 'User-agent: Applebot', 'Allow: /', '', 'Sitemap: ' . home_url( '/sitemap.xml' ), ); if ( file_exists( ABSPATH . 'llms.txt' ) || file_exists( ABSPATH . 'agent-facts.html' ) ) { $blocks[] = ''; $blocks[] = '# AI-facing public files'; if ( file_exists( ABSPATH . 'llms.txt' ) ) { $blocks[] = '# ' . home_url( '/llms.txt' ); } if ( file_exists( ABSPATH . 'agent-facts.html' ) ) { $blocks[] = '# ' . home_url( '/agent-facts.html' ); } } $additions = implode( "\n", $blocks ) . "\n"; return rtrim( $output ) . "\n" . $additions; } }