bot family mapping for classification. * * @var array */ private const BOT_PATTERNS = array( // OpenAI. 'GPTBot' => 'openai', 'ChatGPT-User' => 'openai', 'OAI-SearchBot' => 'openai', // Anthropic. 'ClaudeBot' => 'anthropic', 'Claude-Web' => 'anthropic', 'Claude-User' => 'anthropic', 'Claude-SearchBot' => 'anthropic', 'anthropic-ai' => 'anthropic', // Perplexity. 'PerplexityBot' => 'perplexity', 'Perplexity-User' => 'perplexity', // Google. Keep more specific patterns before Googlebot. 'Google-Extended' => 'google', 'Google-CloudVertexBot' => 'google', 'GoogleOther' => 'google', 'Googlebot' => 'google', // Microsoft / Bing. 'Bingbot' => 'microsoft', // Apple. 'Applebot-Extended' => 'apple', 'Applebot' => 'apple', // Amazon. 'Amazonbot' => 'amazon', // Meta. 'Meta-ExternalAgent' => 'meta', 'Meta-ExternalFetcher' => 'meta', 'FacebookBot' => 'meta', // ByteDance. 'Bytespider' => 'bytedance', // Common Crawl. 'CCBot' => 'commoncrawl', // DuckDuckGo. 'DuckAssistBot' => 'duckduckgo', // Cohere. 'cohere-ai' => 'cohere', // Others. 'YouBot' => 'you', 'Diffbot' => 'diffbot', 'PetalBot' => 'petal', 'Timpibot' => 'timpi', 'ImagesiftBot' => 'imagesift', 'AI2Bot' => 'ai2', ); /** * WordPress database abstraction. * * @var \wpdb */ private \wpdb $wpdb; /** * Constructor. * * @param \wpdb $wpdb WordPress database instance. */ public function __construct( \wpdb $wpdb ) { $this->wpdb = $wpdb; } /** * Detect if the current request is from a known AI bot. * * Uses in-memory string matching only — no DB reads. * * @param string $user_agent The User-Agent header value. * * @return array{is_bot: bool, bot_name: string, bot_family: string}|null */ public function detect_bot( string $user_agent ): ?array { if ( empty( $user_agent ) ) { return null; } foreach ( self::BOT_PATTERNS as $pattern => $family ) { if ( stripos( $user_agent, $pattern ) !== false ) { return array( 'is_bot' => true, 'bot_name' => $pattern, 'bot_family' => $family, ); } } return null; } /** * Handle the current request — detect bot and schedule async logging. * * This is the hot path method, called on every front-end request. * Must complete in <2ms. * * @return void */ public function handle_request(): void { $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $detection = $this->detect_bot( $user_agent ); if ( null === $detection ) { return; } /** * Fires when an AI bot is detected on the front end. * * Runs before blocked-bot enforcement so listeners always see every visit. * * @param array{is_bot: bool, bot_name: string, bot_family: string} $detection Detection result. * @param string $user_agent The raw User-Agent header. */ do_action( 'aicommerce/crawler/bot_detected', $detection, $user_agent ); // Check if this bot is blocked. if ( $this->is_bot_blocked( $detection['bot_family'] ) ) { $this->send_forbidden(); return; } // Schedule async logging — no DB write in the hot path. $this->schedule_log( $detection, $user_agent ); } /** * Schedule an async log write via wp_schedule_single_event. * * @param array $detection Bot detection result. * @param string $user_agent Full user agent string. * * @return void */ private function schedule_log( array $detection, string $user_agent ): void { $log_data = array( 'bot_name' => $detection['bot_name'], 'bot_family' => $detection['bot_family'], 'user_agent' => mb_substr( $user_agent, 0, 500 ), 'ip_address' => $this->anonymize_ip( $this->get_client_ip() ), 'request_url' => $this->get_request_url(), 'page_type' => $this->detect_page_type(), 'request_time' => current_time( 'mysql', true ), ); wp_schedule_single_event( time(), 'aicommerce_log_crawler_visit', array( $log_data ) ); } /** * Write a crawler visit to the database. * * Called asynchronously via Action Scheduler / WP Cron. * * @param array $log_data The log data to write. * * @return void */ public function log_visit( array $log_data ): void { $table = $this->wpdb->prefix . 'aicommerce_crawler_log'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->insert( $table, array( 'bot_name' => $log_data['bot_name'], 'bot_family' => $log_data['bot_family'], 'user_agent' => $log_data['user_agent'], 'ip_address' => $log_data['ip_address'], 'request_url' => $log_data['request_url'], 'page_type' => $log_data['page_type'], 'request_time' => $log_data['request_time'], ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Check if a bot family is blocked. * * Uses a transient cache to avoid DB reads in the hot path. * * @param string $bot_family The bot family identifier. * * @return bool True if the bot is blocked. */ public function is_bot_blocked( string $bot_family ): bool { $blocked = get_transient( 'aicommerce_blocked_bots' ); if ( false === $blocked ) { $blocked = get_option( 'aicommerce_blocked_bots', array() ); set_transient( 'aicommerce_blocked_bots', $blocked, HOUR_IN_SECONDS ); } return is_array( $blocked ) && in_array( $bot_family, $blocked, true ); } /** * Block a bot family. * * @param string $bot_family The bot family to block. * * @return void */ public function block_bot( string $bot_family ): void { $blocked = get_option( 'aicommerce_blocked_bots', array() ); if ( ! in_array( $bot_family, $blocked, true ) ) { $blocked[] = $bot_family; update_option( 'aicommerce_blocked_bots', $blocked, false ); delete_transient( 'aicommerce_blocked_bots' ); } } /** * Unblock a bot family. * * @param string $bot_family The bot family to unblock. * * @return void */ public function unblock_bot( string $bot_family ): void { $blocked = get_option( 'aicommerce_blocked_bots', array() ); $blocked = array_diff( $blocked, array( $bot_family ) ); update_option( 'aicommerce_blocked_bots', array_values( $blocked ), false ); delete_transient( 'aicommerce_blocked_bots' ); } /** * Get crawler visit statistics. * * @param int $days Number of days to look back. * * @return array Statistics data. */ public function get_stats( int $days = 30 ): array { $table = $this->wpdb->prefix . 'aicommerce_crawler_log'; $since = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); // Total visits by bot family. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $by_family = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT bot_family, COUNT(*) as visit_count FROM {$table} WHERE request_time >= %s GROUP BY bot_family ORDER BY visit_count DESC", $since ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Visits by page type. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $by_page_type = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT page_type, COUNT(*) as visit_count FROM {$table} WHERE request_time >= %s GROUP BY page_type ORDER BY visit_count DESC", $since ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Daily visit counts. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $daily = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT DATE(request_time) as visit_date, COUNT(*) as visit_count FROM {$table} WHERE request_time >= %s GROUP BY DATE(request_time) ORDER BY visit_date ASC", $since ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Total count. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $total = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE request_time >= %s", $since ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return array( 'total' => $total, 'by_family' => $by_family ?: array(), 'by_page_type' => $by_page_type ?: array(), 'daily' => $daily ?: array(), 'period_days' => $days, ); } /** * Get recent crawler log entries. * * @param int $limit Number of entries to return. * @param int $offset Offset for pagination. * * @return array Array of log entries. */ public function get_logs( int $limit = 50, int $offset = 0 ): array { $table = $this->wpdb->prefix . 'aicommerce_crawler_log'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $results = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$table} ORDER BY request_time DESC LIMIT %d OFFSET %d", $limit, $offset ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return $results ?: array(); } /** * Get the list of known bot patterns. * * @return array Pattern => family mapping. */ public function get_bot_patterns(): array { return self::BOT_PATTERNS; } /** * Anonymize IP address for GDPR compliance. * * Zeroes the last octet of IPv4 or last 80 bits of IPv6. * * @param string $ip The IP address. * * @return string Anonymized IP. */ private function anonymize_ip( string $ip ): string { if ( empty( $ip ) ) { return ''; } // IPv4: zero last octet. if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return preg_replace( '/\.\d+$/', '.0', $ip ); } // IPv6: zero last 5 groups. if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { $parts = explode( ':', $ip ); $len = count( $parts ); for ( $i = max( 3, $len - 5 ); $i < $len; $i++ ) { $parts[ $i ] = '0'; } return implode( ':', $parts ); } return ''; } /** * Get the client IP address. * * @return string The client IP. */ private function get_client_ip(): string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return $ip; } /** * Get the current request URL. * * @return string The request URL. */ private function get_request_url(): string { $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; return mb_substr( $uri, 0, 2048 ); } /** * Detect the page type of the current request. * * @return string Page type identifier. */ private function detect_page_type(): string { if ( function_exists( 'is_product' ) && is_product() ) { return 'product'; } if ( function_exists( 'is_product_category' ) && is_product_category() ) { return 'category'; } if ( function_exists( 'is_shop' ) && is_shop() ) { return 'shop'; } if ( is_front_page() ) { return 'home'; } if ( is_singular() ) { return 'page'; } if ( is_archive() ) { return 'archive'; } return 'other'; } /** * Send a 403 Forbidden response for blocked bots. * * @return void */ private function send_forbidden(): void { status_header( 403 ); header( 'X-Robots-Tag: noindex, nofollow' ); echo 'Access denied.'; exit; } }