> Keyed by the user agent token. */ function citolume_known_bots() { return array( 'OAI-SearchBot' => array( 'label' => 'OAI-SearchBot', 'company' => 'OpenAI', 'purpose' => 'search', ), 'ChatGPT-User' => array( 'label' => 'ChatGPT-User', 'company' => 'OpenAI', 'purpose' => 'user', ), 'GPTBot' => array( 'label' => 'GPTBot', 'company' => 'OpenAI', 'purpose' => 'training', ), 'Claude-SearchBot' => array( 'label' => 'Claude-SearchBot', 'company' => 'Anthropic', 'purpose' => 'search', ), 'Claude-User' => array( 'label' => 'Claude-User', 'company' => 'Anthropic', 'purpose' => 'user', ), 'ClaudeBot' => array( 'label' => 'ClaudeBot', 'company' => 'Anthropic', 'purpose' => 'training', ), 'anthropic-ai' => array( 'label' => 'anthropic-ai', 'company' => 'Anthropic', 'purpose' => 'training', ), 'Perplexity-User' => array( 'label' => 'Perplexity-User', 'company' => 'Perplexity', 'purpose' => 'user', ), 'PerplexityBot' => array( 'label' => 'PerplexityBot', 'company' => 'Perplexity', 'purpose' => 'search', ), 'DuckAssistBot' => array( 'label' => 'DuckAssistBot', 'company' => 'DuckDuckGo', 'purpose' => 'search', ), 'MistralAI-User' => array( 'label' => 'MistralAI-User', 'company' => 'Mistral', 'purpose' => 'user', ), 'meta-externalagent' => array( 'label' => 'meta-externalagent', 'company' => 'Meta', 'purpose' => 'training', ), 'Bytespider' => array( 'label' => 'Bytespider', 'company' => 'ByteDance', 'purpose' => 'training', ), 'Amazonbot' => array( 'label' => 'Amazonbot', 'company' => 'Amazon', 'purpose' => 'training', ), 'cohere-ai' => array( 'label' => 'cohere-ai', 'company' => 'Cohere', 'purpose' => 'training', ), 'YouBot' => array( 'label' => 'YouBot', 'company' => 'You.com', 'purpose' => 'search', ), 'Diffbot' => array( 'label' => 'Diffbot', 'company' => 'Diffbot', 'purpose' => 'training', ), 'CCBot' => array( 'label' => 'CCBot', 'company' => 'Common Crawl', 'purpose' => 'training', ), ); } /** * What each crawl purpose means, in plain words. * * @return array */ function citolume_purpose_labels() { return array( 'search' => __( 'Builds the index an AI answers from', 'citolume' ), 'user' => __( 'Fetched live because someone asked about your page', 'citolume' ), 'training' => __( 'Collects content to train models', 'citolume' ), ); } /** * Identifies the AI crawler behind a user agent string. * * @param string $user_agent Raw user agent. * @return string The bot token, or an empty string when it is not one we track. */ function citolume_identify_bot( $user_agent ) { if ( '' === $user_agent ) { return ''; } foreach ( citolume_known_bots() as $token => $bot ) { if ( false !== stripos( $user_agent, $token ) ) { return $token; } } return ''; } add_action( 'template_redirect', 'citolume_maybe_record_hit', 1 ); /** * Records a front end request made by a recognised AI crawler. * * Nothing is stored for ordinary visitors: only requests whose user agent * matches a known AI crawler are recorded, and only the crawler name, the * requested path and the date. No IP addresses, and nothing that identifies a * person. * * @return void */ function citolume_maybe_record_hit() { if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || is_preview() ) { return; } // A 404 tells us nothing useful about what a crawler is reading. if ( is_404() ) { return; } $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $bot = citolume_identify_bot( $user_agent ); if ( '' === $bot ) { return; } citolume_record_hit( $bot, citolume_current_path() ); } /** * What the current request should be recorded as. * * Prefers the canonical permalink of whatever was queried, so the same page * counts once however it was reached, and so sites on plain permalinks record * something more useful than a row of identical slashes. * * @return string A site relative path, including a query string when the site * uses plain permalinks. */ function citolume_current_path() { $canonical = ''; if ( is_singular() ) { $permalink = get_permalink( get_queried_object_id() ); $canonical = is_string( $permalink ) ? $permalink : ''; } elseif ( is_front_page() || is_home() ) { $canonical = home_url( '/' ); } if ( '' !== $canonical ) { return citolume_shorten_path( wp_make_link_relative( $canonical ) ); } // Archives, search and everything else: fall back to what was asked for. $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; return citolume_shorten_path( $uri ); } /** * Normalises a recorded path and keeps it inside the column. * * @param string $path Site relative path. * @return string */ function citolume_shorten_path( $path ) { $path = (string) $path; if ( '' === $path ) { $path = '/'; } if ( '/' !== $path[0] ) { $path = '/' . $path; } return substr( $path, 0, 255 ); } /** * Adds one hit to today's tally for this crawler and path. * * @param string $bot Bot token. * @param string $path Requested path. * @return void */ function citolume_record_hit( $bot, $path ) { global $wpdb; // One statement, so concurrent crawler requests cannot race each other into // duplicate rows or a lost increment. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table, and no cache to invalidate. $wpdb->prepare( "INSERT INTO {$wpdb->prefix}citolume_crawler_hits (bot, url, url_hash, hit_date, hits) VALUES (%s, %s, %s, %s, 1) ON DUPLICATE KEY UPDATE hits = hits + 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name built from $wpdb->prefix. $bot, $path, md5( $path ), current_time( 'Y-m-d' ) ) ); } /** * Totals per crawler over a window. * * @param int $days How many days back to count. * @return array Rows of bot and hits, busiest first. */ function citolume_get_bot_totals( $days = 7 ) { global $wpdb; return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- reporting screen, read on demand. $wpdb->prepare( "SELECT bot, SUM(hits) AS hits FROM {$wpdb->prefix}citolume_crawler_hits WHERE hit_date >= %s GROUP BY bot ORDER BY hits DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name built from $wpdb->prefix. citolume_window_start( $days ) ) ); } /** * The most crawled paths over a window. * * @param int $days How many days back to count. * @param int $limit Maximum rows. * @return array Rows of url and hits, busiest first. */ function citolume_get_top_paths( $days = 7, $limit = 10 ) { global $wpdb; return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- reporting screen, read on demand. $wpdb->prepare( "SELECT url, SUM(hits) AS hits FROM {$wpdb->prefix}citolume_crawler_hits WHERE hit_date >= %s GROUP BY url_hash, url ORDER BY hits DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name built from $wpdb->prefix. citolume_window_start( $days ), $limit ) ); } /** * Daily totals over a window, for the trend chart. * * @param int $days How many days back to count. * @return array Hits keyed by Y-m-d, one entry per day including quiet ones. */ function citolume_get_daily_totals( $days = 30 ) { global $wpdb; $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- reporting screen, read on demand. $wpdb->prepare( "SELECT hit_date, SUM(hits) AS hits FROM {$wpdb->prefix}citolume_crawler_hits WHERE hit_date >= %s GROUP BY hit_date", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name built from $wpdb->prefix. citolume_window_start( $days ) ) ); $found = array(); foreach ( $rows as $row ) { $found[ $row->hit_date ] = (int) $row->hits; } // Fill the gaps so the chart has a bar for every day rather than skipping // the quiet ones and distorting the shape. $totals = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $day = gmdate( 'Y-m-d', citolume_now() - ( $i * DAY_IN_SECONDS ) ); $totals[ $day ] = isset( $found[ $day ] ) ? $found[ $day ] : 0; } return $totals; } /** * Whether anything has ever been recorded. * * @return bool */ function citolume_has_any_hits() { global $wpdb; return (bool) $wpdb->get_var( "SELECT 1 FROM {$wpdb->prefix}citolume_crawler_hits LIMIT 1" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name built from $wpdb->prefix. } add_action( 'citolume_ping', 'citolume_report_crawler_hits', 20 ); /** * Reports finished days of the local log to the API, once a day. * * Rides the existing daily ping rather than its own cron. The local table * stays authoritative and every screen keeps reading it; this exists so the * platform can show the same data (ticket 032). Enrichment, never a * dependency: no key or a dead API changes nothing here, and the untouched * watermark means tomorrow simply retries the same days. * * Only days that are over are sent — today is still counting — and a * re-send replaces the day's counts on the API, so overlap cannot * double-count. * * @return void */ function citolume_report_crawler_hits() { if ( '' === citolume_license_key() ) { return; } global $wpdb; $through = (string) get_option( 'citolume_hits_reported_through', '' ); // Never reach past what the API retains, or one long-offline stretch // would poison every batch with rejected dates. $oldest = citolume_window_start( 90 ); $since = max( $through, $oldest ); $today = current_time( 'Y-m-d' ); $limit = 1000; $rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table, read once a day. $wpdb->prepare( "SELECT bot, url, hit_date, hits FROM {$wpdb->prefix}citolume_crawler_hits WHERE hit_date > %s AND hit_date < %s ORDER BY hit_date LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name built from $wpdb->prefix. $since, $today, $limit ) ); if ( ! $rows ) { return; } $hits = array(); foreach ( $rows as $row ) { $hits[] = array( 'crawler' => (string) $row->bot, 'path' => (string) $row->url, 'date' => (string) $row->hit_date, 'hits' => (int) $row->hits, ); } $response = citolume_api_send( '/v1/crawler-hits', 'POST', array( 'hits' => $hits ) ); if ( is_wp_error( $response ) ) { return; } $last = (string) end( $rows )->hit_date; $first = (string) $rows[0]->hit_date; // A full batch may have cut its newest day in half, so step the // watermark back to re-send that day complete tomorrow (a re-send // replaces, never adds). Unless the whole batch IS one day — then // accept the truncated day, or the watermark would never advance. if ( count( $rows ) === $limit && $last > $first ) { $last = gmdate( 'Y-m-d', (int) strtotime( $last ) - DAY_IN_SECONDS ); } if ( $last > $through ) { update_option( 'citolume_hits_reported_through', $last, false ); } } /** * The first date included in a window. * * @param int $days Window length. * @return string Y-m-d */ function citolume_window_start( $days ) { $days = max( 1, (int) $days ); return gmdate( 'Y-m-d', citolume_now() - ( ( $days - 1 ) * DAY_IN_SECONDS ) ); } /** * Now, in the site's timezone, as a timestamp. * * Hits are dated with current_time(), so the windows have to be measured the * same way or the newest day drops out near midnight. * * @return int */ function citolume_now() { return (int) current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- deliberately matching how hits are dated. }