array('label' => 'OAI-SearchBot', 'ua' => 'OAI-SearchBot', 'vendor' => 'OpenAI', 'purpose' => 'search', 'verify' => array('type' => 'cidr', 'url' => 'https://openai.com/searchbot.json')), 'chatgpt-user' => array('label' => 'ChatGPT-User', 'ua' => 'ChatGPT-User', 'vendor' => 'OpenAI', 'purpose' => 'assistant', 'verify' => array('type' => 'cidr', 'url' => 'https://openai.com/chatgpt-user.json')), 'gptbot' => array('label' => 'GPTBot', 'ua' => 'GPTBot', 'vendor' => 'OpenAI', 'purpose' => 'training', 'verify' => array('type' => 'cidr', 'url' => 'https://openai.com/gptbot.json')), 'perplexitybot' => array('label' => 'PerplexityBot', 'ua' => 'PerplexityBot', 'vendor' => 'Perplexity', 'purpose' => 'search', 'verify' => array('type' => 'cidr', 'url' => 'https://www.perplexity.com/perplexitybot.json')), 'perplexity-user' => array('label' => 'Perplexity-User', 'ua' => 'Perplexity-User', 'vendor' => 'Perplexity', 'purpose' => 'assistant', 'verify' => array('type' => 'cidr', 'url' => 'https://www.perplexity.com/perplexity-user.json')), 'claude-searchbot' => array('label' => 'Claude-SearchBot', 'ua' => 'Claude-SearchBot', 'vendor' => 'Anthropic', 'purpose' => 'search', 'verify' => null), 'claude-user' => array('label' => 'Claude-User', 'ua' => 'Claude-User', 'vendor' => 'Anthropic', 'purpose' => 'assistant', 'verify' => null), 'claudebot' => array('label' => 'ClaudeBot', 'ua' => 'ClaudeBot', 'vendor' => 'Anthropic', 'purpose' => 'training', 'verify' => null), 'googlebot' => array('label' => 'Googlebot', 'ua' => 'Googlebot', 'vendor' => 'Google', 'purpose' => 'search', 'verify' => array('type' => 'rdns', 'suffixes' => array('googlebot.com', 'google.com'))), 'google-extended' => array('label' => 'Google-Extended', 'ua' => 'Google-Extended', 'vendor' => 'Google', 'purpose' => 'training', 'verify' => array('type' => 'rdns', 'suffixes' => array('googlebot.com', 'google.com'))), 'bingbot' => array('label' => 'Bingbot', 'ua' => 'bingbot', 'vendor' => 'Microsoft', 'purpose' => 'search', 'verify' => array('type' => 'rdns', 'suffixes' => array('search.msn.com'))), 'applebot' => array('label' => 'Applebot', 'ua' => 'Applebot', 'vendor' => 'Apple', 'purpose' => 'search', 'verify' => array('type' => 'rdns', 'suffixes' => array('applebot.apple.com'))), 'applebot-extended' => array('label' => 'Applebot-Extended', 'ua' => 'Applebot-Extended', 'vendor' => 'Apple', 'purpose' => 'training', 'verify' => array('type' => 'rdns', 'suffixes' => array('applebot.apple.com'))), 'amazonbot' => array('label' => 'Amazonbot', 'ua' => 'Amazonbot', 'vendor' => 'Amazon', 'purpose' => 'both', 'verify' => array('type' => 'rdns', 'suffixes' => array('crawl.amazonbot.amazon'))), 'duckassistbot' => array('label' => 'DuckAssistBot', 'ua' => 'DuckAssistBot', 'vendor' => 'DuckDuckGo', 'purpose' => 'search', 'verify' => null), 'meta-externalagent' => array('label' => 'Meta-ExternalAgent', 'ua' => 'meta-externalagent', 'vendor' => 'Meta', 'purpose' => 'training', 'verify' => null), 'facebookbot' => array('label' => 'FacebookBot', 'ua' => 'FacebookBot', 'vendor' => 'Meta', 'purpose' => 'training', 'verify' => null), 'bytespider' => array('label' => 'Bytespider', 'ua' => 'Bytespider', 'vendor' => 'ByteDance', 'purpose' => 'training', 'verify' => null), 'ccbot' => array('label' => 'CCBot', 'ua' => 'CCBot', 'vendor' => 'Common Crawl', 'purpose' => 'training', 'verify' => null), 'diffbot' => array('label' => 'Diffbot', 'ua' => 'Diffbot', 'vendor' => 'Diffbot', 'purpose' => 'training', 'verify' => null), 'cohere-ai' => array('label' => 'cohere-ai', 'ua' => 'cohere-ai', 'vendor' => 'Cohere', 'purpose' => 'training', 'verify' => null), 'youbot' => array('label' => 'YouBot', 'ua' => 'YouBot', 'vendor' => 'You.com', 'purpose' => 'search', 'verify' => null), 'mistralai-user' => array('label' => 'MistralAI-User', 'ua' => 'MistralAI-User', 'vendor' => 'Mistral', 'purpose' => 'assistant', 'verify' => null), ); } /** Catalogue slug for a User-Agent string, or null. Longest token wins. */ function citami_bots_slug_from_ua($ua) { if (!is_string($ua) || $ua === '') { return null; } $best = null; $best_len = 0; foreach (citami_bots_catalog() as $slug => $bot) { if (stripos($ua, $bot['ua']) !== false && strlen($bot['ua']) > $best_len) { $best = $slug; $best_len = strlen($bot['ua']); } } return $best; } /* ---------------------------------------------------------------- */ /* Policy → robots.txt */ /* ---------------------------------------------------------------- */ function citami_bots_policy() { $p = get_option(CITAMI_BOTS_POLICY_OPT, array()); if (!is_array($p)) { $p = array(); } $out = array('rules' => array(), 'extra' => '', 'access_log' => ''); if (isset($p['rules']) && is_array($p['rules'])) { foreach ($p['rules'] as $slug => $rule) { if (in_array($rule, array('allow', 'block'), true)) { $out['rules'][sanitize_key((string) $slug)] = $rule; } } } if (isset($p['extra']) && is_string($p['extra'])) { $out['extra'] = $p['extra']; } if (isset($p['access_log']) && is_string($p['access_log'])) { $out['access_log'] = $p['access_log']; } return $out; } function citami_sanitize_bots_policy($value) { $in = is_array($value) ? $value : array(); $out = array('rules' => array(), 'extra' => '', 'access_log' => ''); $catalog = citami_bots_catalog(); if (isset($in['rules']) && is_array($in['rules'])) { foreach ($in['rules'] as $slug => $rule) { $slug = sanitize_key((string) $slug); if (isset($catalog[$slug]) && in_array($rule, array('allow', 'block'), true)) { $out['rules'][$slug] = $rule; } } } if (isset($in['extra'])) { $lines = array(); foreach (preg_split('/\r\n|\r|\n/', (string) $in['extra']) as $line) { $line = trim(wp_strip_all_tags($line)); if ($line === '' || preg_match('/^(user-agent|allow|disallow|crawl-delay|sitemap|#)/i', $line)) { $lines[] = $line; } } $out['extra'] = trim(implode("\n", $lines)); } if (isset($in['access_log'])) { $path = trim((string) $in['access_log']); // Only absolute paths to files that exist and are readable. $out['access_log'] = ($path !== '' && $path[0] === '/' && @is_file($path) && @is_readable($path)) ? $path : ''; } return $out; } /** * 2.59 — the classic web search engines. Blocking them takes the site out * of Google, Bing and Apple search, not just out of AI answers: no preset * ever blocks them; only a per-bot choice can. */ function citami_bots_web_search() { return array('googlebot', 'bingbot', 'applebot'); } /** Apply a preset: allow search/assistant bots, block training-only ones. */ function citami_bots_preset($name) { $rules = array(); $web = citami_bots_web_search(); foreach (citami_bots_catalog() as $slug => $bot) { if ($name === 'allow_search_block_training') { $rules[$slug] = ($bot['purpose'] === 'training') ? 'block' : 'allow'; } elseif ($name === 'block_all') { $rules[$slug] = in_array($slug, $web, true) ? 'allow' : 'block'; } elseif ($name === 'allow_all') { $rules[$slug] = 'allow'; } } return $rules; } /** robots.txt groups generated from the policy (empty when no rule). */ function citami_bots_robots_block() { $policy = citami_bots_policy(); $catalog = citami_bots_catalog(); $lines = array(); foreach ($policy['rules'] as $slug => $rule) { if (!isset($catalog[$slug])) { continue; } $lines[] = 'User-agent: ' . $catalog[$slug]['ua']; $lines[] = $rule === 'block' ? 'Disallow: /' : 'Allow: /'; $lines[] = ''; } if ($policy['extra'] !== '') { $lines[] = $policy['extra']; $lines[] = ''; } return empty($lines) ? '' : "# " . citami_brand_name() . " AI bots\n" . trim(implode("\n", $lines)) . "\n"; } add_filter('robots_txt', function ($output) { $block = citami_bots_robots_block(); return $block === '' ? $output : rtrim((string) $output) . "\n\n" . $block; }, 20, 1); /* ---------------------------------------------------------------- */ /* IP verification */ /* ---------------------------------------------------------------- */ function citami_ip_in_cidr($ip, $cidr) { if (strpos($cidr, '/') === false) { return $ip === $cidr; } list($subnet, $bits) = explode('/', $cidr, 2); $bits = (int) $bits; $ipb = @inet_pton($ip); $subb = @inet_pton($subnet); if ($ipb === false || $subb === false || strlen($ipb) !== strlen($subb)) { return false; } $len = strlen($ipb) * 8; if ($bits < 0 || $bits > $len) { return false; } $full = intdiv($bits, 8); $rem = $bits % 8; if ($full > 0 && substr($ipb, 0, $full) !== substr($subb, 0, $full)) { return false; } if ($rem === 0) { return true; } $mask = (0xFF << (8 - $rem)) & 0xFF; return (ord($ipb[$full]) & $mask) === (ord($subb[$full]) & $mask); } /** Fetches the published CIDR lists (once a day, cron) into an option. */ function citami_bots_refresh_ranges() { $ranges = get_option(CITAMI_BOTS_RANGES_OPT, array()); if (!is_array($ranges)) { $ranges = array(); } foreach (citami_bots_catalog() as $slug => $bot) { if (empty($bot['verify']) || $bot['verify']['type'] !== 'cidr') { continue; } $res = wp_remote_get($bot['verify']['url'], array('timeout' => 10, 'headers' => array('User-Agent' => 'CitamiConnector/' . CITAMI_PLUGIN_VERSION))); if (is_wp_error($res) || (int) wp_remote_retrieve_response_code($res) !== 200) { continue; } $data = json_decode(wp_remote_retrieve_body($res), true); $prefixes = array(); if (is_array($data)) { $list = isset($data['prefixes']) && is_array($data['prefixes']) ? $data['prefixes'] : $data; foreach ($list as $p) { if (is_array($p)) { foreach (array('ipv4Prefix', 'ipv6Prefix', 'prefix', 'cidr') as $k) { if (!empty($p[$k]) && is_string($p[$k])) { $prefixes[] = $p[$k]; } } } elseif (is_string($p)) { $prefixes[] = $p; } } } if (!empty($prefixes)) { $ranges[$slug] = array('ts' => time(), 'prefixes' => array_values(array_unique($prefixes))); } } update_option(CITAMI_BOTS_RANGES_OPT, $ranges, false); return $ranges; } add_action(CITAMI_BOTS_VERIFY_HOOK, 'citami_bots_refresh_ranges'); add_action('init', function () { if (!wp_next_scheduled(CITAMI_BOTS_VERIFY_HOOK)) { wp_schedule_event(time() + 10 * MINUTE_IN_SECONDS, 'daily', CITAMI_BOTS_VERIFY_HOOK); } }); /** Reverse DNS + forward confirmation. Filterable for tests / custom resolvers. */ function citami_bots_rdns_verify($ip, array $suffixes) { $override = apply_filters('citami_bots_rdns', null, $ip); $host = $override !== null ? (string) $override : @gethostbyaddr($ip); if (!is_string($host) || $host === '' || $host === $ip) { return false; } $ok = false; foreach ($suffixes as $suffix) { if (substr($host, -strlen('.' . $suffix)) === '.' . $suffix || $host === $suffix) { $ok = true; break; } } if (!$ok) { return false; } $forward = apply_filters('citami_bots_forward_dns', null, $host); if ($forward === null) { $forward = @gethostbyname($host); if ($forward === $host && function_exists('dns_get_record')) { $rec = @dns_get_record($host, DNS_AAAA); $forward = (is_array($rec) && !empty($rec[0]['ipv6'])) ? $rec[0]['ipv6'] : $host; } } return (string) $forward === (string) $ip; } /** * true = verified genuine, false = spoofed / unverifiable-mismatch, * null = cannot verify (no published method or no data yet). */ function citami_bots_verify_ip($slug, $ip) { $catalog = citami_bots_catalog(); if (!isset($catalog[$slug]) || empty($catalog[$slug]['verify']) || !filter_var($ip, FILTER_VALIDATE_IP)) { return null; } $cache = get_option(CITAMI_BOTS_IPCACHE_OPT, array()); if (!is_array($cache)) { $cache = array(); } $key = $slug . '|' . $ip; if (isset($cache[$key]) && is_array($cache[$key]) && (time() - (int) $cache[$key]['ts']) < DAY_IN_SECONDS) { return $cache[$key]['v']; } $v = $catalog[$slug]['verify']; $result = null; if ($v['type'] === 'cidr') { $ranges = get_option(CITAMI_BOTS_RANGES_OPT, array()); if (is_array($ranges) && !empty($ranges[$slug]['prefixes'])) { $result = false; foreach ($ranges[$slug]['prefixes'] as $cidr) { if (citami_ip_in_cidr($ip, $cidr)) { $result = true; break; } } } } elseif ($v['type'] === 'rdns') { $result = citami_bots_rdns_verify($ip, $v['suffixes']); } if ($result !== null) { $cache[$key] = array('v' => $result, 'ts' => time()); if (count($cache) > 500) { $cache = array_slice($cache, -500, null, true); } update_option(CITAMI_BOTS_IPCACHE_OPT, $cache, false); } return $result; } /* ---------------------------------------------------------------- */ /* Local log */ /* ---------------------------------------------------------------- */ function citami_bots_client_ip() { foreach (array('HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR') as $h) { if (!empty($_SERVER[$h])) { $ip = trim(explode(',', sanitize_text_field(wp_unslash($_SERVER[$h])))[0]); if (filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } } } return ''; } /** Appends one entry (dedupe on bot+ip+path+minute) to the rolling log. */ function citami_bots_log_hit($slug, $path, $ip, $ua, $source = 'php', $ts = null, $status = 200) { $log = get_option(CITAMI_BOTS_LOG_OPT, array()); if (!is_array($log)) { $log = array(); } $ts = $ts === null ? time() : (int) $ts; $key = $slug . '|' . $ip . '|' . $path . '|' . intdiv($ts, 60); foreach ($log as $e) { if (is_array($e) && isset($e['key']) && $e['key'] === $key) { return false; } } $entry = array( 'key' => $key, 'ts' => $ts, 'bot' => $slug, 'path' => mb_substr((string) $path, 0, 300), 'ip' => $ip, 'ua' => mb_substr((string) $ua, 0, 200), 'status' => (int) $status, 'source' => $source, 'verified' => null, ); $log[] = $entry; if (count($log) > CITAMI_BOTS_LOG_MAX) { $log = array_slice($log, -CITAMI_BOTS_LOG_MAX); } update_option(CITAMI_BOTS_LOG_OPT, $log, false); return true; } // Every crawler hit the tracker records also lands in the local log. add_action('citami_bot_hit', function ($hit, $ip) { if (!is_array($hit) || empty($hit['botName']) || (isset($hit['trafficType']) && $hit['trafficType'] !== 'crawler')) { return; } $slug = citami_bots_slug_from_ua(isset($hit['userAgent']) ? $hit['userAgent'] : ''); if ($slug === null) { $slug = (string) $hit['botName']; } citami_bots_log_hit($slug, isset($hit['path']) ? $hit['path'] : '/', (string) $ip, isset($hit['userAgent']) ? $hit['userAgent'] : '', 'php', null, isset($hit['statusCode']) ? (int) $hit['statusCode'] : 200); }, 10, 2); /** Verifies unverified log entries (bounded per run, runs with the flush cron). */ function citami_bots_verify_pending($max = 50) { $log = get_option(CITAMI_BOTS_LOG_OPT, array()); if (!is_array($log) || empty($log)) { return 0; } $done = 0; $changed = false; foreach ($log as $i => $e) { if ($done >= $max) { break; } if (!is_array($e) || $e['verified'] !== null || empty($e['ip'])) { continue; } $v = citami_bots_verify_ip($e['bot'], $e['ip']); if ($v === null) { $log[$i]['verified'] = 'n/a'; } else { $log[$i]['verified'] = $v; } $changed = true; $done++; } if ($changed) { update_option(CITAMI_BOTS_LOG_OPT, $log, false); } return $done; } add_action('citami_flush_bot_hits', 'citami_bots_verify_pending', 5); /* ---------------------------------------------------------------- */ /* Access log parsing (cache-proof) */ /* ---------------------------------------------------------------- */ /** Parses one combined-format line; null when it is not an AI bot hit. */ function citami_bots_parse_log_line($line) { if (!preg_match('/^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+)[^"]*" (\d{3}) \S+ "[^"]*" "([^"]*)"/', $line, $m)) { return null; } $slug = citami_bots_slug_from_ua($m[6]); if ($slug === null) { return null; } $ts = strtotime(str_replace('/', ' ', preg_replace('#^(\d+)/(\w+)/(\d+):#', '$1 $2 $3 ', $m[2]))); return array( 'ip' => $m[1], 'ts' => $ts ? $ts : time(), 'method' => $m[3], 'path' => wp_parse_url($m[4], PHP_URL_PATH) ?: $m[4], 'status' => (int) $m[5], 'ua' => $m[6], 'bot' => $slug, ); } /** Reads what was appended to the access log since the last run. */ function citami_bots_scan_access_log() { $policy = citami_bots_policy(); $path = $policy['access_log']; $summary = array('scanned' => 0, 'hits' => 0); if ($path === '' || !@is_readable($path)) { return $summary; } $state = get_option(CITAMI_BOTS_LOGSTATE_OPT, array()); if (!is_array($state)) { $state = array(); } $size = @filesize($path); $inode = @fileinode($path); $offset = (isset($state['inode']) && $state['inode'] === $inode && isset($state['offset']) && $state['offset'] <= $size) ? (int) $state['offset'] : 0; // First run: only the tail, never the whole history. Jumping into the // middle of the file lands mid-line: that first partial line is skipped. // Later runs resume at a line boundary (we only ever consume whole lines). $skip_partial = false; if (!isset($state['inode']) && $size > 512 * 1024) { $offset = $size - 512 * 1024; $skip_partial = true; } // Streamed line by line (the access log can be large): SplFileObject // instead of fopen/fgets so the file handle never leaks. try { $fh = new SplFileObject($path, 'r'); } catch (Exception $e) { return $summary; } $fh->fseek($offset); if ($skip_partial) { $fh->fgets(); } $lines = 0; while (!$fh->eof() && $lines < 20000) { $line = $fh->fgets(); if ($line === '' && $fh->eof()) { break; } $lines++; $summary['scanned']++; $hit = citami_bots_parse_log_line($line); if ($hit === null) { continue; } if (citami_bots_log_hit($hit['bot'], $hit['path'], $hit['ip'], $hit['ua'], 'access_log', $hit['ts'], $hit['status'])) { $summary['hits']++; // Also feed the platform: a cached page hit is a real crawl. if (function_exists('citami_push_buffer')) { citami_push_buffer(array( 'botName' => $hit['bot'], 'trafficType' => 'crawler', 'path' => substr($hit['path'], 0, 500), 'userAgent' => substr($hit['ua'], 0, 500), 'statusCode' => $hit['status'], 'pageType' => 'other', 'hitAt' => gmdate('c', $hit['ts']), )); } } } $newOffset = $fh->ftell(); $fh = null; // 2.60 — the last run's summary stays with the state: the Bot AI tab // shows under the path field when the log was read and what it found. update_option(CITAMI_BOTS_LOGSTATE_OPT, array('inode' => $inode, 'offset' => $newOffset, 'ts' => time(), 'scanned' => (int) $summary['scanned'], 'hits' => (int) $summary['hits'], 'path' => $path), false); return $summary; } /** 2.60 — last access-log read: array('ts', 'scanned', 'hits', 'path') or null when never read. */ function citami_bots_access_log_last() { $state = get_option(CITAMI_BOTS_LOGSTATE_OPT, array()); if (!is_array($state) || empty($state['ts'])) { return null; } return array( 'ts' => (int) $state['ts'], 'scanned' => isset($state['scanned']) ? (int) $state['scanned'] : null, 'hits' => isset($state['hits']) ? (int) $state['hits'] : null, 'path' => isset($state['path']) ? (string) $state['path'] : '', ); } add_action('citami_flush_bot_hits', 'citami_bots_scan_access_log', 4); /* ---------------------------------------------------------------- */ /* Stats + admin tab */ /* ---------------------------------------------------------------- */ function citami_bots_stats($days = 7) { $log = get_option(CITAMI_BOTS_LOG_OPT, array()); $since = time() - $days * DAY_IN_SECONDS; $out = array(); foreach ((array) $log as $e) { if (!is_array($e) || (int) $e['ts'] < $since) { continue; } $b = $e['bot']; if (!isset($out[$b])) { $out[$b] = array('hits' => 0, 'verified' => 0, 'spoofed' => 0); } $out[$b]['hits']++; if ($e['verified'] === true) { $out[$b]['verified']++; } elseif ($e['verified'] === false) { $out[$b]['spoofed']++; } } return $out; } /** * 2.53 — reads by assistants that fetch on a person's request * (ChatGPT-User, Perplexity-User, Claude-User, MistralAI-User): each one * is a page opened while an AI was writing an answer for someone. * Returns array('total' => int, 'by' => array(slug => hits)). */ function citami_bots_live_reads($days = 14) { $catalog = citami_bots_catalog(); $out = array('total' => 0, 'by' => array()); foreach (citami_bots_stats($days) as $slug => $row) { if (!isset($catalog[$slug]) || $catalog[$slug]['purpose'] !== 'assistant') { continue; } $out['by'][$slug] = (int) $row['hits']; $out['total'] += (int) $row['hits']; } arsort($out['by']); return $out; } add_action('admin_init', function () { register_setting('citami_bots', CITAMI_BOTS_POLICY_OPT, array( 'type' => 'array', 'sanitize_callback' => 'citami_sanitize_bots_policy', 'default' => array(), )); }); /* ---------------------------------------------------------------- */ /* 2.60 — visits log filters, physical robots.txt */ /* ---------------------------------------------------------------- */ /** * A physical robots.txt in the site root is served by the web server * before WordPress runs: the robots_txt filter (our rules) never applies. */ function citami_bots_physical_robots_exists() { return @is_file(ABSPATH . 'robots.txt'); } /** Filter for the visits log from the query string: bot, verified (0|1), purpose, days. */ function citami_bots_log_filter_from_request() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filter $get = function ($k) { return isset($_GET[$k]) && is_string($_GET[$k]) ? sanitize_key(wp_unslash($_GET[$k])) : ''; }; $f = array('bot' => $get('bot'), 'verified' => null, 'purpose' => '', 'days' => 0); $verified = $get('verified'); if ($verified === '0' || $verified === '1') { $f['verified'] = $verified === '1'; } if (in_array($get('purpose'), array('search', 'assistant', 'training', 'both'), true)) { $f['purpose'] = $get('purpose'); } if ($get('days') !== '') { $f['days'] = max(0, min(90, (int) $get('days'))); } // phpcs:enable return $f; } /** True when the filter narrows the log. */ function citami_bots_log_filter_active(array $f) { return $f['bot'] !== '' || $f['verified'] !== null || $f['purpose'] !== '' || (int) $f['days'] > 0; } /** Log entries (any order) matching the filter. Pure: tests call it directly. */ function citami_bots_filter_log(array $log, array $f) { $catalog = citami_bots_catalog(); $since = (int) $f['days'] > 0 ? time() - (int) $f['days'] * DAY_IN_SECONDS : 0; $out = array(); foreach ($log as $e) { if (!is_array($e) || !isset($e['bot'])) { continue; } if ($since > 0 && (int) (isset($e['ts']) ? $e['ts'] : 0) < $since) { continue; } if ($f['bot'] !== '' && (string) $e['bot'] !== $f['bot']) { continue; } if ($f['verified'] !== null && (!array_key_exists('verified', $e) || $e['verified'] !== $f['verified'])) { continue; } if ($f['purpose'] !== '' && (!isset($catalog[$e['bot']]) || $catalog[$e['bot']]['purpose'] !== $f['purpose'])) { continue; } $out[] = $e; } return $out; } /** Link to the Bot AI tab with the visits log filtered (anchor on the log). */ function citami_bots_log_url(array $args) { return citami_admin_url('bots', $args) . '#citami-bots-log'; } /** "Filtro: …" words for an active filter. */ function citami_bots_log_filter_label(array $f) { $catalog = citami_bots_catalog(); $purpose_labels = array( 'search' => citami_t('bot di ricerca', 'search bots'), 'assistant' => citami_t('assistenti che leggono per qualcuno', 'assistants reading for someone'), 'training' => citami_t('bot di solo addestramento', 'training-only bots'), 'both' => citami_t('ricerca + addestramento', 'search + training'), ); $parts = array(); if ($f['bot'] !== '') { $parts[] = isset($catalog[$f['bot']]) ? $catalog[$f['bot']]['label'] : $f['bot']; } if ($f['purpose'] !== '') { $parts[] = $purpose_labels[$f['purpose']]; } if ($f['verified'] === true) { $parts[] = citami_t('verificate', 'verified'); } elseif ($f['verified'] === false) { $parts[] = citami_t('false (IP non del bot)', 'spoofed (IP not the bot\'s)'); } if ((int) $f['days'] > 0) { $parts[] = sprintf(citami_t('ultimi %d gg', 'last %d days'), (int) $f['days']); } return implode(' · ', $parts); } function citami_handle_bots_preset() { if (!current_user_can('manage_options')) { wp_die('Permesso negato.', '', array('response' => 403)); } if (!wp_verify_nonce(isset($_POST['_citami_bots_nonce']) ? sanitize_text_field(wp_unslash($_POST['_citami_bots_nonce'])) : '', 'citami_bots_preset')) { wp_die('Nonce non valido.', '', array('response' => 403)); } $preset = isset($_POST['preset']) ? sanitize_key((string) $_POST['preset']) : ''; $policy = citami_bots_policy(); $policy['rules'] = citami_bots_preset($preset); update_option(CITAMI_BOTS_POLICY_OPT, $policy, false); set_transient('citami_action_notice', array('type' => 'success', 'message' => citami_t('Regole bot aggiornate.', 'Bot rules updated.')), 30); wp_safe_redirect(admin_url('admin.php?page=citami-connector&tab=bots')); exit; } add_action('admin_post_citami_bots_preset', 'citami_handle_bots_preset'); /** Which preset the current rules match, or '' (custom / none). */ function citami_bots_active_preset() { $rules = citami_bots_policy(); $rules = $rules['rules']; if (empty($rules)) { return ''; } foreach (array('allow_search_block_training', 'allow_all', 'block_all') as $name) { $preset = citami_bots_preset($name); ksort($preset); $mine = $rules; ksort($mine); if ($preset === $mine) { return $name; } } return 'custom'; } // 2.40 — the bot policy travels with the content-health snapshot: the // dashboard sees which citing bots the site blocks without asking twice. add_filter('citami_health_extra', function ($extra) { $extra = is_array($extra) ? $extra : array(); $policy = citami_bots_policy(); $catalog = citami_bots_catalog(); $counts = array('allow' => 0, 'block' => 0, 'none' => 0); $blocked_citing = array(); $rules = array(); foreach ($catalog as $slug => $bot) { $rule = isset($policy['rules'][$slug]) ? (string) $policy['rules'][$slug] : ''; if ($rule === 'allow' || $rule === 'block') { $counts[$rule]++; $rules[$slug] = $rule; if ($rule === 'block' && in_array($bot['purpose'], array('search', 'assistant', 'both'), true)) { $blocked_citing[] = (string) $slug; } } else { $counts['none']++; } } sort($blocked_citing, SORT_STRING); $extra['bots'] = array( 'preset' => (string) citami_bots_active_preset(), 'allow' => $counts['allow'], 'block' => $counts['block'], 'none' => $counts['none'], 'blocked_citing' => array_slice($blocked_citing, 0, 30), 'rules' => array_slice($rules, 0, 40, true), ); return $extra; }); function citami_render_bots_tab() { $policy = citami_bots_policy(); $stats = citami_bots_stats(7); $log = array_reverse((array) get_option(CITAMI_BOTS_LOG_OPT, array())); $catalog = citami_bots_catalog(); $active = citami_bots_active_preset(); $purpose_labels = array( 'search' => citami_t('Ricerca / risposte', 'Search / answers'), 'assistant' => citami_t('Assistente per l\'utente', 'On-demand assistant'), 'training' => citami_t('Solo addestramento', 'Training only'), 'both' => citami_t('Ricerca + addestramento', 'Search + training'), ); $groups = array( 'cite' => array('title' => citami_t('Portano citazioni', 'They bring citations'), 'hint' => citami_t('Ricerca e assistenti: bloccarli spegne la visibilità AI.', 'Search and assistants: blocking them switches off AI visibility.'), 'purposes' => array('search', 'assistant', 'both')), 'training' => array('title' => citami_t('Solo addestramento', 'Training only'), 'hint' => citami_t('Non ti citano mai: bloccarli è una scelta legittima senza costi.', 'They never cite you: blocking them is a legitimate choice with no cost.'), 'purposes' => array('training')), ); $counts = array('allow' => 0, 'block' => 0, 'none' => 0); $hits7 = 0; $verified7 = 0; $spoofed7 = 0; $max_hits = 1; foreach ($catalog as $slug => $bot) { $rule = isset($policy['rules'][$slug]) ? $policy['rules'][$slug] : ''; $counts[$rule === '' ? 'none' : $rule]++; $st = isset($stats[$slug]) ? $stats[$slug] : array('hits' => 0, 'verified' => 0, 'spoofed' => 0); $hits7 += (int) $st['hits']; $verified7 += (int) $st['verified']; $spoofed7 += (int) $st['spoofed']; $max_hits = max($max_hits, (int) $st['hits']); } $preview = citami_bots_robots_block(); $physical_robots = citami_bots_physical_robots_exists(); $filter = citami_bots_log_filter_from_request(); $filtered = citami_bots_log_filter_active($filter); $log_all = count($log); if ($filtered) { $log = citami_bots_filter_log($log, $filter); } $citing = array('search', 'assistant', 'both'); $presets = array( 'allow_search_block_training' => citami_t('Consigliato: ricerca sì, addestramento no', 'Recommended: search yes, training no'), 'allow_all' => citami_t('Consenti tutti', 'Allow all'), 'block_all' => citami_t('Blocca tutti i bot AI', 'Block every AI bot'), ); ?>

$label): ?>

· ·

0): ?>

robots.txt

$g): ?>
$bot): if (!in_array($bot['purpose'], $g['purposes'], true)) continue; $st = isset($stats[$slug]) ? $stats[$slug] : array('hits' => 0, 'verified' => 0, 'spoofed' => 0); $rule = isset($policy['rules'][$slug]) ? $policy['rules'][$slug] : ''; $field = CITAMI_BOTS_POLICY_OPT . '[rules][' . $slug . ']'; ?> >
Botrobots.txt

0): ?> 0
— ✓ 0): ?>✗
>


·

BotIPHTTP
✓' : ($e['verified'] === false ? '✗ ' . esc_html(citami_t('falso', 'spoofed')) . '' : '—'); ?>