'gptbot', '/ChatGPT-User/i' => 'chatgpt-user', '/OAI-SearchBot/i' => 'oai-searchbot', '/Google-Extended/i' => 'google-extended', '/PerplexityBot/i' => 'perplexitybot', '/Perplexity-User/i' => 'perplexity-user', // 2.53 — user-triggered fetchers and AI search crawlers that were // never forwarded: their reads mean "used to answer someone". '/Claude-User/i' => 'claude-user', '/Claude-SearchBot/i' => 'claude-searchbot', '/MistralAI-User/i' => 'mistralai-user', '/DuckAssistBot/i' => 'duckassistbot', '/ClaudeBot|anthropic-ai/i' => 'claudebot', '/Claude-Web/i' => 'claude-web', '/Applebot-Extended/i' => 'applebot-extended', '/Bytespider/i' => 'bytespider', '/CCBot/i' => 'ccbot', '/Diffbot/i' => 'diffbot', '/Amazonbot/i' => 'amazonbot', '/FacebookBot|Meta-ExternalAgent/i' => 'facebookbot', '/YouBot/i' => 'youbot', '/cohere-ai/i' => 'cohere-ai', ); foreach ($patterns as $re => $name) { if (preg_match($re, $ua)) { return $name; } } return null; } /** * v2.3.0 — HUMAN-REFERRAL. Detect clicks coming FROM an AI assistant: * a real person asked ChatGPT/Perplexity/Claude, got this site cited, * and clicked through. This is the ROI-proof signal (crawler hits tell * you the AI is *reading* the site; referral hits tell you it's * *sending customers*). Heuristic only — Citami re-verifies the * referrer server-side (trust-but-verify, same as bot UAs). * Returns the platform slug or null. */ function citami_detect_ai_referrer($referrer) { if (!is_string($referrer) || $referrer === '') { return null; } $host = wp_parse_url($referrer, PHP_URL_HOST); if (!is_string($host) || $host === '') { return null; } $patterns = array( '/(^|\.)chatgpt\.com$/i' => 'chatgpt', '/(^|\.)chat\.openai\.com$/i' => 'chatgpt', '/(^|\.)openai\.com$/i' => 'chatgpt', '/(^|\.)perplexity\.ai$/i' => 'perplexity', '/(^|\.)claude\.ai$/i' => 'claude', '/(^|\.)gemini\.google\.com$/i' => 'gemini', '/(^|\.)bard\.google\.com$/i' => 'gemini', '/(^|\.)copilot\.microsoft\.com$/i' => 'copilot', '/(^|\.)you\.com$/i' => 'you', ); foreach ($patterns as $re => $name) { if (preg_match($re, $host)) { return $name; } } return null; } /** * Determine the high-level page type at request time. Used by Citami to * group bot traffic by intent (homepage vs single post vs search vs 404) * without having to re-derive from path patterns server-side. * * Output domain (pinned, mirrored in /api/bot-hits route validator): * home | post | page | category | tag | archive | search | 404 | * singular | other * * 'singular' covers custom post types (Product, Recipe, etc.); 'other' * is the catch-all for anything we don't recognize so the server side * never sees an unbounded string. */ function citami_detect_page_type() { if (is_404()) return '404'; if (is_search()) return 'search'; if (is_front_page() || is_home()) return 'home'; if (is_category()) return 'category'; if (is_tag()) return 'tag'; if (is_singular('post')) return 'post'; if (is_singular('page')) return 'page'; if (is_singular()) return 'singular'; if (is_archive()) return 'archive'; return 'other'; } /** * Bot tracking — runs on `template_redirect` (after WP routing, before * render) so is_404() and the other conditional tags are decided. * * v2.0.x ran on `init` and hardcoded statusCode=200 — every 404 served * to GPTBot was reported as a successful fetch. v2.1.0 captures the * real status (200 / 404) plus the page type for downstream analysis. */ /** * 2.64.1 — the hit is recorded at the very end of template_redirect, and * redirects are recorded with their own status. Before, it ran at the * default priority: a request that WordPress or an SEO plugin then * redirected (Rank Math sends /sitemap.xml to /sitemap_index.xml) was * logged as a 404, and the platform showed crawler errors that the crawler * never got. * * @param int $redirect_status 3xx status when called from wp_redirect, 0 otherwise. */ function citami_bot_track_request($redirect_status = 0) { // Skip admin, cron, REST, AJAX — we only care about public-facing // pages that a crawler would hit. if (is_admin() || wp_doing_cron() || wp_doing_ajax()) return; if (defined('REST_REQUEST') && REST_REQUEST) return; $ua = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; $bot_name = citami_detect_ai_bot($ua); // v2.3.0 — HUMAN-REFERRAL: se l'UA non è un bot, controlla se il // visitatore arriva cliccando da un assistente AI (referrer). $referrer = isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; $ai_platform = ($bot_name === null) ? citami_detect_ai_referrer($referrer) : null; if ($bot_name === null && $ai_platform === null) return; $uri = isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '/'; $path = wp_parse_url($uri, PHP_URL_PATH); if (!is_string($path) || $path === '') $path = '/'; if ($redirect_status > 0) { // Redirected before any template: no page type to speak of. $page_type = 'other'; $status_code = (int) $redirect_status; $GLOBALS['citami_bot_hit_logged'] = true; } else { $page_type = citami_detect_page_type(); // Map page type to canonical HTTP status. 200 for everything that // resolved to a real page; 404 when WP routed the request to its // 404 handler. We don't try to infer 410/503 here — those would // require post-render hooks we don't have. $status_code = ($page_type === '404') ? 404 : 200; } if ($bot_name !== null) { $hit = array( 'botName' => $bot_name, 'trafficType' => 'crawler', 'path' => substr($path, 0, 500), 'userAgent' => substr($ua, 0, 500), 'statusCode' => $status_code, 'pageType' => $page_type, 'hitAt' => gmdate('c'), ); } else { $hit = array( 'botName' => $ai_platform, 'trafficType' => 'human_referral', 'referrer' => substr($referrer, 0, 1000), 'path' => substr($path, 0, 500), 'userAgent' => substr($ua, 0, 500), 'statusCode' => $status_code, 'pageType' => $page_type, 'hitAt' => gmdate('c'), ); } citami_push_buffer($hit); // WP5 — the local verified log listens here (crawler hits only). do_action('citami_bot_hit', $hit, function_exists('citami_bots_client_ip') ? citami_bots_client_ip() : (isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '')); } add_action('template_redirect', function () { if (!empty($GLOBALS['citami_bot_hit_logged'])) return; citami_bot_track_request(); }, PHP_INT_MAX); // A redirect issued before the end of template_redirect (canonical, // SEO plugin, our own rules) never reaches the hook above. add_filter('wp_redirect_status', function ($status) { if (empty($GLOBALS['citami_bot_hit_logged']) && (int) $status >= 300 && (int) $status < 400) { citami_bot_track_request((int) $status); } return $status; }, PHP_INT_MAX); function citami_push_buffer($hit) { $buffer = get_option(CITAMI_BUFFER_OPT, array()); if (!is_array($buffer)) { $buffer = array(); } $buffer[] = $hit; // Hard cap: drop oldest so we don't blow up the options table if // Citami is unreachable for hours. if (count($buffer) > CITAMI_BUFFER_HARD_CAP) { $buffer = array_slice($buffer, -CITAMI_BUFFER_HARD_CAP); } update_option(CITAMI_BUFFER_OPT, $buffer, false); // Opportunistic flush when the soft cap is hit — don't wait 5min // on a high-traffic site. if (count($buffer) >= CITAMI_BUFFER_SOFT_CAP) { // Schedule a single-shot flush soon rather than running it // inline (keeps the page response fast for the bot). if (!wp_next_scheduled('citami_flush_bot_hits')) { wp_schedule_single_event(time() + 30, 'citami_flush_bot_hits'); } } } /* Register a 5-minute cron interval. */ add_filter('cron_schedules', function ($schedules) { if (!isset($schedules['citami_5min'])) { $schedules['citami_5min'] = array( 'interval' => 300, 'display' => citami_brand_text('Ogni 5 minuti (Citami)'), ); } return $schedules; }); add_action('citami_flush_bot_hits', 'citami_flush_bot_hits_handler'); function citami_flush_bot_hits_handler() { if (function_exists('citami_cron_heartbeat')) citami_cron_heartbeat(); // 2.46 — proof WP-Cron runs $key = citami_get_api_key(); if (empty($key)) return; // v2.7.1 — HEALTH-HANDSHAKE. Keep-alive agganciato al cron 5-min // già esistente: senza, un sito senza traffico bot AI non // contatterebbe MAI il server e in dashboard resterebbe "Non // connesso" per sempre. Cadenza 25 min: il badge dashboard // considera "connected" un ping negli ultimi 30 min // (lib/plugin/status.ts CONNECTED_THRESHOLD_MS) — un keep-alive // più lasco farebbe oscillare il badge su "stale". Costo: ~2 GET // l'ora per sito, contro un rate limit server di 60/min per key. $health = get_option(CITAMI_HEALTH_OPT, array()); $health_ts = is_array($health) && !empty($health['ts']) ? intval($health['ts']) : 0; if (time() - $health_ts > 25 * MINUTE_IN_SECONDS) { citami_health_ping(); } $buffer = get_option(CITAMI_BUFFER_OPT, array()); if (!is_array($buffer) || empty($buffer)) return; // Send up to 1000 per POST (matches the server's Zod cap). Keep // anything beyond that for the next run. $batch = array_slice($buffer, 0, 1000); $remainder = array_slice($buffer, 1000); $endpoint = citami_get_endpoint() . '/api/bot-hits'; $response = wp_remote_post($endpoint, array( 'timeout' => 10, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json', 'User-Agent' => 'CitamiConnector/' . CITAMI_PLUGIN_VERSION . '; ' . home_url('/'), ), 'body' => wp_json_encode(array('hits' => $batch)), )); if (is_wp_error($response)) { // Keep the buffer intact — we'll retry next cycle. citami_debug_log('[citami] bot-hits flush failed: ' . $response->get_error_message()); return; } $code = wp_remote_retrieve_response_code($response); if ($code >= 200 && $code < 300) { // Success: drop the batch we just sent, keep the remainder. update_option(CITAMI_BUFFER_OPT, $remainder, false); } else { citami_debug_log('[citami] bot-hits flush rejected: HTTP ' . $code); // 4xx on auth/rate-limit? We still retry — the server's 429 is // idempotent, and a mis-typed key will just keep failing until // the admin fixes it. The hard cap protects us from unbounded // growth. } } // v2.10.0 — BOT-FLUSH-SHUTDOWN. Bug: i bot hit venivano bufferizzati ma // inviati SOLO via WP-cron (citami_flush_bot_hits), e WP-cron parte solo // quando un essere umano carica una pagina. Un sito che riceve SOLO // traffico bot AI (zero visite umane) non svuotava mai il buffer → 0 // dati in dashboard (segnalato da Adrian: "i dati non arrivano"). // // Fix: il bot hit È una richiesta HTTP, e l'hook `shutdown` gira a fine // di OGNI richiesta front-end — quindi anche su una richiesta di un bot. // Agganciamo qui un flush con throttle (per non fare una POST a ogni // singolo hit): si invia quando il buffer raggiunge una piccola soglia // (burst) oppure è passato CITAMI_BOT_FLUSH_MIN_INTERVAL dall'ultimo // invio. Il cron 5-min resta come backstop + keep-alive health. define('CITAMI_BOT_FLUSH_LAST_OPT', 'citami_bot_flush_last_ts'); define('CITAMI_BOT_FLUSH_MIN_INTERVAL', 120); // 2 min tra invii "a tempo" define('CITAMI_BOT_FLUSH_BURST', 10); // invia subito a 10 hit add_action('shutdown', function () { // Solo richieste front-end pubbliche: admin/cron/ajax/rest hanno i // loro percorsi (e il cron chiama già l'handler direttamente). if (is_admin() || wp_doing_cron() || wp_doing_ajax()) return; if (defined('REST_REQUEST') && REST_REQUEST) return; $buffer = get_option(CITAMI_BUFFER_OPT, array()); if (!is_array($buffer) || empty($buffer)) return; $last = (int) get_option(CITAMI_BOT_FLUSH_LAST_OPT, 0); $now = time(); $due = count($buffer) >= CITAMI_BOT_FLUSH_BURST || ($now - $last) >= CITAMI_BOT_FLUSH_MIN_INTERVAL; if (!$due) return; // Marca il tentativo PRIMA dell'invio: il throttle deve reggere anche // se la POST fallisce, così non martelliamo il server a ogni hit. update_option(CITAMI_BOT_FLUSH_LAST_OPT, $now, false); citami_flush_bot_hits_handler(); }, 99); /* Activation/deactivation: schedule / unschedule the recurring flush. */ register_activation_hook(CITAMI_PLUGIN_FILE, function () { if (!wp_next_scheduled('citami_flush_bot_hits')) { wp_schedule_event(time() + 60, 'citami_5min', 'citami_flush_bot_hits'); } // v2.7.1 — HEALTH-HANDSHAKE. Primo contatto col server subito // all'attivazione (la key preloadata c'è già negli ZIP scaricati // dalla dashboard): la dashboard mostra "Connesso" senza dover // aspettare la prima visita di un bot AI. citami_health_ping(); // v2.6.0 — refresh giornaliero della sezione auto di llms.txt. if (!wp_next_scheduled('citami_refresh_llms_txt')) { wp_schedule_event(time() + 120, 'daily', 'citami_refresh_llms_txt'); } // v2.7.0 — AUTO-DEFAULT. Il marketing promette "fix con un click": // con review_only ON di default servivano 3 approvazioni dentro // WP. Nuova regola: se il plugin e' stato scaricato dal dashboard // Citami (preload presente) il cliente HA GIA' espresso l'intent // nel download → review_only parte OFF. Un install manuale/non // configurato (nessun preload) resta ON, secure-by-default. Chi ha // gia' un valore salvato non viene mai toccato. if (get_option(CITAMI_REVIEW_ONLY_OPT, null) === null || get_option(CITAMI_REVIEW_ONLY_OPT) === false) { $preloaded = defined('CITAMI_PRELOADED_API_KEY') && CITAMI_PRELOADED_API_KEY !== '' && strpos(CITAMI_PRELOADED_API_KEY, '{{') === false; add_option(CITAMI_REVIEW_ONLY_OPT, $preloaded ? '0' : '1', '', false); } }); register_deactivation_hook(CITAMI_PLUGIN_FILE, function () { $timestamp = wp_next_scheduled('citami_flush_bot_hits'); if ($timestamp) { wp_unschedule_event($timestamp, 'citami_flush_bot_hits'); } // v2.6.0 — spegni anche il refresh giornaliero di llms.txt. $llms_ts = wp_next_scheduled('citami_refresh_llms_txt'); if ($llms_ts) { wp_unschedule_event($llms_ts, 'citami_refresh_llms_txt'); } });