/** * Snorklee — Beacon crawler IA (Cloudflare Worker). * * Les crawlers IA (GPTBot, ClaudeBot…) récupèrent le HTML sans exécuter le * tracker w.js : ils sont invisibles côté client. Ce Worker se place devant * l'origine, sert la réponse normalement et — en parallèle, sans retarder le * crawler (ctx.waitUntil) — signale la visite au dashboard quand le User-Agent * correspond à un crawler IA. Le backend reconnaît le crawler via son UA. * * Configuration : * - SITE_DOMAIN : doit être IDENTIQUE à l'attribut data-site de w.js. * - DASHBOARD_URL : URL du dashboard (défaut https://snorklee.com). * Surchageables par variables d'environnement Worker (env.SNORKLEE_SITE_DOMAIN / * env.SNORKLEE_DASHBOARD_URL) ; sinon les constantes ci-dessous s'appliquent. * * Patterns : miroir strict de ai-crawlers.json (source de vérité). */ const SITE_DOMAIN = "example.com"; const DASHBOARD_URL = "https://snorklee.com"; // Miroir strict de ai-crawlers.json (même ordre). Googlebot exclu volontairement. const AI_CRAWLER_PATTERNS = [ "GPTBot", "ChatGPT-User", "OAI-SearchBot", "ClaudeBot|anthropic-ai|Claude-Web|Claude-User|Claude-SearchBot", "PerplexityBot|Perplexity-User", "Google-Extended", "Applebot-Extended", "cohere-ai", "Bytespider", "CCBot", "FacebookBot|Meta-ExternalAgent", "MistralAI|Mistralbot", "YouBot", "DuckAssistBot", "Amazonbot", "PetalBot", "DeepSeekBot", "Grok(?:bot|-User)", "Diffbot", ]; const AI_CRAWLER_RE = new RegExp(AI_CRAWLER_PATTERNS.join("|"), "i"); // IP du robot. Derrière un proxy, l'adresse vue est celle du proxy — souvent // PRIVÉE. Snorklee écarte les IP privées (elles ne prouvent rien) et le hit // retombe en « déclaré » au lieu de « vérifié ». On prend donc la PREMIÈRE // valeur de X-Forwarded-For et on refuse toute adresse privée. const PRIVATE_IP_RE = /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|f[cd])/i; function crawlerIp(...candidates) { for (const c of candidates) { if (!c) continue; const first = String(c).split(",")[0].trim().replace(/^::ffff:/i, ""); if (first && !PRIVATE_IP_RE.test(first)) return first; } return ""; } function isPageRequest(request) { if (request.method !== "GET") return false; const path = new URL(request.url).pathname; // Exclut les assets statiques courants : on ne compte que des pages. return !/\.[a-z0-9]{1,8}$/i.test(path); } async function sendBeacon(request, ua, siteDomain, dashboardUrl) { try { const path = new URL(request.url).pathname.slice(0, 2048); const headers = { "Content-Type": "application/json", "User-Agent": ua, "X-Snorklee-Server": "cloudflare/1", }; const ip = crawlerIp( request.headers.get("cf-connecting-ip"), request.headers.get("x-forwarded-for"), ); if (ip) { // En-tête DÉDIÉ : X-Forwarded-For est réécrit par chaque proxy traversé // entre le site et Snorklee, ce qui faisait perdre l'IP du robot. Les // deux sont envoyés pour rester compatible avec les installations qui // n'ont pas encore mis à jour ce fichier. headers["X-Snorklee-Crawler-IP"] = ip; headers["X-Forwarded-For"] = ip; } await fetch(`${dashboardUrl}/api/event`, { method: "POST", headers, body: JSON.stringify({ s: siteDomain, p: path, t: "pageview" }), signal: AbortSignal.timeout(2000), }); } catch { // Non bloquant : un échec du beacon ne doit jamais affecter le crawler. } } export default { async fetch(request, env, ctx) { const response = await fetch(request); const siteDomain = (env && env.SNORKLEE_SITE_DOMAIN) || SITE_DOMAIN; const dashboardUrl = (env && env.SNORKLEE_DASHBOARD_URL) || DASHBOARD_URL; const ua = request.headers.get("user-agent") || ""; if (ua && AI_CRAWLER_RE.test(ua) && isPageRequest(request)) { ctx.waitUntil(sendBeacon(request, ua, siteDomain, dashboardUrl)); } return response; }, };