/** * Snorklee — Beacon crawler IA (Netlify Edge Function). * * Les crawlers IA récupèrent le HTML sans exécuter w.js : invisibles côté * client. Cette Edge Function laisse la réponse origine passer (context.next()) * et — sans bloquer — signale la visite au dashboard quand le User-Agent * correspond à un crawler IA. * * Installation : déposer ce fichier dans `netlify/edge-functions/` et le câbler * via netlify.toml ou l'export `config` ci-dessous (path "/*"). * * Configuration : * - SITE_DOMAIN : doit être IDENTIQUE à l'attribut data-site de w.js. * - DASHBOARD_URL : URL du dashboard (défaut https://snorklee.com). * Surchargeables via Netlify.env (SNORKLEE_SITE_DOMAIN / SNORKLEE_DASHBOARD_URL). * * Patterns : miroir strict de ai-crawlers.json (source de vérité). */ /* global Netlify */ const SITE_DOMAIN = Netlify.env.get("SNORKLEE_SITE_DOMAIN") || "example.com"; const DASHBOARD_URL = Netlify.env.get("SNORKLEE_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; return !/\.[a-z0-9]{1,8}$/i.test(path); } export default async (request, context) => { const ua = request.headers.get("user-agent") || ""; if (ua && AI_CRAWLER_RE.test(ua) && isPageRequest(request)) { try { const headers = { "Content-Type": "application/json", "User-Agent": ua, "X-Snorklee-Server": "netlify/1", }; const ip = crawlerIp( request.headers.get("x-nf-client-connection-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; } const path = new URL(request.url).pathname.slice(0, 2048); // Fire-and-forget : promesse non attendue mais attrapée. La réponse // origine est servie immédiatement via context.next(). fetch(`${DASHBOARD_URL}/api/event`, { method: "POST", headers, body: JSON.stringify({ s: SITE_DOMAIN, p: path, t: "pageview" }), signal: AbortSignal.timeout(2000), }).catch(() => {}); } catch { // Non bloquant : un échec du beacon ne doit jamais affecter le crawler. } } return context.next(); }; export const config = { path: "/*" };