/** * Snorklee — Beacon crawler IA (middleware Express réutilisable). * * Les crawlers IA récupèrent le HTML sans exécuter w.js : invisibles côté * client. Ce middleware détecte le crawler sur le User-Agent de la requête de * page et signale la visite au dashboard via un POST non bloquant. next() est * appelé immédiatement : la réponse au crawler n'est jamais retardée. * * Usage : * const snorkleeCrawlerBeacon = require('./node-express'); * app.use(snorkleeCrawlerBeacon({ * siteDomain: 'example.com', // = attribut data-site de w.js * dashboardUrl: 'https://snorklee.com', // défaut * })); * // À monter AVANT les routes de pages. Node 18+ (fetch global) requis ; * // fallback https natif si fetch absent. * * Patterns : miroir strict de ai-crawlers.json (source de vérité). */ const https = require("https"); const http = require("http"); const { URL } = require("url"); // 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"); function isAsset(path) { return /\.[a-z0-9]{1,8}$/i.test(path); } // IP du robot. Derrière un reverse proxy, `socket.remoteAddress` est l'adresse // du proxy — souvent PRIVÉE. Snorklee écarte les IP privées (elles ne prouvent // rien), le hit retombe alors en « déclaré » au lieu de « vérifié ». On prend // donc la PREMIÈRE valeur de X-Forwarded-For (le client d'origine) et on ne // revient au socket que s'il porte une adresse publique. const PRIVATE_IP_RE = /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|f[cd])/i; function crawlerIp(xff, remoteAddress) { const clean = (v) => String(v || "").trim().replace(/^::ffff:/i, ""); if (xff) { const first = clean(String(xff).split(",")[0]); if (first && !PRIVATE_IP_RE.test(first)) return first; } const direct = clean(remoteAddress); if (direct && !PRIVATE_IP_RE.test(direct)) return direct; return ""; } // Échec d'envoi : toujours non bloquant, mais JAMAIS muet — un beacon qui // poste dans le vide (URL morte, DNS, 4xx/5xx) doit se voir dans les logs de // l'app hôte. Throttle 1 warn/min pour ne pas inonder. let _lastWarnAt = 0; function warnBeaconFailure(endpoint, reason) { const now = Date.now(); if (now - _lastWarnAt < 60000) return; _lastWarnAt = now; console.warn(`[snorklee-beacon] POST ${endpoint} failed: ${reason}`); } function postBeacon(endpoint, headers, body) { // Préfère fetch global (Node 18+), fallback https/http. if (typeof fetch === "function") { fetch(endpoint, { method: "POST", headers, body, signal: AbortSignal.timeout(2000), }) .then((res) => { if (!res.ok) warnBeaconFailure(endpoint, `HTTP ${res.status}`); }) .catch((err) => warnBeaconFailure(endpoint, err && err.message)); return; } try { const u = new URL(endpoint); const lib = u.protocol === "http:" ? http : https; const req = lib.request( { method: "POST", hostname: u.hostname, port: u.port || (u.protocol === "http:" ? 80 : 443), path: u.pathname + u.search, headers: { ...headers, "Content-Length": Buffer.byteLength(body) }, timeout: 2000, }, (res) => { if (res.statusCode >= 400) warnBeaconFailure(endpoint, `HTTP ${res.statusCode}`); res.resume(); } ); req.on("error", (err) => warnBeaconFailure(endpoint, err && err.message)); req.on("timeout", () => { warnBeaconFailure(endpoint, "timeout"); req.destroy(); }); req.end(body); } catch (err) { warnBeaconFailure(endpoint, err && err.message); } } module.exports = function snorkleeCrawlerBeacon(opts) { const options = opts || {}; const siteDomain = options.siteDomain || "example.com"; const dashboardUrl = options.dashboardUrl || "https://snorklee.com"; const endpoint = `${dashboardUrl}/api/event`; return function (req, res, next) { try { const ua = req.headers["user-agent"] || ""; const path = (req.originalUrl || req.url || "/").split("?")[0]; if ( req.method === "GET" && ua && AI_CRAWLER_RE.test(ua) && !isAsset(path) ) { const headers = { "Content-Type": "application/json", "User-Agent": ua, "X-Snorklee-Server": "node-express/1", }; const ip = crawlerIp( req.headers["x-forwarded-for"], req.socket && req.socket.remoteAddress, ); 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 et retombait en « déclaré ». Les deux sont envoyés — les // installations qui n'ont pas encore mis à jour ce fichier // continuent de fonctionner via X-Forwarded-For. headers["X-Snorklee-Crawler-IP"] = ip; headers["X-Forwarded-For"] = ip; } const body = JSON.stringify({ s: siteDomain, p: path.slice(0, 2048), t: "pageview", }); postBeacon(endpoint, headers, body); } } catch { // Non bloquant : un échec du beacon ne doit jamais affecter la requête. } return next(); }; };