/** * Snorklee — Beacon crawler IA (Next.js middleware, runtime Edge). * * Les crawlers IA récupèrent le HTML sans exécuter w.js : invisibles côté * client. Ce middleware s'exécute sur l'Edge avant le rendu, laisse passer la * requête (NextResponse.next()) et — sans bloquer — signale la visite au * dashboard quand le User-Agent correspond à un crawler IA. * * Installation : déposer ce fichier en `middleware.js` (ou `.ts`) à la racine * du projet Next.js. * * 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 process.env.SNORKLEE_SITE_DOMAIN / SNORKLEE_DASHBOARD_URL. * * Patterns : miroir strict de ai-crawlers.json (source de vérité). */ import { NextResponse } from "next/server"; const SITE_DOMAIN = process.env.SNORKLEE_SITE_DOMAIN || "example.com"; const DASHBOARD_URL = process.env.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 ""; } export function middleware(request) { const ua = request.headers.get("user-agent") || ""; if (ua && AI_CRAWLER_RE.test(ua)) { try { const headers = { "Content-Type": "application/json", "User-Agent": ua, "X-Snorklee-Server": "vercel/1", }; const ip = crawlerIp( request.headers.get("x-real-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 = request.nextUrl.pathname.slice(0, 2048); // Fire-and-forget : on n'attend pas la promesse, mais on attrape le rejet // pour ne jamais laisser fuiter une erreur (la réponse n'est pas retardée). 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 NextResponse.next(); } // Pages uniquement : exclut les assets statiques, _next et l'API. export const config = { matcher: [ "/((?!api|_next/static|_next/image|favicon.ico|.*\\.[a-z0-9]+$).*)", ], };