$match === 'regex' ? $source : citami_redirect_normalise_path($source), 'target' => $target, 'code' => $code, 'regex' => $match === 'regex', 'match' => $match, 'active' => !isset($in['active']) || $in['active'] !== false, ); } function citami_redirect_add(array $in) { $rule = citami_redirect_sanitize_rule($in); if (is_wp_error($rule)) { return $rule; } $rules = citami_redirects_all(); foreach ($rules as $r) { if ($r['source'] === $rule['source'] && (isset($r['match']) ? $r['match'] : 'exact') === $rule['match']) { return new WP_Error('citami_redirect_duplicate', citami_t('Esiste già un redirect per questa sorgente.', 'A redirect for this source already exists.')); } } $rule['id'] = uniqid('rd_', true); $rule['hits'] = 0; $rule['imported'] = ''; $rule['created'] = gmdate('c'); $rules[] = $rule; update_option(CITAMI_REDIRECTS_OPT, $rules, false); return $rule; } function citami_redirect_update($id, array $patch) { $rules = citami_redirects_all(); foreach ($rules as $i => $r) { if ($r['id'] === $id) { $rules[$i] = array_merge($r, $patch); update_option(CITAMI_REDIRECTS_OPT, $rules, false); return $rules[$i]; } } return null; } function citami_redirect_delete($id) { $rules = citami_redirects_all(); $kept = array_values(array_filter($rules, function ($r) use ($id) { return $r['id'] !== $id; })); $removed = count($rules) - count($kept); if ($removed > 0) { update_option(CITAMI_REDIRECTS_OPT, $kept, false); } return $removed; } /* ---------------------------------------------------------------- */ /* 404 log */ /* ---------------------------------------------------------------- */ /** * Requests not worth a redirect: WordPress internals (also inside a * sub-folder install, /blog/wp-includes/…), probes, and static files * (scripts, styles, images, fonts) — 2.56.1: a missing asset is not a * page anyone asked for, and it buried the AI-invented URLs. */ function citami_404_is_noise($path) { $path = (string) $path; return (bool) (preg_match('#(^|/)(wp-admin|wp-login|xmlrpc|wp-content|wp-includes|\.well-known)(/|\.|$)#', $path) || preg_match('/\.(php|env|git|sql|bak|map|js|mjs|css|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot|otf|mp4|webm|mp3)$/i', $path)); } function citami_404_record($path, $referrer = '', $ua = '') { $path = citami_redirect_normalise_path($path); if ($path === '/' || strlen($path) > 300) { return; } // Ignore the noise every WordPress sees. if (citami_404_is_noise($path)) { return; } $log = get_option(CITAMI_404_LOG_OPT, array()); if (!is_array($log)) { $log = array(); } // BR4 — who asked for a page that does not exist: an AI crawler // (verified catalogue UA) or a visitor sent by an AI assistant // (Referer). Those are the URLs the models INVENTED about the site. $ai_source = citami_404_ai_source($ua, $referrer); if (isset($log[$path])) { $log[$path]['count']++; $log[$path]['last'] = time(); } else { if (count($log) >= CITAMI_404_LOG_MAX) { uasort($log, function ($a, $b) { return $a['last'] <=> $b['last']; }); $log = array_slice($log, -(CITAMI_404_LOG_MAX - 1), null, true); } $log[$path] = array('count' => 1, 'first' => time(), 'last' => time(), 'referrer' => mb_substr((string) $referrer, 0, 200), 'bot' => citami_bots_slug_from_ua($ua), 'ai' => 0, 'ai_from' => array()); } if ($ai_source !== null) { do_action('citami_ai_404', $path, $ai_source); // 2.34 — the daily history counts it $log[$path]['ai'] = (isset($log[$path]['ai']) ? (int) $log[$path]['ai'] : 0) + 1; if (!isset($log[$path]['ai_from']) || !is_array($log[$path]['ai_from'])) { $log[$path]['ai_from'] = array(); } $log[$path]['ai_from'][$ai_source] = (isset($log[$path]['ai_from'][$ai_source]) ? (int) $log[$path]['ai_from'][$ai_source] : 0) + 1; if (count($log[$path]['ai_from']) > 8) { arsort($log[$path]['ai_from']); $log[$path]['ai_from'] = array_slice($log[$path]['ai_from'], 0, 8, true); } } update_option(CITAMI_404_LOG_OPT, $log, false); } /* ---------------------------------------------------------------- */ /* BR4 — hallucinated URLs: 404s that come from AI */ /* ---------------------------------------------------------------- */ /** Hosts of the assistants that send visitors (Referer header). */ function citami_404_ai_referrer_hosts() { return array( 'chatgpt.com' => 'chatgpt', 'chat.openai.com' => 'chatgpt', 'openai.com' => 'chatgpt', 'perplexity.ai' => 'perplexity', 'gemini.google.com' => 'gemini', 'bard.google.com' => 'gemini', 'copilot.microsoft.com' => 'copilot', 'bing.com' => 'copilot', 'claude.ai' => 'claude', 'you.com' => 'you', 'chat.mistral.ai' => 'mistral', 'meta.ai' => 'meta', 'poe.com' => 'poe', 'duckduckgo.com' => 'duckassist', 'search.brave.com' => 'brave', ); } /** "crawler:" for a catalogue AI crawler, "referrer:" for a visit from an assistant, null otherwise. */ function citami_404_ai_source($ua, $referrer) { $slug = citami_bots_slug_from_ua($ua); if ($slug !== null && !in_array($slug, array('googlebot', 'bingbot', 'applebot'), true)) { return 'crawler:' . $slug; } $host = strtolower((string) wp_parse_url((string) $referrer, PHP_URL_HOST)); $host = preg_replace('/^www\./', '', $host); if ($host === '') { return null; } foreach (citami_404_ai_referrer_hosts() as $h => $engine) { if ($host === $h || substr($host, -strlen('.' . $h)) === '.' . $h) { // bing.com only counts as Copilot when the path says so. if ($h === 'bing.com' && stripos((string) wp_parse_url((string) $referrer, PHP_URL_PATH), 'chat') === false && stripos((string) wp_parse_url((string) $referrer, PHP_URL_PATH), 'copilot') === false) { return null; } return 'referrer:' . $engine; } } return null; } function citami_404_path_tokens($s) { $s = strtolower(preg_replace('/\.(html?|php)$/', '', (string) $s)); $s = preg_replace('/[^a-z0-9]+/', ' ', $s); $stop = array('it', 'en', 'the', 'and', 'del', 'della', 'di', 'da', 'il', 'la', 'le', 'i', 'gli', 'per', 'con', 'www', 'index', 'page', 'pagina', 'html'); $out = array(); foreach (preg_split('/\s+/', trim($s)) as $t) { if ($t !== '' && strlen($t) > 1 && !in_array($t, $stop, true)) { $out[$t] = true; } } return $out; } /** The existing page an invented URL most likely meant (links index: slug + title tokens). '' when nothing is close. */ function citami_404_suggest_target($path) { if (!function_exists('citami_links_index')) { return ''; } $want = citami_404_path_tokens($path); if (empty($want)) { return ''; } $idx = citami_links_index(); $entries = isset($idx['entries']) && is_array($idx['entries']) ? $idx['entries'] : array(); $best = ''; $best_score = 0.0; foreach ($entries as $e) { if (empty($e['url']) || !empty($e['noindex'])) continue; $have = citami_404_path_tokens((string) wp_parse_url((string) $e['url'], PHP_URL_PATH) . ' ' . (isset($e['title']) ? $e['title'] : '')); if (empty($have)) continue; $shared = count(array_intersect_key($want, $have)); if ($shared === 0) continue; $score = $shared / max(1, count($want)); if ($score > $best_score) { $best_score = $score; $best = (string) wp_parse_url((string) $e['url'], PHP_URL_PATH); } } return $best_score >= 0.34 ? $best : ''; } /** 404 entries that came from AI, most requested first, with a suggested target. */ function citami_404_ai_entries($limit = 20) { $log = get_option(CITAMI_404_LOG_OPT, array()); $out = array(); foreach ((array) $log as $path => $e) { if (!is_array($e) || empty($e['ai'])) continue; $from = isset($e['ai_from']) && is_array($e['ai_from']) ? $e['ai_from'] : array(); arsort($from); $out[] = array( 'path' => (string) $path, 'ai' => (int) $e['ai'], 'total' => (int) $e['count'], 'sources' => array_keys($from), 'last' => (int) $e['last'], 'suggested' => citami_404_suggest_target((string) $path), ); } usort($out, function ($a, $b) { return $b['ai'] <=> $a['ai'] ?: $b['last'] <=> $a['last']; }); return array_slice($out, 0, $limit); } // The snapshot carries the top invented URLs to the dashboard. add_filter('citami_health_extra', function ($extra) { $extra = is_array($extra) ? $extra : array(); $list = citami_404_ai_entries(10); $all = citami_404_ai_entries(1000); $extra['hallucinated'] = array('count' => count($all), 'hits' => array_sum(array_map(function ($e) { return $e['ai']; }, $all)), 'list' => $list); // 2.40 — the rule list as numbers only (no sources or targets travel). $rules = citami_redirects_all(); $active = 0; $hits = 0; foreach ($rules as $r) { if (!isset($r['active']) || $r['active'] !== false) { $active++; } $hits += isset($r['hits']) ? (int) $r['hits'] : 0; } $extra['redirects'] = array('count' => count($rules), 'active' => $active, 'hits' => $hits); return $extra; }); add_action('template_redirect', function () { if (is_admin() || !is_404() || empty($_SERVER['REQUEST_URI'])) { return; } citami_404_record(esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])), isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : '', isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''); }, PHP_INT_MAX); // last: a path that WordPress or an SEO plugin redirects is not a 404 for the visitor /* ---------------------------------------------------------------- */ /* Admin tab + handler */ /* ---------------------------------------------------------------- */ function citami_handle_redirect_action() { if (!current_user_can('manage_options')) { wp_die('Permesso negato.', '', array('response' => 403)); } if (!wp_verify_nonce(isset($_POST['_citami_redirect_nonce']) ? sanitize_text_field(wp_unslash($_POST['_citami_redirect_nonce'])) : '', 'citami_redirect')) { wp_die('Nonce non valido.', '', array('response' => 403)); } $do = isset($_POST['do']) ? sanitize_key((string) $_POST['do']) : ''; $notice = array('type' => 'success', 'message' => ''); if ($do === 'add') { $res = citami_redirect_add(array( 'source' => sanitize_text_field(wp_unslash((string) ($_POST['source'] ?? ''))), 'target' => sanitize_text_field(wp_unslash((string) ($_POST['target'] ?? ''))), 'match' => sanitize_key(wp_unslash($_POST['match'] ?? 'exact')), 'code' => absint(wp_unslash($_POST['code'] ?? 301)), )); if (is_wp_error($res)) { // 2.59 — the form comes back filled in: fix the value, don't retype it. set_transient('citami_action_notice', array('type' => 'error', 'message' => $res->get_error_message()), 30); wp_safe_redirect(citami_admin_url('redirects', array_filter(array( 'source' => sanitize_text_field(wp_unslash((string) ($_POST['source'] ?? ''))), 'target' => sanitize_text_field(wp_unslash((string) ($_POST['target'] ?? ''))), )))); exit; } else { $notice['message'] = $res['code'] === 410 || $res['code'] === 451 ? sprintf(citami_t('Redirect aggiunto: %1$s risponde %2$d.', 'Redirect added: %1$s answers %2$d.'), $res['source'], $res['code']) : sprintf(citami_t('Redirect aggiunto: %1$s → %2$s. Lo trovi in fondo alla tabella, dove puoi spegnerlo.', 'Redirect added: %1$s → %2$s. You find it at the bottom of the table, where you can switch it off.'), $res['source'], $res['target']); // A 404 turned into a redirect leaves the log. $log = get_option(CITAMI_404_LOG_OPT, array()); if (is_array($log) && isset($log[$res['source']])) { if (!empty($log[$res['source']]['ai'])) { do_action('citami_ai_404_redirected', $res['source']); // 2.34 — an invented URL recovered } unset($log[$res['source']]); update_option(CITAMI_404_LOG_OPT, $log, false); } } } elseif ($do === 'delete' && !empty($_POST['id'])) { citami_redirect_delete(sanitize_text_field(wp_unslash($_POST['id']))); $notice['message'] = citami_t('Redirect eliminato.', 'Redirect deleted.'); } elseif ($do === 'toggle' && !empty($_POST['id'])) { $id = sanitize_text_field(wp_unslash($_POST['id'])); foreach (citami_redirects_all() as $r) { if ($r['id'] === $id) { citami_redirect_update($id, array('active' => empty($r['active']))); } } $notice['message'] = citami_t('Redirect aggiornato.', 'Redirect updated.'); } elseif ($do === 'clear404') { delete_option(CITAMI_404_LOG_OPT); $notice['message'] = citami_t('Log 404 svuotato.', '404 log cleared.'); } set_transient('citami_action_notice', $notice, 30); wp_safe_redirect(admin_url('admin.php?page=citami-connector&tab=redirects')); exit; } add_action('admin_post_citami_redirect_action', 'citami_handle_redirect_action'); /** * "crawler:gptbot" / "referrer:chatgpt" → array(label, tooltip, is_visit). * Display only: the 404 table shows who asked as a short chip. */ function citami_404_source_label($src) { $src = (string) $src; $engines = array( 'chatgpt' => 'ChatGPT', 'perplexity' => 'Perplexity', 'gemini' => 'Gemini', 'copilot' => 'Copilot', 'claude' => 'Claude', 'you' => 'You.com', 'mistral' => 'Le Chat', 'meta' => 'Meta AI', 'poe' => 'Poe', 'duckassist' => 'DuckAssist', 'brave' => 'Brave', ); if (strpos($src, 'referrer:') === 0) { $k = substr($src, 9); $name = isset($engines[$k]) ? $engines[$k] : ucfirst($k); return array($name, sprintf(citami_t('Visitatore arrivato da %s', 'Visitor sent by %s'), $name), true); } $k = strpos($src, 'crawler:') === 0 ? substr($src, 8) : $src; $catalog = function_exists('citami_bots_catalog') ? citami_bots_catalog() : array(); $name = isset($catalog[strtolower($k)]['label']) ? $catalog[strtolower($k)]['label'] : $k; return array($name, sprintf(citami_t('Crawler AI %s', 'AI crawler %s'), $name), false); } function citami_render_redirects_tab() { $rules = citami_redirects_all(); $raw_log = get_option(CITAMI_404_LOG_OPT, array()); $raw_log = is_array($raw_log) ? $raw_log : array(); // Entries logged before the noise filter existed are hidden too. $log = array_filter($raw_log, function ($e, $path) { return is_array($e) && !citami_404_is_noise($path); }, ARRAY_FILTER_USE_BOTH); uasort($log, function ($a, $b) { return $b['count'] <=> $a['count']; }); $prefill = isset($_GET['source']) ? sanitize_text_field(wp_unslash((string) $_GET['source'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only: form prefill from the 404 log link $prefill_target = isset($_GET['target']) ? sanitize_text_field(wp_unslash((string) $_GET['target'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only: form prefill // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only: lift the 404 caps $all404 = !empty($_GET['all404']); $ai_entries = citami_404_ai_entries($all404 ? CITAMI_404_LOG_MAX * 5 : 20); // Every AI path, without the (slower) suggested target: only to count them. $ai_paths = array(); foreach ($raw_log as $path => $e) { if (is_array($e) && !empty($e['ai'])) $ai_paths[] = (string) $path; } // 2.52 — one table instead of two: the AI-invented URLs used to appear // in their own list AND again in the 404 log below it. Now every 404 is // one row, the AI ones first (with who asked and the likely page). $ai_by_path = array(); foreach ($ai_entries as $e) $ai_by_path[$e['path']] = $e; $rows = array(); foreach ($ai_entries as $e) $rows[] = array('path' => $e['path'], 'e' => isset($log[$e['path']]) ? $log[$e['path']] : array('count' => $e['total'], 'last' => $e['last'], 'referrer' => '', 'bot' => ''), 'ai' => $e); foreach (($all404 ? $log : array_slice($log, 0, 100, true)) as $path => $e) { if (isset($ai_by_path[$path])) continue; $rows[] = array('path' => (string) $path, 'e' => $e, 'ai' => null); } // 2.60 — say when the table is cut: every distinct 404 path, AI or not. $total404 = count(array_unique(array_merge(array_map('strval', array_keys($log)), $ai_paths))); $active = 0; $hits = 0; foreach ($rules as $r) { if (!empty($r['active'])) $active++; $hits += isset($r['hits']) ? (int) $r['hits'] : 0; } $match_labels = array( 'exact' => citami_t('esatto', 'exact'), 'start' => citami_t('inizia con', 'starts with'), 'contains' => citami_t('contiene', 'contains'), 'end' => citami_t('finisce con', 'ends with'), 'regex' => 'regex', ); $form_open = function ($do, $extra = '', $class = 'citami-inline-form') { return '
' . wp_nonce_field('citami_redirect', '_citami_redirect_nonce', true, false) . '' . $extra; }; ?>

HTTPHit
() ' . esc_html($r['target']) . '' : '—'; ?> '), citami_kses_allowed()); ?> '), citami_kses_allowed()); ?>
0): ?>

count($rows)): ?>

0): ?>

>
0): ?> × — ' . esc_html($ai['suggested']) . '' : '—'; ?>