build_bots_index(); add_filter( 'cron_schedules', array( $this, 'add_cron_schedules' ) ); if ( Rayetun_AG_Modules::is_enabled( 'analytics' ) ) { // Bot intercept on template_redirect — page URL is reliable here. add_action( 'template_redirect', array( $this, 'intercept_bot_visit' ) ); // Schedule cron jobs. if ( ! wp_next_scheduled( 'rayetun_ag_cleanup_old_visits' ) ) { wp_schedule_event( time(), 'daily', 'rayetun_ag_cleanup_old_visits' ); } if ( ! wp_next_scheduled( 'rayetun_ag_check_spikes' ) ) { wp_schedule_event( time(), 'rayetun_ag_six_hourly', 'rayetun_ag_check_spikes' ); } add_action( 'rayetun_ag_cleanup_old_visits', array( $this, 'delete_old_records' ) ); add_action( 'rayetun_ag_check_spikes', array( $this, 'check_traffic_spikes' ) ); } // AJAX — always register. add_action( 'wp_ajax_rayetun_ag_get_analytics', array( $this, 'handle_get_analytics' ) ); add_action( 'wp_ajax_rayetun_ag_export_csv', array( $this, 'handle_export_csv' ) ); add_action( 'wp_ajax_rayetun_ag_live_feed', array( $this, 'handle_live_feed' ) ); } // ------------------------------------------------------------------------- // Cron schedules // ------------------------------------------------------------------------- public function add_cron_schedules( $schedules ) { if ( ! isset( $schedules['rayetun_ag_six_hourly'] ) ) { $schedules['rayetun_ag_six_hourly'] = array( 'interval' => 6 * HOUR_IN_SECONDS, 'display' => __( 'Every 6 Hours (AgentGarrison)', 'agentgarrison' ), ); } return $schedules; } // ------------------------------------------------------------------------- // Bot fingerprinting // ------------------------------------------------------------------------- private function build_bots_index() { $json_file = RAYETUN_AG_DIR . 'includes/data/bots.json'; if ( ! file_exists( $json_file ) ) { return; } $raw = file_get_contents( $json_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $decoded = json_decode( $raw, true ); if ( JSON_ERROR_NONE !== json_last_error() || empty( $decoded['bots'] ) ) { return; } foreach ( $decoded['bots'] as $bot ) { $this->bots_index[ strtolower( $bot['user_agent'] ) ] = $bot; } } private function detect_bot( $user_agent ) { $ua_lower = strtolower( $user_agent ); foreach ( $this->bots_index as $ua_token => $bot ) { if ( false !== strpos( $ua_lower, $ua_token ) ) { return $bot; } } return null; } // ------------------------------------------------------------------------- // Intercept // ------------------------------------------------------------------------- public function intercept_bot_visit() { $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( ! $user_agent ) { return; } global $wp; $request = isset( $wp->request ) ? $wp->request : ''; $page_url = home_url( add_query_arg( array(), $request ) ); $bot = $this->detect_bot( $user_agent ); if ( $bot ) { $this->log_visit( $bot, $page_url, $user_agent ); return; } // Not a known bot — if it still looks like a crawler, record it for the // unknown-crawler heuristic (a possible new AI bot we don't ship yet). if ( $this->is_bot_like( $user_agent ) ) { $this->record_unknown_agent( $user_agent, '/' . ltrim( (string) $request, '/' ) ); } } // ------------------------------------------------------------------------- // Unknown-crawler heuristic // ------------------------------------------------------------------------- /** * Whether a user-agent that matched no known bot still looks automated. * * The token list is chosen to catch crawlers, HTTP libraries, and agent * frameworks while never matching mainstream human browser UA strings (which * contain none of these), so real visitors are never recorded. * * @param string $user_agent Raw UA. * @return bool */ private function is_bot_like( $user_agent ) { $needles = array( 'bot', 'crawl', 'spider', 'slurp', 'scrape', 'fetch', 'wget', 'curl', 'python', 'go-http', 'okhttp', 'java', 'libwww', 'httpclient', 'http-client', 'headless', 'phantom', 'scrapy', 'archiver', 'feedfetcher', 'externalhit', 'externalagent', 'llm', 'gpt', 'claude', 'perplexity', 'anthropic', 'openai', 'monitor', 'uptime', 'semrush', 'ahrefs', 'dataprovider', 'facebookexternal', ); $ua_lower = strtolower( $user_agent ); foreach ( $needles as $needle ) { if ( false !== strpos( $ua_lower, $needle ) ) { return true; } } return false; } /** * Upsert one aggregate row per unknown bot-like UA. A new page is only counted * when the path differs from the last one seen for that UA, so a client * hammering a single URL does not inflate the crawl count. * * @param string $user_agent Raw UA (stored, truncated to 255). * @param string $path Request path. */ private function record_unknown_agent( $user_agent, $path ) { global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::UNKNOWN_AGENTS_TABLE; $ua = substr( $user_agent, 0, 255 ); $path = substr( $path, 0, 255 ); $hash = md5( $ua ); $now = current_time( 'mysql', true ); $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT id, last_path FROM %i WHERE ua_hash = %s', $table, $hash ) ); if ( ! $row ) { $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $table, array( 'ua_hash' => $hash, 'user_agent' => $ua, 'hits' => 1, 'last_path' => $path, 'last_hit_at' => $now, 'first_seen' => $now, 'last_seen' => $now, ), array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' ) ); return; } $increment = ( $path !== $row->last_path ) ? 1 : 0; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'UPDATE %i SET hits = hits + %d, last_path = %s, last_hit_at = %s, last_seen = %s WHERE id = %d', $table, $increment, $path, $now, $now, $row->id ) ); } /** * Unknown crawlers that have crawled at least $min_hits pages, most active first. * * @param int $min_hits Minimum page count to surface. * @param int $limit Max rows. * @return array */ public function get_unknown_crawlers( $min_hits = 5, $limit = 20 ) { global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::UNKNOWN_AGENTS_TABLE; return $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT user_agent, hits, first_seen, last_seen FROM %i WHERE hits >= %d ORDER BY hits DESC, last_seen DESC LIMIT %d', $table, (int) $min_hits, (int) $limit ), ARRAY_A ); } private function log_visit( $bot, $page_url, $user_agent ) { global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::BOT_VISITS_TABLE; $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; // Deduplicate: skip if same bot+URL visited within last 60 minutes. $recent = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT id FROM %i WHERE bot_name = %s AND page_url = %s AND visited_at > %s LIMIT 1', $table, $bot['name'], $page_url, gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) ) ); if ( $recent ) { return; } $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $table, array( 'bot_name' => $bot['name'], 'bot_category' => $bot['category'], 'page_url' => $page_url, 'user_agent' => $user_agent, 'ip_address' => $ip, 'spoofed' => 0, 'visited_at' => current_time( 'mysql', true ), ), array( '%s', '%s', '%s', '%s', '%s', '%d', '%s' ) ); } // ------------------------------------------------------------------------- // Maintenance // ------------------------------------------------------------------------- public function delete_old_records() { global $wpdb; $settings = get_option( 'rayetun_ag_general_settings', array() ); $retention = absint( $settings['retention_days'] ?? 90 ); $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention} days" ) ); $table = $wpdb->prefix . Rayetun_AG_DB::BOT_VISITS_TABLE; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'DELETE FROM %i WHERE visited_at < %s', $table, $cutoff ) ); // Prune unknown-crawler aggregates on the same retention window, then cap // the table so a flood of one-off spoofed UAs cannot grow it unbounded. $unknown = $wpdb->prefix . Rayetun_AG_DB::UNKNOWN_AGENTS_TABLE; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'DELETE FROM %i WHERE last_seen < %s', $unknown, $cutoff ) ); $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'DELETE FROM %i WHERE id NOT IN ( SELECT id FROM ( SELECT id FROM %i ORDER BY hits DESC, last_seen DESC LIMIT 500 ) t )', $unknown, $unknown ) ); } public function check_traffic_spikes() { global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::BOT_VISITS_TABLE; $bots = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT DISTINCT bot_name FROM %i', $table ) ); $settings = get_option( 'rayetun_ag_general_settings', array() ); $email = sanitize_email( $settings['alert_email'] ?? get_option( 'admin_email' ) ); foreach ( $bots as $bot_name ) { $count_24h = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE bot_name = %s AND visited_at > %s', $table, $bot_name, gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) ) ) ); $count_7d_daily_avg = (float) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT COUNT(*) / 7 FROM %i WHERE bot_name = %s AND visited_at > %s', $table, $bot_name, gmdate( 'Y-m-d H:i:s', strtotime( '-7 days' ) ) ) ); if ( $count_7d_daily_avg > 0 && $count_24h > ( $count_7d_daily_avg * 3 ) ) { $subject = sprintf( /* translators: %s: bot name */ __( '[AgentGarrison] Traffic spike detected: %s', 'agentgarrison' ), $bot_name ); $body = sprintf( /* translators: 1: bot name, 2: 24h count, 3: daily average */ __( "AgentGarrison detected a traffic spike from %1\$s.\n\n24h visits: %2\$d\n7-day daily average: %3\$.1f\n\nLog in to your AgentGarrison dashboard to investigate.", 'agentgarrison' ), $bot_name, $count_24h, $count_7d_daily_avg ); wp_mail( $email, $subject, $body ); } } } // ------------------------------------------------------------------------- // AJAX handlers // ------------------------------------------------------------------------- public function handle_get_analytics() { check_ajax_referer( 'rayetun_ag_ajax', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Forbidden', 'agentgarrison' ) ), 403 ); } $days = absint( $_POST['days'] ?? 30 ); $days = in_array( $days, array( 7, 30, 90 ), true ) ? $days : 30; $since = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ); global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::BOT_VISITS_TABLE; $by_bot = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT bot_name, COUNT(*) as visits FROM %i WHERE visited_at > %s GROUP BY bot_name ORDER BY visits DESC LIMIT 20', $table, $since ), ARRAY_A ); $by_day = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT DATE(visited_at) as day, COUNT(*) as visits FROM %i WHERE visited_at > %s GROUP BY DATE(visited_at) ORDER BY day ASC', $table, $since ), ARRAY_A ); $top_pages = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT page_url, COUNT(*) as visits FROM %i WHERE visited_at > %s GROUP BY page_url ORDER BY visits DESC LIMIT 10', $table, $since ), ARRAY_A ); $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE visited_at > %s', $table, $since ) ); wp_send_json_success( array( 'total' => $total, 'by_bot' => $by_bot, 'by_day' => $by_day, 'top_pages' => $top_pages, ) ); } public function handle_export_csv() { check_ajax_referer( 'rayetun_ag_export', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Forbidden', 'agentgarrison' ), 403 ); } global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::BOT_VISITS_TABLE; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT bot_name, bot_category, page_url, ip_address, spoofed, visited_at FROM %i ORDER BY visited_at DESC LIMIT 5000', $table ), ARRAY_A ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="agentgarrison-bot-visits.csv"' ); $output = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen fputcsv( $output, array( 'Bot Name', 'Category', 'Page URL', 'IP Address', 'Spoofed', 'Visited At' ) ); foreach ( $rows as $row ) { fputcsv( $output, array_map( array( 'Rayetun_AG_DB', 'csv_safe_cell' ), array_values( $row ) ) ); } fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } // ------------------------------------------------------------------------- // Public helpers // ------------------------------------------------------------------------- public function get_recent_visits( $limit = 50 ) { global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::BOT_VISITS_TABLE; return $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT * FROM %i ORDER BY visited_at DESC LIMIT %d', $table, $limit ), ARRAY_A ); } // ------------------------------------------------------------------------- // Real-time feed (AJAX polling) // ------------------------------------------------------------------------- /** * Return bot visits newer than the supplied last-seen id. The dashboard polls * this every ~15s (only while the tab is visible) to surface live bot activity. */ public function handle_live_feed() { check_ajax_referer( 'rayetun_ag_ajax', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Forbidden', 'agentgarrison' ) ), 403 ); } global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::BOT_VISITS_TABLE; $last_id = absint( $_POST['last_id'] ?? 0 ); if ( $last_id > 0 ) { // Only rows newer than what the client already has. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT id, bot_name, bot_category, page_url, visited_at FROM %i WHERE id > %d ORDER BY id DESC LIMIT 20', $table, $last_id ), ARRAY_A ); } else { // First load — seed the feed with the latest rows. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT id, bot_name, bot_category, page_url, visited_at FROM %i ORDER BY id DESC LIMIT 12', $table ), ARRAY_A ); } $items = array(); $max_id = $last_id; foreach ( $rows as $row ) { $id = (int) $row['id']; $max_id = max( $max_id, $id ); $items[] = array( 'id' => $id, 'bot_name' => $row['bot_name'], 'category' => $row['bot_category'], 'page_path' => wp_make_link_relative( $row['page_url'] ), 'ago' => human_time_diff( strtotime( $row['visited_at'] ), time() ), ); } wp_send_json_success( array( 'items' => $items, 'last_id' => $max_id, ) ); } public function get_last_seen_by_bot() { global $wpdb; $table = $wpdb->prefix . Rayetun_AG_DB::BOT_VISITS_TABLE; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( 'SELECT bot_name, MAX(visited_at) as last_seen FROM %i GROUP BY bot_name', $table ), ARRAY_A ); $indexed = array(); foreach ( $rows as $row ) { $indexed[ $row['bot_name'] ] = $row['last_seen']; } return $indexed; } }