@apideck/agent-analytics 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -0
- package/dist/adapters/posthog.cjs +2 -30
- package/dist/adapters/posthog.cjs.map +1 -1
- package/dist/adapters/posthog.d.cts +7 -1
- package/dist/adapters/posthog.d.ts +7 -1
- package/dist/adapters/posthog.js +2 -28
- package/dist/adapters/posthog.js.map +1 -1
- package/dist/adapters/webhook.cjs +1 -23
- package/dist/adapters/webhook.cjs.map +1 -1
- package/dist/adapters/webhook.d.cts +3 -1
- package/dist/adapters/webhook.d.ts +3 -1
- package/dist/adapters/webhook.js +1 -21
- package/dist/adapters/webhook.js.map +1 -1
- package/dist/index.cjs +2 -302
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -10
- package/dist/index.d.ts +45 -10
- package/dist/index.js +2 -287
- package/dist/index.js.map +1 -1
- package/dist/markdown.cjs +2 -67
- package/dist/markdown.cjs.map +1 -1
- package/dist/markdown.js +2 -63
- package/dist/markdown.js.map +1 -1
- package/dist/{types-B7jSKtLz.d.cts → types-sQoQK-ox.d.cts} +48 -1
- package/dist/{types-B7jSKtLz.d.ts → types-sQoQK-ox.d.ts} +48 -1
- package/dist/verify.cjs +3 -0
- package/dist/verify.cjs.map +1 -0
- package/dist/verify.d.cts +50 -0
- package/dist/verify.d.ts +50 -0
- package/dist/verify.js +3 -0
- package/dist/verify.js.map +1 -0
- package/package.json +7 -2
package/dist/markdown.cjs
CHANGED
|
@@ -1,68 +1,3 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// src/bots.ts
|
|
4
|
-
var AI_BOT_PATTERN = /ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|Anthropic|GPTBot|ChatGPT-User|OAI-SearchBot|PerplexityBot|Perplexity-User|Google-Extended|Google-CloudVertexBot|Google-Agent|GoogleAgent-Mariner|Gemini-Deep-Research|Applebot|cohere|Bytespider|CCBot|Amazonbot|Amzn-SearchBot|NovaAct|AzureAI-SearchBot|Meta-ExternalAgent|meta-externalfetcher|meta-webindexer|FacebookBot|DuckAssistBot|MistralAI-User|YouBot|AI2Bot|Diffbot|DeepSeek|PanguBot|Webzio-Extended|omgili|Timpibot|Grok|Manus-User|quillbot|MyCentralAIScraperBot|Cursor|Windsurf/i;
|
|
5
|
-
function isAiBot(userAgent) {
|
|
6
|
-
if (!userAgent) return false;
|
|
7
|
-
return AI_BOT_PATTERN.test(userAgent);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// src/markdown.ts
|
|
11
|
-
function markdownServeDecision(req) {
|
|
12
|
-
let pathname = "/";
|
|
13
|
-
try {
|
|
14
|
-
pathname = new URL(req.url).pathname;
|
|
15
|
-
} catch {
|
|
16
|
-
pathname = req.url || "/";
|
|
17
|
-
}
|
|
18
|
-
const ua = req.headers.get("user-agent") || "";
|
|
19
|
-
if (isAiBot(ua)) {
|
|
20
|
-
return { reason: "ua-rewrite", strippedPath: pathname };
|
|
21
|
-
}
|
|
22
|
-
if (pathname.endsWith(".md")) {
|
|
23
|
-
return { reason: "md-suffix", strippedPath: pathname.replace(/\.md$/, "") };
|
|
24
|
-
}
|
|
25
|
-
const accept = req.headers.get("accept") || "";
|
|
26
|
-
if (accept.includes("text/markdown")) {
|
|
27
|
-
return { reason: "accept-header", strippedPath: pathname };
|
|
28
|
-
}
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
function markdownHeaders(input = {}) {
|
|
32
|
-
const headers = {
|
|
33
|
-
"Content-Type": "text/markdown; charset=utf-8",
|
|
34
|
-
"Content-Signal": input.contentSignal ?? "search=yes, ai-input=yes, ai-train=no",
|
|
35
|
-
Vary: "accept"
|
|
36
|
-
};
|
|
37
|
-
if (typeof input.tokens === "number" && input.tokens > 0) {
|
|
38
|
-
headers["x-markdown-tokens"] = Math.max(1, Math.ceil(input.tokens)).toString();
|
|
39
|
-
}
|
|
40
|
-
return headers;
|
|
41
|
-
}
|
|
42
|
-
function synthesizeMarkdownPointer(input) {
|
|
43
|
-
const site = input.siteName ?? (() => {
|
|
44
|
-
try {
|
|
45
|
-
return new URL(input.origin).hostname;
|
|
46
|
-
} catch {
|
|
47
|
-
return input.origin;
|
|
48
|
-
}
|
|
49
|
-
})();
|
|
50
|
-
const url = `${input.origin}${input.pathname}`;
|
|
51
|
-
const lines = [`# ${site}`, "", `This page (${url}) does not have a dedicated Markdown mirror yet.`, ""];
|
|
52
|
-
const links = [];
|
|
53
|
-
if (input.llmsTxtUrl) links.push(`- [${input.llmsTxtUrl}](${input.llmsTxtUrl}) \u2014 curated index of docs`);
|
|
54
|
-
if (input.llmsFullTxtUrl)
|
|
55
|
-
links.push(`- [${input.llmsFullTxtUrl}](${input.llmsFullTxtUrl}) \u2014 full enumerated index`);
|
|
56
|
-
if (input.markdownIndexUrl)
|
|
57
|
-
links.push(`- [${input.markdownIndexUrl}](${input.markdownIndexUrl}) \u2014 JSON index of all Markdown paths`);
|
|
58
|
-
if (links.length) {
|
|
59
|
-
lines.push("For machine-readable documentation, see:", "", ...links, "");
|
|
60
|
-
}
|
|
61
|
-
return lines.join("\n");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
exports.markdownHeaders = markdownHeaders;
|
|
65
|
-
exports.markdownServeDecision = markdownServeDecision;
|
|
66
|
-
exports.synthesizeMarkdownPointer = synthesizeMarkdownPointer;
|
|
67
|
-
//# sourceMappingURL=markdown.cjs.map
|
|
1
|
+
'use strict';var o=/ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|Anthropic|GPTBot|ChatGPT-User|OAI-SearchBot|PerplexityBot|Perplexity-User|Google-Extended|Google-CloudVertexBot|Google-Agent|GoogleAgent-Mariner|Gemini-Deep-Research|Applebot|cohere|Bytespider|CCBot|Amazonbot|Amzn-SearchBot|NovaAct|AzureAI-SearchBot|Meta-ExternalAgent|meta-externalfetcher|meta-webindexer|FacebookBot|DuckAssistBot|MistralAI-User|YouBot|AI2Bot|Diffbot|DeepSeek|PanguBot|Webzio-Extended|omgili|Timpibot|Grok|Manus-User|quillbot|MyCentralAIScraperBot|Cursor|Windsurf/i;function s(e){return e?o.test(e):false}function c(e){let t="/";try{t=new URL(e.url).pathname;}catch{t=e.url||"/";}let i=t.endsWith(".md"),r=i?t.slice(0,-3):t,n=e.headers.get("user-agent")||"";return s(n)?{reason:i?"md-suffix":"ua-rewrite",strippedPath:r}:i?{reason:"md-suffix",strippedPath:r}:(e.headers.get("accept")||"").includes("text/markdown")?{reason:"accept-header",strippedPath:r}:null}function d(e={}){let t={"Content-Type":"text/markdown; charset=utf-8","Content-Signal":e.contentSignal??"search=yes, ai-input=yes, ai-train=no",Vary:"accept"};return typeof e.tokens=="number"&&e.tokens>0&&(t["x-markdown-tokens"]=Math.max(1,Math.ceil(e.tokens)).toString()),t}function f(e){let t=e.siteName??(()=>{try{return new URL(e.origin).hostname}catch{return e.origin}})(),i=`${e.origin}${e.pathname}`,r=[`# ${t}`,"",`This page (${i}) does not have a dedicated Markdown mirror yet.`,""],n=[];return e.llmsTxtUrl&&n.push(`- [${e.llmsTxtUrl}](${e.llmsTxtUrl}) \u2014 curated index of docs`),e.llmsFullTxtUrl&&n.push(`- [${e.llmsFullTxtUrl}](${e.llmsFullTxtUrl}) \u2014 full enumerated index`),e.markdownIndexUrl&&n.push(`- [${e.markdownIndexUrl}](${e.markdownIndexUrl}) \u2014 JSON index of all Markdown paths`),n.length&&r.push("For machine-readable documentation, see:","",...n,""),r.join(`
|
|
2
|
+
`)}exports.markdownHeaders=d;exports.markdownServeDecision=c;exports.synthesizeMarkdownPointer=f;//# sourceMappingURL=markdown.cjs.map
|
|
68
3
|
//# sourceMappingURL=markdown.cjs.map
|
package/dist/markdown.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/bots.ts","../src/markdown.ts"],"names":[],"mappings":";;;AAgBO,IAAM,cAAA,GACX,shBAAA;AAsBK,SAAS,QAAQ,SAAA,EAA+C;AACrE,EAAA,IAAI,CAAC,WAAW,OAAO,KAAA;AACvB,EAAA,OAAO,cAAA,CAAe,KAAK,SAAS,CAAA;AACtC;;;ACZO,SAAS,sBAAsB,GAAA,EAAuC;AAC3E,EAAA,IAAI,QAAA,GAAW,GAAA;AACf,EAAA,IAAI;AACF,IAAA,QAAA,GAAW,IAAI,GAAA,CAAI,GAAA,CAAI,GAAG,CAAA,CAAE,QAAA;AAAA,EAC9B,CAAA,CAAA,MAAQ;AACN,IAAA,QAAA,GAAW,IAAI,GAAA,IAAO,GAAA;AAAA,EACxB;AAEA,EAAA,MAAM,EAAA,GAAK,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,YAAY,CAAA,IAAK,EAAA;AAC5C,EAAA,IAAI,OAAA,CAAQ,EAAE,CAAA,EAAG;AACf,IAAA,OAAO,EAAE,MAAA,EAAQ,YAAA,EAAc,YAAA,EAAc,QAAA,EAAS;AAAA,EACxD;AAEA,EAAA,IAAI,QAAA,CAAS,QAAA,CAAS,KAAK,CAAA,EAAG;AAC5B,IAAA,OAAO,EAAE,QAAQ,WAAA,EAAa,YAAA,EAAc,SAAS,OAAA,CAAQ,OAAA,EAAS,EAAE,CAAA,EAAE;AAAA,EAC5E;AAEA,EAAA,MAAM,MAAA,GAAS,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,QAAQ,CAAA,IAAK,EAAA;AAC5C,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,eAAe,CAAA,EAAG;AACpC,IAAA,OAAO,EAAE,MAAA,EAAQ,eAAA,EAAiB,YAAA,EAAc,QAAA,EAAS;AAAA,EAC3D;AAEA,EAAA,OAAO,IAAA;AACT;AAqBO,SAAS,eAAA,CAAgB,KAAA,GAA8B,EAAC,EAA2B;AACxF,EAAA,MAAM,OAAA,GAAkC;AAAA,IACtC,cAAA,EAAgB,8BAAA;AAAA,IAChB,gBAAA,EAAkB,MAAM,aAAA,IAAiB,uCAAA;AAAA,IACzC,IAAA,EAAM;AAAA,GACR;AACA,EAAA,IAAI,OAAO,KAAA,CAAM,MAAA,KAAW,QAAA,IAAY,KAAA,CAAM,SAAS,CAAA,EAAG;AACxD,IAAA,OAAA,CAAQ,mBAAmB,CAAA,GAAI,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,IAAA,CAAK,KAAA,CAAM,MAAM,CAAC,CAAA,CAAE,QAAA,EAAS;AAAA,EAC/E;AACA,EAAA,OAAO,OAAA;AACT;AAoBO,SAAS,0BAA0B,KAAA,EAAuC;AAC/E,EAAA,MAAM,IAAA,GACJ,KAAA,CAAM,QAAA,IAAA,CACL,MAAM;AACL,IAAA,IAAI;AACF,MAAA,OAAO,IAAI,GAAA,CAAI,KAAA,CAAM,MAAM,CAAA,CAAE,QAAA;AAAA,IAC/B,CAAA,CAAA,MAAQ;AACN,MAAA,OAAO,KAAA,CAAM,MAAA;AAAA,IACf;AAAA,EACF,CAAA,GAAG;AACL,EAAA,MAAM,MAAM,CAAA,EAAG,KAAA,CAAM,MAAM,CAAA,EAAG,MAAM,QAAQ,CAAA,CAAA;AAC5C,EAAA,MAAM,KAAA,GAAkB,CAAC,CAAA,EAAA,EAAK,IAAI,IAAI,EAAA,EAAI,CAAA,WAAA,EAAc,GAAG,CAAA,gDAAA,CAAA,EAAoD,EAAE,CAAA;AACjH,EAAA,MAAM,QAAkB,EAAC;AACzB,EAAA,IAAI,KAAA,CAAM,UAAA,EAAY,KAAA,CAAM,IAAA,CAAK,CAAA,GAAA,EAAM,MAAM,UAAU,CAAA,EAAA,EAAK,KAAA,CAAM,UAAU,CAAA,8BAAA,CAA2B,CAAA;AACvG,EAAA,IAAI,KAAA,CAAM,cAAA;AACR,IAAA,KAAA,CAAM,KAAK,CAAA,GAAA,EAAM,KAAA,CAAM,cAAc,CAAA,EAAA,EAAK,KAAA,CAAM,cAAc,CAAA,8BAAA,CAA2B,CAAA;AAC3F,EAAA,IAAI,KAAA,CAAM,gBAAA;AACR,IAAA,KAAA,CAAM,KAAK,CAAA,GAAA,EAAM,KAAA,CAAM,gBAAgB,CAAA,EAAA,EAAK,KAAA,CAAM,gBAAgB,CAAA,yCAAA,CAAsC,CAAA;AAC1G,EAAA,IAAI,MAAM,MAAA,EAAQ;AAChB,IAAA,KAAA,CAAM,IAAA,CAAK,0CAAA,EAA4C,EAAA,EAAI,GAAG,OAAO,EAAE,CAAA;AAAA,EACzE;AACA,EAAA,OAAO,KAAA,CAAM,KAAK,IAAI,CAAA;AACxB","file":"markdown.cjs","sourcesContent":["/**\n * User-agent substrings that identify **publicly declared** AI crawlers — the\n * branded bots that identify themselves by name (OpenAI's GPTBot, Anthropic's\n * ClaudeBot, Perplexity-User, Google-Extended, etc.). High-confidence: when\n * this matches, the request almost certainly comes from that vendor's crawler\n * fleet.\n *\n * Does NOT include **coding-agent traffic** (Claude Code, Cline, Cursor,\n * Windsurf, Aider, OpenCode, VS Code). Those tools use generic HTTP library\n * UAs (axios, curl, got, colly, Electron) or spoof full browser UAs — they\n * can't be distinguished from non-AI traffic by UA alone. See\n * {@link HTTP_CLIENT_PATTERN} for the loose heuristic layer.\n *\n * Sources consulted when updating: darkvisitors.com, vendor docs from OpenAI,\n * Anthropic, Google, Perplexity, Cohere, Apple, Bytedance.\n */\nexport const AI_BOT_PATTERN =\n /ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|Anthropic|GPTBot|ChatGPT-User|OAI-SearchBot|PerplexityBot|Perplexity-User|Google-Extended|Google-CloudVertexBot|Google-Agent|GoogleAgent-Mariner|Gemini-Deep-Research|Applebot|cohere|Bytespider|CCBot|Amazonbot|Amzn-SearchBot|NovaAct|AzureAI-SearchBot|Meta-ExternalAgent|meta-externalfetcher|meta-webindexer|FacebookBot|DuckAssistBot|MistralAI-User|YouBot|AI2Bot|Diffbot|DeepSeek|PanguBot|Webzio-Extended|omgili|Timpibot|Grok|Manus-User|quillbot|MyCentralAIScraperBot|Cursor|Windsurf/i\n\n/**\n * HTTP library / runtime signatures frequently used by coding agents. Matching\n * any of these is a **loose** signal — legitimate curl scripts, CI jobs, and\n * server-to-server traffic use the same libraries. Use this for the wider\n * net (`coding_agent_hint: true`) and pair with other signals (request\n * shape, JA4 fingerprint, path patterns) for higher confidence.\n *\n * Based on behavioural signatures observed by Addy Osmani:\n * Claude Code → axios/1.8.4\n * Cline, Junie → curl/8.4.0\n * Cursor → got (sindresorhus/got)\n * Windsurf → colly\n * VS Code → Electron / Chromium\n *\n * Aider and OpenCode use Playwright-driven full Mozilla/Safari UAs and are\n * indistinguishable from real browsers at the UA layer.\n */\nexport const HTTP_CLIENT_PATTERN =\n /axios\\/|curl\\/|(?:^|[\\s(])got(?:\\/|[\\s(])|\\bcolly\\b|Electron\\/|node-fetch\\/|python-requests\\/|Go-http-client\\/|okhttp\\/|aiohttp\\/|Deno\\//i\n\nexport function isAiBot(userAgent: string | null | undefined): boolean {\n if (!userAgent) return false\n return AI_BOT_PATTERN.test(userAgent)\n}\n\nexport function isHttpClient(userAgent: string | null | undefined): boolean {\n if (!userAgent) return false\n return HTTP_CLIENT_PATTERN.test(userAgent)\n}\n\n/**\n * Map a user-agent string to a coarse, human-readable label. Returns one of:\n *\n * - A branded-crawler name (`'Claude'`, `'ChatGPT'`, …) — pair with\n * {@link isAiBot} for `is_ai_bot: true` segmentation.\n * - An HTTP-library name (`'curl'`, `'axios'`, `'got'`, `'colly'`,\n * `'Electron'`, …) — hint of a coding agent or automation; not\n * conclusive. Pair with {@link isHttpClient}.\n * - `'Browser'` for typical desktop browsers (possibly spoofed by\n * Playwright-based agents like Aider/OpenCode — this label alone can't\n * tell you).\n * - `'Other'` for anything unrecognised or empty input.\n */\nexport function parseBotName(userAgent: string | null | undefined): string {\n if (!userAgent || typeof userAgent !== 'string') return 'Other'\n const s = userAgent.toLowerCase()\n\n // Publicly declared AI crawlers (high confidence).\n if (s.includes('chatgpt-user') || s.includes('gptbot') || s.includes('oai-searchbot') || s.includes('openai'))\n return 'ChatGPT'\n if (\n s.includes('claudebot') ||\n s.includes('claude-user') ||\n s.includes('claude-searchbot') ||\n s.includes('claude-web') ||\n s.includes('anthropic')\n )\n return 'Claude'\n if (s.includes('perplexitybot') || s.includes('perplexity-user')) return 'Perplexity'\n if (s.includes('ccbot')) return 'Common Crawl'\n if (\n s.includes('google-extended') ||\n s.includes('googlebot') ||\n s.includes('google-cloudvertexbot') ||\n s.includes('google-agent') ||\n s.includes('googleagent-mariner') ||\n s.includes('gemini-deep-research')\n )\n return 'Google'\n if (s.includes('applebot')) return 'Apple'\n if (s.includes('bingbot')) return 'Bing'\n if (s.includes('bytespider')) return 'Bytespider'\n if (s.includes('amazonbot') || s.includes('amzn-searchbot') || s.includes('novaact')) return 'Amazon'\n if (\n s.includes('meta-externalagent') ||\n s.includes('meta-externalfetcher') ||\n s.includes('meta-webindexer') ||\n s.includes('facebookbot')\n )\n return 'Meta'\n if (s.includes('mistralai-user')) return 'Mistral'\n if (s.includes('duckassistbot')) return 'DuckDuckGo'\n if (s.includes('youbot')) return 'You.com'\n if (s.includes('diffbot')) return 'Diffbot'\n if (s.includes('ai2bot')) return 'AI2'\n if (s.includes('cohere')) return 'Cohere'\n if (s.includes('cursor')) return 'Cursor'\n if (s.includes('windsurf')) return 'Windsurf'\n if (s.includes('deepseek')) return 'DeepSeek'\n if (s.includes('pangubot')) return 'Huawei'\n if (s.includes('webzio') || s.includes('omgili')) return 'Webz.io'\n if (s.includes('timpibot')) return 'Timpi'\n if (s.includes('grok') || s.includes('xai-')) return 'xAI'\n if (s.includes('manus-user')) return 'Manus'\n if (s.includes('quillbot')) return 'QuillBot'\n if (s.includes('azureai-searchbot')) return 'Microsoft'\n if (s.includes('mycentralaiscraperbot')) return 'MyCentralAI'\n if (s.includes('petalbot')) return 'PetalBot'\n\n // SEO crawlers and monitoring bots.\n if (s.includes('ahrefsbot')) return 'Ahrefs'\n if (s.includes('semrushbot')) return 'Semrush'\n if (s.includes('mj12bot')) return 'Majestic'\n if (s.includes('dotbot')) return 'Moz'\n if (s.includes('rogerbot')) return 'Moz'\n if (s.includes('screaming frog')) return 'Screaming Frog'\n if (s.includes('sitebulb')) return 'Sitebulb'\n if (s.includes('linkfluence')) return 'Linkfluence'\n if (s.includes('dataforseo')) return 'DataForSEO'\n if (s.includes('serpstatbot')) return 'Serpstat'\n\n // Monitoring and feed bots.\n if (s.includes('uptimerobot')) return 'UptimeRobot'\n if (s.includes('pingdom')) return 'Pingdom'\n if (s.includes('statuscake')) return 'StatusCake'\n if (s.includes('newrelicpinger')) return 'New Relic'\n if (s.includes('datadogagent') || s.includes('datadog')) return 'Datadog'\n if (s.includes('slackbot')) return 'Slack'\n if (s.includes('twitterbot')) return 'Twitter'\n if (s.includes('linkedinbot')) return 'LinkedIn'\n if (s.includes('discordbot')) return 'Discord'\n if (s.includes('telegrambot')) return 'Telegram'\n if (s.includes('whatsapp')) return 'WhatsApp'\n\n // AI search and indexing bots.\n if (s.includes('linkupbot')) return 'Linkup'\n if (s.includes('sogou')) return 'Sogou'\n if (s.includes('yandexbot')) return 'Yandex'\n if (s.includes('baiduspider')) return 'Baidu'\n\n // Link preview fetchers.\n if (s.includes('facebookexternalhit')) return 'Facebook'\n if (s.includes('com.apple.webkit')) return 'Apple URL Preview'\n\n // Uptime and monitoring.\n if (s.includes('ohdear')) return 'Oh Dear'\n\n // Generic scrapers.\n if (s.includes('scrapy')) return 'Scrapy'\n if (s.includes('headlesschrome')) return 'Headless Chrome'\n if (s.includes('phantomjs')) return 'PhantomJS'\n if (s.includes('wget')) return 'wget'\n if (s.includes('httpie')) return 'HTTPie'\n if (s.includes('guzzlehttp')) return 'Guzzle'\n\n // HTTP library / runtime signatures (loose — coding agent or automation).\n // Check Electron before Browser since Electron UAs contain Chrome/Safari.\n if (s.includes('electron/')) return 'Electron'\n if (/curl\\//.test(s)) return 'curl'\n if (/axios\\//.test(s)) return 'axios'\n if (/(?:^|[\\s(])got(?:\\/|[\\s(])/.test(s)) return 'got'\n if (/\\bcolly\\b/.test(s)) return 'colly'\n if (/node-fetch\\//.test(s)) return 'node-fetch'\n if (/python-requests\\//.test(s)) return 'python-requests'\n if (/go-http-client\\//.test(s)) return 'Go http client'\n if (/okhttp\\//.test(s)) return 'OkHttp'\n if (/aiohttp\\//.test(s)) return 'aiohttp'\n if (/deno\\//.test(s)) return 'Deno'\n\n // Real browsers (or UAs spoofed to look like them — see Aider/OpenCode note).\n if (s.includes('mozilla') || s.includes('chrome') || s.includes('safari') || s.includes('firefox'))\n return 'Browser'\n\n return 'Other'\n}\n\n/**\n * Return the first product token from a UA header, useful for segmenting by\n * client without hard-coding every bot name. Falls back to `'Other'` for empty\n * input.\n */\nexport function firstUserAgentProduct(userAgent: string | null | undefined): string {\n if (!userAgent || typeof userAgent !== 'string') return 'Other'\n const compatibleMatch = userAgent.match(/compatible;\\s*([^/;\\s]+)(?:\\/[^\\s;]*)?/i)\n if (compatibleMatch && compatibleMatch[1]) return compatibleMatch[1].trim()\n const first = userAgent.trim().split('/')[0]?.trim().split(/\\s+/)[0]?.trim()\n return first || 'Other'\n}\n\n/**\n * Detect likely headless/automated browsers by checking for missing headers\n * that real browsers always send. Playwright, Puppeteer, and similar tools\n * spoof the UA but often omit standard browser headers.\n *\n * Signals checked (each scores 1 point):\n * - Missing `Accept-Language` — every real browser sends this\n * - Missing `Sec-Fetch-Mode` — sent by all modern browsers\n * - Missing `Sec-CH-UA` — Client Hints, Chromium 89+\n * - `Sec-CH-UA` contains \"HeadlessChrome\"\n * - Missing or bare Accept header — browsers send detailed accept lists\n * - `Connection: close` with browser UA — browsers use keep-alive\n *\n * Returns a score (0-6), the signals that fired, and a boolean `likely`\n * flag (score >= 2 with a browser-like UA).\n */\nexport function detectHeadless(req: Request): HeadlessDetection {\n const signals: string[] = []\n const ua = (req.headers.get('user-agent') || '').toLowerCase()\n const isBrowserUA =\n ua.includes('mozilla') || ua.includes('chrome') || ua.includes('safari') || ua.includes('firefox')\n\n if (!isBrowserUA) return { score: 0, signals: [], likely: false }\n\n if (!req.headers.get('accept-language')) {\n signals.push('missing-accept-language')\n }\n if (!req.headers.get('sec-fetch-mode')) {\n signals.push('missing-sec-fetch-mode')\n }\n const secChUa = req.headers.get('sec-ch-ua')\n if (!secChUa) {\n signals.push('missing-sec-ch-ua')\n } else if (secChUa.toLowerCase().includes('headlesschrome')) {\n signals.push('headless-chrome-hint')\n }\n const accept = req.headers.get('accept') || ''\n if (!accept || accept === '*/*') {\n signals.push('missing-or-bare-accept')\n }\n if ((req.headers.get('connection') || '').toLowerCase() === 'close') {\n signals.push('connection-close')\n }\n\n const score = signals.length\n return { score, signals, likely: score >= 2 }\n}\n\nexport interface HeadlessDetection {\n /** Number of suspicious signals found (0-6). */\n score: number\n /** Names of the specific signals that fired. */\n signals: string[]\n /** True when score >= 2 — strong headless indication. */\n likely: boolean\n}\n\nexport type AgentKind =\n | 'declared-crawler'\n | 'coding-agent-hint'\n | 'headless-likely'\n | 'browser'\n | 'other'\n\nexport interface AgentClassification {\n /**\n * Categorical tag for the request:\n *\n * - `'declared-crawler'` — {@link AI_BOT_PATTERN} matched. High confidence.\n * - `'coding-agent-hint'` — {@link HTTP_CLIENT_PATTERN} matched. Loose\n * signal; could be a coding agent, a curl script, or any automation.\n * - `'headless-likely'` — Browser-like UA but missing standard headers.\n * Strong signal of Playwright/Puppeteer automation (Aider, OpenCode, etc.).\n * - `'browser'` — Looks like a real browser with expected headers present.\n * - `'other'` — Unrecognised or empty.\n */\n kind: AgentKind\n /** Human-readable label, same string {@link parseBotName} returns. */\n label: string\n /** Strict: `true` only when the UA matches a branded AI crawler. */\n isAiBot: boolean\n /** Loose: `true` for known HTTP-library / automation UAs. */\n codingAgentHint: boolean\n /** Headless browser detection result. Only populated when `req` is passed. */\n headless?: HeadlessDetection\n}\n\n/**\n * UA-only classification. Use {@link classifyRequest} for full detection\n * including headless browser heuristics.\n */\nexport function classifyAgent(userAgent: string | null | undefined): AgentClassification {\n const label = parseBotName(userAgent)\n const aiBot = isAiBot(userAgent)\n const httpClient = isHttpClient(userAgent)\n\n let kind: AgentKind\n if (aiBot) kind = 'declared-crawler'\n else if (httpClient) kind = 'coding-agent-hint'\n else if (label === 'Browser') kind = 'browser'\n else kind = 'other'\n\n return { kind, label, isAiBot: aiBot, codingAgentHint: httpClient }\n}\n\n/**\n * Full request classification — combines UA parsing with header-based\n * headless detection. When a browser-like UA is missing standard headers,\n * the kind is promoted from `'browser'` to `'headless-likely'`.\n */\nexport function classifyRequest(req: Request): AgentClassification {\n const userAgent = req.headers.get('user-agent') || ''\n const base = classifyAgent(userAgent)\n const headless = detectHeadless(req)\n\n let kind = base.kind\n if (kind === 'browser' && headless.likely) {\n kind = 'headless-likely'\n }\n\n return { ...base, kind, headless }\n}\n","import { isAiBot } from './bots.js'\n\nexport type MarkdownServeReason =\n | 'ua-rewrite'\n | 'md-suffix'\n | 'accept-header'\n\nexport interface MarkdownDecision {\n /** Why this request should be served Markdown. */\n reason: MarkdownServeReason\n /**\n * The request's original logical path, with any trailing `.md` stripped.\n * Use this when mapping to a mirror file.\n */\n strippedPath: string\n}\n\n/**\n * Decide whether the request should be served Markdown instead of HTML.\n * Returns `null` when the request should go through your normal handler.\n *\n * Covers three triggers:\n * - Known AI-bot UA on any URL (`ua-rewrite`)\n * - Explicit `.md` suffix on the URL (`md-suffix`)\n * - `Accept: text/markdown` header (`accept-header`)\n *\n * This helper intentionally does not perform the rewrite itself — routing is\n * framework-specific (NextResponse.rewrite for Next.js, ctx.rewrite for\n * Hono, etc.). Use the returned decision to build the appropriate response.\n */\nexport function markdownServeDecision(req: Request): MarkdownDecision | null {\n let pathname = '/'\n try {\n pathname = new URL(req.url).pathname\n } catch {\n pathname = req.url || '/'\n }\n\n const ua = req.headers.get('user-agent') || ''\n if (isAiBot(ua)) {\n return { reason: 'ua-rewrite', strippedPath: pathname }\n }\n\n if (pathname.endsWith('.md')) {\n return { reason: 'md-suffix', strippedPath: pathname.replace(/\\.md$/, '') }\n }\n\n const accept = req.headers.get('accept') || ''\n if (accept.includes('text/markdown')) {\n return { reason: 'accept-header', strippedPath: pathname }\n }\n\n return null\n}\n\nexport interface MarkdownHeadersInput {\n /**\n * If provided, rendered as `x-markdown-tokens` so agents can budget context\n * before parsing the body. Typically `Math.ceil(body.length / 4)`.\n */\n tokens?: number\n /**\n * Content-Signal directive (see contentsignals.org). Defaults to\n * `'search=yes, ai-input=yes, ai-train=no'` — change if you want to permit\n * training or restrict indexing.\n */\n contentSignal?: string\n}\n\n/**\n * Build the set of response headers to attach to a Markdown response. Safe\n * defaults: UTF-8 text/markdown, Vary: accept, and a Content-Signal directive\n * that permits search + agent input but denies training.\n */\nexport function markdownHeaders(input: MarkdownHeadersInput = {}): Record<string, string> {\n const headers: Record<string, string> = {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Content-Signal': input.contentSignal ?? 'search=yes, ai-input=yes, ai-train=no',\n Vary: 'accept'\n }\n if (typeof input.tokens === 'number' && input.tokens > 0) {\n headers['x-markdown-tokens'] = Math.max(1, Math.ceil(input.tokens)).toString()\n }\n return headers\n}\n\nexport interface SynthesizePointerInput {\n origin: string\n pathname: string\n /** URL of the site's curated index, usually `/llms.txt`. */\n llmsTxtUrl?: string\n /** URL of the full enumerated index, usually `/llms-full.txt`. */\n llmsFullTxtUrl?: string\n /** URL of the machine-readable path manifest, usually `/md/index.json`. */\n markdownIndexUrl?: string\n /** Site name to title the pointer document. Defaults to the origin hostname. */\n siteName?: string\n}\n\n/**\n * Generate a minimal pointer Markdown document for URLs that don't have a\n * pre-built mirror. Keeps the `Accept: text/markdown` contract intact\n * site-wide — agents always get *something* parseable, not a 404.\n */\nexport function synthesizeMarkdownPointer(input: SynthesizePointerInput): string {\n const site =\n input.siteName ??\n (() => {\n try {\n return new URL(input.origin).hostname\n } catch {\n return input.origin\n }\n })()\n const url = `${input.origin}${input.pathname}`\n const lines: string[] = [`# ${site}`, '', `This page (${url}) does not have a dedicated Markdown mirror yet.`, '']\n const links: string[] = []\n if (input.llmsTxtUrl) links.push(`- [${input.llmsTxtUrl}](${input.llmsTxtUrl}) — curated index of docs`)\n if (input.llmsFullTxtUrl)\n links.push(`- [${input.llmsFullTxtUrl}](${input.llmsFullTxtUrl}) — full enumerated index`)\n if (input.markdownIndexUrl)\n links.push(`- [${input.markdownIndexUrl}](${input.markdownIndexUrl}) — JSON index of all Markdown paths`)\n if (links.length) {\n lines.push('For machine-readable documentation, see:', '', ...links, '')\n }\n return lines.join('\\n')\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/bots.ts","../src/markdown.ts"],"names":["AI_BOT_PATTERN","isAiBot","userAgent","markdownServeDecision","req","pathname","hasSuffix","strippedPath","ua","markdownHeaders","input","headers","synthesizeMarkdownPointer","site","url","lines","links"],"mappings":"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","file":"markdown.cjs","sourcesContent":["/**\n * User-agent substrings that identify **publicly declared** AI crawlers — the\n * branded bots that identify themselves by name (OpenAI's GPTBot, Anthropic's\n * ClaudeBot, Perplexity-User, Google-Extended, etc.). High-confidence: when\n * this matches, the request almost certainly comes from that vendor's crawler\n * fleet.\n *\n * Does NOT include **coding-agent traffic** (Claude Code, Cline, Cursor,\n * Windsurf, Aider, OpenCode, VS Code). Those tools use generic HTTP library\n * UAs (axios, curl, got, colly, Electron) or spoof full browser UAs — they\n * can't be distinguished from non-AI traffic by UA alone. See\n * {@link HTTP_CLIENT_PATTERN} for the loose heuristic layer.\n *\n * Sources consulted when updating: darkvisitors.com, vendor docs from OpenAI,\n * Anthropic, Google, Perplexity, Cohere, Apple, Bytedance.\n */\nexport const AI_BOT_PATTERN =\n /ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|Anthropic|GPTBot|ChatGPT-User|OAI-SearchBot|PerplexityBot|Perplexity-User|Google-Extended|Google-CloudVertexBot|Google-Agent|GoogleAgent-Mariner|Gemini-Deep-Research|Applebot|cohere|Bytespider|CCBot|Amazonbot|Amzn-SearchBot|NovaAct|AzureAI-SearchBot|Meta-ExternalAgent|meta-externalfetcher|meta-webindexer|FacebookBot|DuckAssistBot|MistralAI-User|YouBot|AI2Bot|Diffbot|DeepSeek|PanguBot|Webzio-Extended|omgili|Timpibot|Grok|Manus-User|quillbot|MyCentralAIScraperBot|Cursor|Windsurf/i\n\n/**\n * HTTP library / runtime signatures frequently used by coding agents. Matching\n * any of these is a **loose** signal — legitimate curl scripts, CI jobs, and\n * server-to-server traffic use the same libraries. Use this for the wider\n * net (`coding_agent_hint: true`) and pair with other signals (request\n * shape, JA4 fingerprint, path patterns) for higher confidence.\n *\n * Based on behavioural signatures observed by Addy Osmani:\n * Claude Code → axios/1.8.4\n * Cline, Junie → curl/8.4.0\n * Cursor → got (sindresorhus/got)\n * Windsurf → colly\n * VS Code → Electron / Chromium\n *\n * Aider and OpenCode use Playwright-driven full Mozilla/Safari UAs and are\n * indistinguishable from real browsers at the UA layer.\n */\nexport const HTTP_CLIENT_PATTERN =\n /axios\\/|curl\\/|(?:^|[\\s(])got(?:\\/|[\\s(])|\\bcolly\\b|Electron\\/|node-fetch\\/|python-requests\\/|Go-http-client\\/|okhttp\\/|aiohttp\\/|Deno\\//i\n\nexport function isAiBot(userAgent: string | null | undefined): boolean {\n if (!userAgent) return false\n return AI_BOT_PATTERN.test(userAgent)\n}\n\nexport function isHttpClient(userAgent: string | null | undefined): boolean {\n if (!userAgent) return false\n return HTTP_CLIENT_PATTERN.test(userAgent)\n}\n\n/**\n * Map a user-agent string to a coarse, human-readable label. Returns one of:\n *\n * - A branded-crawler name (`'Claude'`, `'ChatGPT'`, …) — pair with\n * {@link isAiBot} for `is_ai_bot: true` segmentation.\n * - An HTTP-library name (`'curl'`, `'axios'`, `'got'`, `'colly'`,\n * `'Electron'`, …) — hint of a coding agent or automation; not\n * conclusive. Pair with {@link isHttpClient}.\n * - `'Browser'` for typical desktop browsers (possibly spoofed by\n * Playwright-based agents like Aider/OpenCode — this label alone can't\n * tell you).\n * - `'Other'` for anything unrecognised or empty input.\n */\nexport function parseBotName(userAgent: string | null | undefined): string {\n if (!userAgent || typeof userAgent !== 'string') return 'Other'\n const s = userAgent.toLowerCase()\n\n // Publicly declared AI crawlers (high confidence).\n if (s.includes('chatgpt-user') || s.includes('gptbot') || s.includes('oai-searchbot') || s.includes('openai'))\n return 'ChatGPT'\n if (\n s.includes('claudebot') ||\n s.includes('claude-user') ||\n s.includes('claude-searchbot') ||\n s.includes('claude-web') ||\n s.includes('anthropic')\n )\n return 'Claude'\n if (s.includes('perplexitybot') || s.includes('perplexity-user')) return 'Perplexity'\n if (s.includes('ccbot')) return 'Common Crawl'\n if (\n s.includes('google-extended') ||\n s.includes('googlebot') ||\n s.includes('google-cloudvertexbot') ||\n s.includes('google-agent') ||\n s.includes('googleagent-mariner') ||\n s.includes('gemini-deep-research')\n )\n return 'Google'\n if (s.includes('applebot')) return 'Apple'\n if (s.includes('bingbot')) return 'Bing'\n if (s.includes('bytespider')) return 'Bytespider'\n if (s.includes('amazonbot') || s.includes('amzn-searchbot') || s.includes('novaact')) return 'Amazon'\n if (\n s.includes('meta-externalagent') ||\n s.includes('meta-externalfetcher') ||\n s.includes('meta-webindexer') ||\n s.includes('facebookbot')\n )\n return 'Meta'\n if (s.includes('mistralai-user')) return 'Mistral'\n if (s.includes('duckassistbot')) return 'DuckDuckGo'\n if (s.includes('youbot')) return 'You.com'\n if (s.includes('diffbot')) return 'Diffbot'\n if (s.includes('ai2bot')) return 'AI2'\n if (s.includes('cohere')) return 'Cohere'\n if (s.includes('cursor')) return 'Cursor'\n if (s.includes('windsurf')) return 'Windsurf'\n if (s.includes('deepseek')) return 'DeepSeek'\n if (s.includes('pangubot')) return 'Huawei'\n if (s.includes('webzio') || s.includes('omgili')) return 'Webz.io'\n if (s.includes('timpibot')) return 'Timpi'\n if (s.includes('grok') || s.includes('xai-')) return 'xAI'\n if (s.includes('manus-user')) return 'Manus'\n if (s.includes('quillbot')) return 'QuillBot'\n if (s.includes('azureai-searchbot')) return 'Microsoft'\n if (s.includes('mycentralaiscraperbot')) return 'MyCentralAI'\n if (s.includes('petalbot')) return 'PetalBot'\n\n // SEO crawlers and monitoring bots.\n if (s.includes('ahrefsbot')) return 'Ahrefs'\n if (s.includes('semrushbot')) return 'Semrush'\n if (s.includes('mj12bot')) return 'Majestic'\n if (s.includes('dotbot')) return 'Moz'\n if (s.includes('rogerbot')) return 'Moz'\n if (s.includes('screaming frog')) return 'Screaming Frog'\n if (s.includes('sitebulb')) return 'Sitebulb'\n if (s.includes('linkfluence')) return 'Linkfluence'\n if (s.includes('dataforseo')) return 'DataForSEO'\n if (s.includes('serpstatbot')) return 'Serpstat'\n\n // Monitoring and feed bots.\n if (s.includes('uptimerobot')) return 'UptimeRobot'\n if (s.includes('pingdom')) return 'Pingdom'\n if (s.includes('statuscake')) return 'StatusCake'\n if (s.includes('newrelicpinger')) return 'New Relic'\n if (s.includes('datadogagent') || s.includes('datadog')) return 'Datadog'\n if (s.includes('slackbot')) return 'Slack'\n if (s.includes('twitterbot')) return 'Twitter'\n if (s.includes('linkedinbot')) return 'LinkedIn'\n if (s.includes('discordbot')) return 'Discord'\n if (s.includes('telegrambot')) return 'Telegram'\n if (s.includes('whatsapp')) return 'WhatsApp'\n\n // AI search and indexing bots.\n if (s.includes('linkupbot')) return 'Linkup'\n if (s.includes('sogou')) return 'Sogou'\n if (s.includes('yandexbot')) return 'Yandex'\n if (s.includes('baiduspider')) return 'Baidu'\n\n // Link preview fetchers.\n if (s.includes('facebookexternalhit')) return 'Facebook'\n if (s.includes('com.apple.webkit')) return 'Apple URL Preview'\n\n // Uptime and monitoring.\n if (s.includes('ohdear')) return 'Oh Dear'\n\n // Generic scrapers.\n if (s.includes('scrapy')) return 'Scrapy'\n if (s.includes('headlesschrome')) return 'Headless Chrome'\n if (s.includes('phantomjs')) return 'PhantomJS'\n if (s.includes('wget')) return 'wget'\n if (s.includes('httpie')) return 'HTTPie'\n if (s.includes('guzzlehttp')) return 'Guzzle'\n\n // HTTP library / runtime signatures (loose — coding agent or automation).\n // Check Electron before Browser since Electron UAs contain Chrome/Safari.\n if (s.includes('electron/')) return 'Electron'\n if (/curl\\//.test(s)) return 'curl'\n if (/axios\\//.test(s)) return 'axios'\n if (/(?:^|[\\s(])got(?:\\/|[\\s(])/.test(s)) return 'got'\n if (/\\bcolly\\b/.test(s)) return 'colly'\n if (/node-fetch\\//.test(s)) return 'node-fetch'\n if (/python-requests\\//.test(s)) return 'python-requests'\n if (/go-http-client\\//.test(s)) return 'Go http client'\n if (/okhttp\\//.test(s)) return 'OkHttp'\n if (/aiohttp\\//.test(s)) return 'aiohttp'\n if (/deno\\//.test(s)) return 'Deno'\n\n // Real browsers (or UAs spoofed to look like them — see Aider/OpenCode note).\n if (s.includes('mozilla') || s.includes('chrome') || s.includes('safari') || s.includes('firefox'))\n return 'Browser'\n\n return 'Other'\n}\n\n/**\n * Return the first product token from a UA header, useful for segmenting by\n * client without hard-coding every bot name. Falls back to `'Other'` for empty\n * input.\n */\nexport function firstUserAgentProduct(userAgent: string | null | undefined): string {\n if (!userAgent || typeof userAgent !== 'string') return 'Other'\n const compatibleMatch = userAgent.match(/compatible;\\s*([^/;\\s]+)(?:\\/[^\\s;]*)?/i)\n if (compatibleMatch && compatibleMatch[1]) return compatibleMatch[1].trim()\n const first = userAgent.trim().split('/')[0]?.trim().split(/\\s+/)[0]?.trim()\n return first || 'Other'\n}\n\n/**\n * Detect likely headless/automated browsers by checking for missing headers\n * that real browsers always send. Playwright, Puppeteer, and similar tools\n * spoof the UA but often omit standard browser headers.\n *\n * Signals checked (each scores 1 point):\n * - Missing `Accept-Language` — every real browser sends this\n * - Missing `Sec-Fetch-Mode` — sent by all modern browsers\n * - Missing `Sec-CH-UA` — Client Hints, Chromium 89+\n * - `Sec-CH-UA` contains \"HeadlessChrome\"\n * - Missing or bare Accept header — browsers send detailed accept lists\n * - `Connection: close` with browser UA — browsers use keep-alive\n *\n * Returns a score (0-6), the signals that fired, and a boolean `likely`\n * flag (score >= 2 with a browser-like UA).\n */\nexport function detectHeadless(req: Request): HeadlessDetection {\n const signals: string[] = []\n const ua = (req.headers.get('user-agent') || '').toLowerCase()\n const isBrowserUA =\n ua.includes('mozilla') || ua.includes('chrome') || ua.includes('safari') || ua.includes('firefox')\n\n if (!isBrowserUA) return { score: 0, signals: [], likely: false }\n\n if (!req.headers.get('accept-language')) {\n signals.push('missing-accept-language')\n }\n if (!req.headers.get('sec-fetch-mode')) {\n signals.push('missing-sec-fetch-mode')\n }\n const secChUa = req.headers.get('sec-ch-ua')\n if (!secChUa) {\n signals.push('missing-sec-ch-ua')\n } else if (secChUa.toLowerCase().includes('headlesschrome')) {\n signals.push('headless-chrome-hint')\n }\n const accept = req.headers.get('accept') || ''\n if (!accept || accept === '*/*') {\n signals.push('missing-or-bare-accept')\n }\n if ((req.headers.get('connection') || '').toLowerCase() === 'close') {\n signals.push('connection-close')\n }\n\n const score = signals.length\n return { score, signals, likely: score >= 2 }\n}\n\nexport interface HeadlessDetection {\n /** Number of suspicious signals found (0-6). */\n score: number\n /** Names of the specific signals that fired. */\n signals: string[]\n /** True when score >= 2 — strong headless indication. */\n likely: boolean\n}\n\nexport type AgentKind =\n | 'declared-crawler'\n | 'coding-agent-hint'\n | 'headless-likely'\n | 'browser'\n | 'other'\n\nexport interface AgentClassification {\n /**\n * Categorical tag for the request:\n *\n * - `'declared-crawler'` — {@link AI_BOT_PATTERN} matched. High confidence.\n * - `'coding-agent-hint'` — {@link HTTP_CLIENT_PATTERN} matched. Loose\n * signal; could be a coding agent, a curl script, or any automation.\n * - `'headless-likely'` — Browser-like UA but missing standard headers.\n * Strong signal of Playwright/Puppeteer automation (Aider, OpenCode, etc.).\n * - `'browser'` — Looks like a real browser with expected headers present.\n * - `'other'` — Unrecognised or empty.\n */\n kind: AgentKind\n /** Human-readable label, same string {@link parseBotName} returns. */\n label: string\n /** Strict: `true` only when the UA matches a branded AI crawler. */\n isAiBot: boolean\n /** Loose: `true` for known HTTP-library / automation UAs. */\n codingAgentHint: boolean\n /** Headless browser detection result. Only populated when `req` is passed. */\n headless?: HeadlessDetection\n}\n\n/**\n * UA-only classification. Use {@link classifyRequest} for full detection\n * including headless browser heuristics.\n */\nexport function classifyAgent(userAgent: string | null | undefined): AgentClassification {\n const label = parseBotName(userAgent)\n const aiBot = isAiBot(userAgent)\n const httpClient = isHttpClient(userAgent)\n\n let kind: AgentKind\n if (aiBot) kind = 'declared-crawler'\n else if (httpClient) kind = 'coding-agent-hint'\n else if (label === 'Browser') kind = 'browser'\n else kind = 'other'\n\n return { kind, label, isAiBot: aiBot, codingAgentHint: httpClient }\n}\n\n/**\n * Full request classification — combines UA parsing with header-based\n * headless detection. When a browser-like UA is missing standard headers,\n * the kind is promoted from `'browser'` to `'headless-likely'`.\n */\nexport function classifyRequest(req: Request): AgentClassification {\n const userAgent = req.headers.get('user-agent') || ''\n const base = classifyAgent(userAgent)\n const headless = detectHeadless(req)\n\n let kind = base.kind\n let label = base.label\n if (kind === 'browser' && headless.likely) {\n kind = 'headless-likely'\n // Relabel too. Leaving it as 'Browser' meant automation with a spoofed\n // browser UA — 79% of one production site's agent traffic — was\n // indistinguishable from a human in any `bot_name` breakdown, and was\n // silently excluded by the obvious `bot_name != 'Browser'` filter.\n label = 'Headless'\n }\n\n return { ...base, kind, label, headless }\n}\n","import { isAiBot } from './bots.js'\n\nexport type MarkdownServeReason =\n | 'ua-rewrite'\n | 'md-suffix'\n | 'accept-header'\n\nexport interface MarkdownDecision {\n /** Why this request should be served Markdown. */\n reason: MarkdownServeReason\n /**\n * The request's original logical path, with any trailing `.md` stripped.\n * Use this when mapping to a mirror file.\n */\n strippedPath: string\n}\n\n/**\n * Decide whether the request should be served Markdown instead of HTML.\n * Returns `null` when the request should go through your normal handler.\n *\n * Covers three triggers:\n * - Known AI-bot UA on any URL (`ua-rewrite`)\n * - Explicit `.md` suffix on the URL (`md-suffix`)\n * - `Accept: text/markdown` header (`accept-header`)\n *\n * This helper intentionally does not perform the rewrite itself — routing is\n * framework-specific (NextResponse.rewrite for Next.js, ctx.rewrite for\n * Hono, etc.). Use the returned decision to build the appropriate response.\n */\nexport function markdownServeDecision(req: Request): MarkdownDecision | null {\n let pathname = '/'\n try {\n pathname = new URL(req.url).pathname\n } catch {\n pathname = req.url || '/'\n }\n\n // Strip once, up front. Stripping only inside the md-suffix branch meant an\n // AI bot following a `.md` link — the very links this library advertises via\n // `Link: rel=\"alternate\"` and llms.txt — matched `ua-rewrite` first and kept\n // the extension, so callers building `/md${strippedPath}.md` requested\n // `/md/docs/intro.md.md` and silently fell back to a pointer document.\n const hasSuffix = pathname.endsWith('.md')\n const strippedPath = hasSuffix ? pathname.slice(0, -3) : pathname\n\n const ua = req.headers.get('user-agent') || ''\n if (isAiBot(ua)) {\n return { reason: hasSuffix ? 'md-suffix' : 'ua-rewrite', strippedPath }\n }\n\n if (hasSuffix) {\n return { reason: 'md-suffix', strippedPath }\n }\n\n const accept = req.headers.get('accept') || ''\n if (accept.includes('text/markdown')) {\n return { reason: 'accept-header', strippedPath }\n }\n\n return null\n}\n\nexport interface MarkdownHeadersInput {\n /**\n * If provided, rendered as `x-markdown-tokens` so agents can budget context\n * before parsing the body. Typically `Math.ceil(body.length / 4)`.\n */\n tokens?: number\n /**\n * Content-Signal directive (see contentsignals.org). Defaults to\n * `'search=yes, ai-input=yes, ai-train=no'` — change if you want to permit\n * training or restrict indexing.\n */\n contentSignal?: string\n}\n\n/**\n * Build the set of response headers to attach to a Markdown response. Safe\n * defaults: UTF-8 text/markdown, Vary: accept, and a Content-Signal directive\n * that permits search + agent input but denies training.\n */\nexport function markdownHeaders(input: MarkdownHeadersInput = {}): Record<string, string> {\n const headers: Record<string, string> = {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Content-Signal': input.contentSignal ?? 'search=yes, ai-input=yes, ai-train=no',\n Vary: 'accept'\n }\n if (typeof input.tokens === 'number' && input.tokens > 0) {\n headers['x-markdown-tokens'] = Math.max(1, Math.ceil(input.tokens)).toString()\n }\n return headers\n}\n\nexport interface SynthesizePointerInput {\n origin: string\n pathname: string\n /** URL of the site's curated index, usually `/llms.txt`. */\n llmsTxtUrl?: string\n /** URL of the full enumerated index, usually `/llms-full.txt`. */\n llmsFullTxtUrl?: string\n /** URL of the machine-readable path manifest, usually `/md/index.json`. */\n markdownIndexUrl?: string\n /** Site name to title the pointer document. Defaults to the origin hostname. */\n siteName?: string\n}\n\n/**\n * Generate a minimal pointer Markdown document for URLs that don't have a\n * pre-built mirror. Keeps the `Accept: text/markdown` contract intact\n * site-wide — agents always get *something* parseable, not a 404.\n */\nexport function synthesizeMarkdownPointer(input: SynthesizePointerInput): string {\n const site =\n input.siteName ??\n (() => {\n try {\n return new URL(input.origin).hostname\n } catch {\n return input.origin\n }\n })()\n const url = `${input.origin}${input.pathname}`\n const lines: string[] = [`# ${site}`, '', `This page (${url}) does not have a dedicated Markdown mirror yet.`, '']\n const links: string[] = []\n if (input.llmsTxtUrl) links.push(`- [${input.llmsTxtUrl}](${input.llmsTxtUrl}) — curated index of docs`)\n if (input.llmsFullTxtUrl)\n links.push(`- [${input.llmsFullTxtUrl}](${input.llmsFullTxtUrl}) — full enumerated index`)\n if (input.markdownIndexUrl)\n links.push(`- [${input.markdownIndexUrl}](${input.markdownIndexUrl}) — JSON index of all Markdown paths`)\n if (links.length) {\n lines.push('For machine-readable documentation, see:', '', ...links, '')\n }\n return lines.join('\\n')\n}\n"]}
|
package/dist/markdown.js
CHANGED
|
@@ -1,64 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
function isAiBot(userAgent) {
|
|
4
|
-
if (!userAgent) return false;
|
|
5
|
-
return AI_BOT_PATTERN.test(userAgent);
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
// src/markdown.ts
|
|
9
|
-
function markdownServeDecision(req) {
|
|
10
|
-
let pathname = "/";
|
|
11
|
-
try {
|
|
12
|
-
pathname = new URL(req.url).pathname;
|
|
13
|
-
} catch {
|
|
14
|
-
pathname = req.url || "/";
|
|
15
|
-
}
|
|
16
|
-
const ua = req.headers.get("user-agent") || "";
|
|
17
|
-
if (isAiBot(ua)) {
|
|
18
|
-
return { reason: "ua-rewrite", strippedPath: pathname };
|
|
19
|
-
}
|
|
20
|
-
if (pathname.endsWith(".md")) {
|
|
21
|
-
return { reason: "md-suffix", strippedPath: pathname.replace(/\.md$/, "") };
|
|
22
|
-
}
|
|
23
|
-
const accept = req.headers.get("accept") || "";
|
|
24
|
-
if (accept.includes("text/markdown")) {
|
|
25
|
-
return { reason: "accept-header", strippedPath: pathname };
|
|
26
|
-
}
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
function markdownHeaders(input = {}) {
|
|
30
|
-
const headers = {
|
|
31
|
-
"Content-Type": "text/markdown; charset=utf-8",
|
|
32
|
-
"Content-Signal": input.contentSignal ?? "search=yes, ai-input=yes, ai-train=no",
|
|
33
|
-
Vary: "accept"
|
|
34
|
-
};
|
|
35
|
-
if (typeof input.tokens === "number" && input.tokens > 0) {
|
|
36
|
-
headers["x-markdown-tokens"] = Math.max(1, Math.ceil(input.tokens)).toString();
|
|
37
|
-
}
|
|
38
|
-
return headers;
|
|
39
|
-
}
|
|
40
|
-
function synthesizeMarkdownPointer(input) {
|
|
41
|
-
const site = input.siteName ?? (() => {
|
|
42
|
-
try {
|
|
43
|
-
return new URL(input.origin).hostname;
|
|
44
|
-
} catch {
|
|
45
|
-
return input.origin;
|
|
46
|
-
}
|
|
47
|
-
})();
|
|
48
|
-
const url = `${input.origin}${input.pathname}`;
|
|
49
|
-
const lines = [`# ${site}`, "", `This page (${url}) does not have a dedicated Markdown mirror yet.`, ""];
|
|
50
|
-
const links = [];
|
|
51
|
-
if (input.llmsTxtUrl) links.push(`- [${input.llmsTxtUrl}](${input.llmsTxtUrl}) \u2014 curated index of docs`);
|
|
52
|
-
if (input.llmsFullTxtUrl)
|
|
53
|
-
links.push(`- [${input.llmsFullTxtUrl}](${input.llmsFullTxtUrl}) \u2014 full enumerated index`);
|
|
54
|
-
if (input.markdownIndexUrl)
|
|
55
|
-
links.push(`- [${input.markdownIndexUrl}](${input.markdownIndexUrl}) \u2014 JSON index of all Markdown paths`);
|
|
56
|
-
if (links.length) {
|
|
57
|
-
lines.push("For machine-readable documentation, see:", "", ...links, "");
|
|
58
|
-
}
|
|
59
|
-
return lines.join("\n");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export { markdownHeaders, markdownServeDecision, synthesizeMarkdownPointer };
|
|
63
|
-
//# sourceMappingURL=markdown.js.map
|
|
1
|
+
var o=/ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|Anthropic|GPTBot|ChatGPT-User|OAI-SearchBot|PerplexityBot|Perplexity-User|Google-Extended|Google-CloudVertexBot|Google-Agent|GoogleAgent-Mariner|Gemini-Deep-Research|Applebot|cohere|Bytespider|CCBot|Amazonbot|Amzn-SearchBot|NovaAct|AzureAI-SearchBot|Meta-ExternalAgent|meta-externalfetcher|meta-webindexer|FacebookBot|DuckAssistBot|MistralAI-User|YouBot|AI2Bot|Diffbot|DeepSeek|PanguBot|Webzio-Extended|omgili|Timpibot|Grok|Manus-User|quillbot|MyCentralAIScraperBot|Cursor|Windsurf/i;function s(e){return e?o.test(e):false}function c(e){let t="/";try{t=new URL(e.url).pathname;}catch{t=e.url||"/";}let i=t.endsWith(".md"),r=i?t.slice(0,-3):t,n=e.headers.get("user-agent")||"";return s(n)?{reason:i?"md-suffix":"ua-rewrite",strippedPath:r}:i?{reason:"md-suffix",strippedPath:r}:(e.headers.get("accept")||"").includes("text/markdown")?{reason:"accept-header",strippedPath:r}:null}function d(e={}){let t={"Content-Type":"text/markdown; charset=utf-8","Content-Signal":e.contentSignal??"search=yes, ai-input=yes, ai-train=no",Vary:"accept"};return typeof e.tokens=="number"&&e.tokens>0&&(t["x-markdown-tokens"]=Math.max(1,Math.ceil(e.tokens)).toString()),t}function f(e){let t=e.siteName??(()=>{try{return new URL(e.origin).hostname}catch{return e.origin}})(),i=`${e.origin}${e.pathname}`,r=[`# ${t}`,"",`This page (${i}) does not have a dedicated Markdown mirror yet.`,""],n=[];return e.llmsTxtUrl&&n.push(`- [${e.llmsTxtUrl}](${e.llmsTxtUrl}) \u2014 curated index of docs`),e.llmsFullTxtUrl&&n.push(`- [${e.llmsFullTxtUrl}](${e.llmsFullTxtUrl}) \u2014 full enumerated index`),e.markdownIndexUrl&&n.push(`- [${e.markdownIndexUrl}](${e.markdownIndexUrl}) \u2014 JSON index of all Markdown paths`),n.length&&r.push("For machine-readable documentation, see:","",...n,""),r.join(`
|
|
2
|
+
`)}export{d as markdownHeaders,c as markdownServeDecision,f as synthesizeMarkdownPointer};//# sourceMappingURL=markdown.js.map
|
|
64
3
|
//# sourceMappingURL=markdown.js.map
|
package/dist/markdown.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/bots.ts","../src/markdown.ts"],"names":[],"mappings":";AAgBO,IAAM,cAAA,GACX,shBAAA;AAsBK,SAAS,QAAQ,SAAA,EAA+C;AACrE,EAAA,IAAI,CAAC,WAAW,OAAO,KAAA;AACvB,EAAA,OAAO,cAAA,CAAe,KAAK,SAAS,CAAA;AACtC;;;ACZO,SAAS,sBAAsB,GAAA,EAAuC;AAC3E,EAAA,IAAI,QAAA,GAAW,GAAA;AACf,EAAA,IAAI;AACF,IAAA,QAAA,GAAW,IAAI,GAAA,CAAI,GAAA,CAAI,GAAG,CAAA,CAAE,QAAA;AAAA,EAC9B,CAAA,CAAA,MAAQ;AACN,IAAA,QAAA,GAAW,IAAI,GAAA,IAAO,GAAA;AAAA,EACxB;AAEA,EAAA,MAAM,EAAA,GAAK,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,YAAY,CAAA,IAAK,EAAA;AAC5C,EAAA,IAAI,OAAA,CAAQ,EAAE,CAAA,EAAG;AACf,IAAA,OAAO,EAAE,MAAA,EAAQ,YAAA,EAAc,YAAA,EAAc,QAAA,EAAS;AAAA,EACxD;AAEA,EAAA,IAAI,QAAA,CAAS,QAAA,CAAS,KAAK,CAAA,EAAG;AAC5B,IAAA,OAAO,EAAE,QAAQ,WAAA,EAAa,YAAA,EAAc,SAAS,OAAA,CAAQ,OAAA,EAAS,EAAE,CAAA,EAAE;AAAA,EAC5E;AAEA,EAAA,MAAM,MAAA,GAAS,GAAA,CAAI,OAAA,CAAQ,GAAA,CAAI,QAAQ,CAAA,IAAK,EAAA;AAC5C,EAAA,IAAI,MAAA,CAAO,QAAA,CAAS,eAAe,CAAA,EAAG;AACpC,IAAA,OAAO,EAAE,MAAA,EAAQ,eAAA,EAAiB,YAAA,EAAc,QAAA,EAAS;AAAA,EAC3D;AAEA,EAAA,OAAO,IAAA;AACT;AAqBO,SAAS,eAAA,CAAgB,KAAA,GAA8B,EAAC,EAA2B;AACxF,EAAA,MAAM,OAAA,GAAkC;AAAA,IACtC,cAAA,EAAgB,8BAAA;AAAA,IAChB,gBAAA,EAAkB,MAAM,aAAA,IAAiB,uCAAA;AAAA,IACzC,IAAA,EAAM;AAAA,GACR;AACA,EAAA,IAAI,OAAO,KAAA,CAAM,MAAA,KAAW,QAAA,IAAY,KAAA,CAAM,SAAS,CAAA,EAAG;AACxD,IAAA,OAAA,CAAQ,mBAAmB,CAAA,GAAI,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,IAAA,CAAK,KAAA,CAAM,MAAM,CAAC,CAAA,CAAE,QAAA,EAAS;AAAA,EAC/E;AACA,EAAA,OAAO,OAAA;AACT;AAoBO,SAAS,0BAA0B,KAAA,EAAuC;AAC/E,EAAA,MAAM,IAAA,GACJ,KAAA,CAAM,QAAA,IAAA,CACL,MAAM;AACL,IAAA,IAAI;AACF,MAAA,OAAO,IAAI,GAAA,CAAI,KAAA,CAAM,MAAM,CAAA,CAAE,QAAA;AAAA,IAC/B,CAAA,CAAA,MAAQ;AACN,MAAA,OAAO,KAAA,CAAM,MAAA;AAAA,IACf;AAAA,EACF,CAAA,GAAG;AACL,EAAA,MAAM,MAAM,CAAA,EAAG,KAAA,CAAM,MAAM,CAAA,EAAG,MAAM,QAAQ,CAAA,CAAA;AAC5C,EAAA,MAAM,KAAA,GAAkB,CAAC,CAAA,EAAA,EAAK,IAAI,IAAI,EAAA,EAAI,CAAA,WAAA,EAAc,GAAG,CAAA,gDAAA,CAAA,EAAoD,EAAE,CAAA;AACjH,EAAA,MAAM,QAAkB,EAAC;AACzB,EAAA,IAAI,KAAA,CAAM,UAAA,EAAY,KAAA,CAAM,IAAA,CAAK,CAAA,GAAA,EAAM,MAAM,UAAU,CAAA,EAAA,EAAK,KAAA,CAAM,UAAU,CAAA,8BAAA,CAA2B,CAAA;AACvG,EAAA,IAAI,KAAA,CAAM,cAAA;AACR,IAAA,KAAA,CAAM,KAAK,CAAA,GAAA,EAAM,KAAA,CAAM,cAAc,CAAA,EAAA,EAAK,KAAA,CAAM,cAAc,CAAA,8BAAA,CAA2B,CAAA;AAC3F,EAAA,IAAI,KAAA,CAAM,gBAAA;AACR,IAAA,KAAA,CAAM,KAAK,CAAA,GAAA,EAAM,KAAA,CAAM,gBAAgB,CAAA,EAAA,EAAK,KAAA,CAAM,gBAAgB,CAAA,yCAAA,CAAsC,CAAA;AAC1G,EAAA,IAAI,MAAM,MAAA,EAAQ;AAChB,IAAA,KAAA,CAAM,IAAA,CAAK,0CAAA,EAA4C,EAAA,EAAI,GAAG,OAAO,EAAE,CAAA;AAAA,EACzE;AACA,EAAA,OAAO,KAAA,CAAM,KAAK,IAAI,CAAA;AACxB","file":"markdown.js","sourcesContent":["/**\n * User-agent substrings that identify **publicly declared** AI crawlers — the\n * branded bots that identify themselves by name (OpenAI's GPTBot, Anthropic's\n * ClaudeBot, Perplexity-User, Google-Extended, etc.). High-confidence: when\n * this matches, the request almost certainly comes from that vendor's crawler\n * fleet.\n *\n * Does NOT include **coding-agent traffic** (Claude Code, Cline, Cursor,\n * Windsurf, Aider, OpenCode, VS Code). Those tools use generic HTTP library\n * UAs (axios, curl, got, colly, Electron) or spoof full browser UAs — they\n * can't be distinguished from non-AI traffic by UA alone. See\n * {@link HTTP_CLIENT_PATTERN} for the loose heuristic layer.\n *\n * Sources consulted when updating: darkvisitors.com, vendor docs from OpenAI,\n * Anthropic, Google, Perplexity, Cohere, Apple, Bytedance.\n */\nexport const AI_BOT_PATTERN =\n /ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|Anthropic|GPTBot|ChatGPT-User|OAI-SearchBot|PerplexityBot|Perplexity-User|Google-Extended|Google-CloudVertexBot|Google-Agent|GoogleAgent-Mariner|Gemini-Deep-Research|Applebot|cohere|Bytespider|CCBot|Amazonbot|Amzn-SearchBot|NovaAct|AzureAI-SearchBot|Meta-ExternalAgent|meta-externalfetcher|meta-webindexer|FacebookBot|DuckAssistBot|MistralAI-User|YouBot|AI2Bot|Diffbot|DeepSeek|PanguBot|Webzio-Extended|omgili|Timpibot|Grok|Manus-User|quillbot|MyCentralAIScraperBot|Cursor|Windsurf/i\n\n/**\n * HTTP library / runtime signatures frequently used by coding agents. Matching\n * any of these is a **loose** signal — legitimate curl scripts, CI jobs, and\n * server-to-server traffic use the same libraries. Use this for the wider\n * net (`coding_agent_hint: true`) and pair with other signals (request\n * shape, JA4 fingerprint, path patterns) for higher confidence.\n *\n * Based on behavioural signatures observed by Addy Osmani:\n * Claude Code → axios/1.8.4\n * Cline, Junie → curl/8.4.0\n * Cursor → got (sindresorhus/got)\n * Windsurf → colly\n * VS Code → Electron / Chromium\n *\n * Aider and OpenCode use Playwright-driven full Mozilla/Safari UAs and are\n * indistinguishable from real browsers at the UA layer.\n */\nexport const HTTP_CLIENT_PATTERN =\n /axios\\/|curl\\/|(?:^|[\\s(])got(?:\\/|[\\s(])|\\bcolly\\b|Electron\\/|node-fetch\\/|python-requests\\/|Go-http-client\\/|okhttp\\/|aiohttp\\/|Deno\\//i\n\nexport function isAiBot(userAgent: string | null | undefined): boolean {\n if (!userAgent) return false\n return AI_BOT_PATTERN.test(userAgent)\n}\n\nexport function isHttpClient(userAgent: string | null | undefined): boolean {\n if (!userAgent) return false\n return HTTP_CLIENT_PATTERN.test(userAgent)\n}\n\n/**\n * Map a user-agent string to a coarse, human-readable label. Returns one of:\n *\n * - A branded-crawler name (`'Claude'`, `'ChatGPT'`, …) — pair with\n * {@link isAiBot} for `is_ai_bot: true` segmentation.\n * - An HTTP-library name (`'curl'`, `'axios'`, `'got'`, `'colly'`,\n * `'Electron'`, …) — hint of a coding agent or automation; not\n * conclusive. Pair with {@link isHttpClient}.\n * - `'Browser'` for typical desktop browsers (possibly spoofed by\n * Playwright-based agents like Aider/OpenCode — this label alone can't\n * tell you).\n * - `'Other'` for anything unrecognised or empty input.\n */\nexport function parseBotName(userAgent: string | null | undefined): string {\n if (!userAgent || typeof userAgent !== 'string') return 'Other'\n const s = userAgent.toLowerCase()\n\n // Publicly declared AI crawlers (high confidence).\n if (s.includes('chatgpt-user') || s.includes('gptbot') || s.includes('oai-searchbot') || s.includes('openai'))\n return 'ChatGPT'\n if (\n s.includes('claudebot') ||\n s.includes('claude-user') ||\n s.includes('claude-searchbot') ||\n s.includes('claude-web') ||\n s.includes('anthropic')\n )\n return 'Claude'\n if (s.includes('perplexitybot') || s.includes('perplexity-user')) return 'Perplexity'\n if (s.includes('ccbot')) return 'Common Crawl'\n if (\n s.includes('google-extended') ||\n s.includes('googlebot') ||\n s.includes('google-cloudvertexbot') ||\n s.includes('google-agent') ||\n s.includes('googleagent-mariner') ||\n s.includes('gemini-deep-research')\n )\n return 'Google'\n if (s.includes('applebot')) return 'Apple'\n if (s.includes('bingbot')) return 'Bing'\n if (s.includes('bytespider')) return 'Bytespider'\n if (s.includes('amazonbot') || s.includes('amzn-searchbot') || s.includes('novaact')) return 'Amazon'\n if (\n s.includes('meta-externalagent') ||\n s.includes('meta-externalfetcher') ||\n s.includes('meta-webindexer') ||\n s.includes('facebookbot')\n )\n return 'Meta'\n if (s.includes('mistralai-user')) return 'Mistral'\n if (s.includes('duckassistbot')) return 'DuckDuckGo'\n if (s.includes('youbot')) return 'You.com'\n if (s.includes('diffbot')) return 'Diffbot'\n if (s.includes('ai2bot')) return 'AI2'\n if (s.includes('cohere')) return 'Cohere'\n if (s.includes('cursor')) return 'Cursor'\n if (s.includes('windsurf')) return 'Windsurf'\n if (s.includes('deepseek')) return 'DeepSeek'\n if (s.includes('pangubot')) return 'Huawei'\n if (s.includes('webzio') || s.includes('omgili')) return 'Webz.io'\n if (s.includes('timpibot')) return 'Timpi'\n if (s.includes('grok') || s.includes('xai-')) return 'xAI'\n if (s.includes('manus-user')) return 'Manus'\n if (s.includes('quillbot')) return 'QuillBot'\n if (s.includes('azureai-searchbot')) return 'Microsoft'\n if (s.includes('mycentralaiscraperbot')) return 'MyCentralAI'\n if (s.includes('petalbot')) return 'PetalBot'\n\n // SEO crawlers and monitoring bots.\n if (s.includes('ahrefsbot')) return 'Ahrefs'\n if (s.includes('semrushbot')) return 'Semrush'\n if (s.includes('mj12bot')) return 'Majestic'\n if (s.includes('dotbot')) return 'Moz'\n if (s.includes('rogerbot')) return 'Moz'\n if (s.includes('screaming frog')) return 'Screaming Frog'\n if (s.includes('sitebulb')) return 'Sitebulb'\n if (s.includes('linkfluence')) return 'Linkfluence'\n if (s.includes('dataforseo')) return 'DataForSEO'\n if (s.includes('serpstatbot')) return 'Serpstat'\n\n // Monitoring and feed bots.\n if (s.includes('uptimerobot')) return 'UptimeRobot'\n if (s.includes('pingdom')) return 'Pingdom'\n if (s.includes('statuscake')) return 'StatusCake'\n if (s.includes('newrelicpinger')) return 'New Relic'\n if (s.includes('datadogagent') || s.includes('datadog')) return 'Datadog'\n if (s.includes('slackbot')) return 'Slack'\n if (s.includes('twitterbot')) return 'Twitter'\n if (s.includes('linkedinbot')) return 'LinkedIn'\n if (s.includes('discordbot')) return 'Discord'\n if (s.includes('telegrambot')) return 'Telegram'\n if (s.includes('whatsapp')) return 'WhatsApp'\n\n // AI search and indexing bots.\n if (s.includes('linkupbot')) return 'Linkup'\n if (s.includes('sogou')) return 'Sogou'\n if (s.includes('yandexbot')) return 'Yandex'\n if (s.includes('baiduspider')) return 'Baidu'\n\n // Link preview fetchers.\n if (s.includes('facebookexternalhit')) return 'Facebook'\n if (s.includes('com.apple.webkit')) return 'Apple URL Preview'\n\n // Uptime and monitoring.\n if (s.includes('ohdear')) return 'Oh Dear'\n\n // Generic scrapers.\n if (s.includes('scrapy')) return 'Scrapy'\n if (s.includes('headlesschrome')) return 'Headless Chrome'\n if (s.includes('phantomjs')) return 'PhantomJS'\n if (s.includes('wget')) return 'wget'\n if (s.includes('httpie')) return 'HTTPie'\n if (s.includes('guzzlehttp')) return 'Guzzle'\n\n // HTTP library / runtime signatures (loose — coding agent or automation).\n // Check Electron before Browser since Electron UAs contain Chrome/Safari.\n if (s.includes('electron/')) return 'Electron'\n if (/curl\\//.test(s)) return 'curl'\n if (/axios\\//.test(s)) return 'axios'\n if (/(?:^|[\\s(])got(?:\\/|[\\s(])/.test(s)) return 'got'\n if (/\\bcolly\\b/.test(s)) return 'colly'\n if (/node-fetch\\//.test(s)) return 'node-fetch'\n if (/python-requests\\//.test(s)) return 'python-requests'\n if (/go-http-client\\//.test(s)) return 'Go http client'\n if (/okhttp\\//.test(s)) return 'OkHttp'\n if (/aiohttp\\//.test(s)) return 'aiohttp'\n if (/deno\\//.test(s)) return 'Deno'\n\n // Real browsers (or UAs spoofed to look like them — see Aider/OpenCode note).\n if (s.includes('mozilla') || s.includes('chrome') || s.includes('safari') || s.includes('firefox'))\n return 'Browser'\n\n return 'Other'\n}\n\n/**\n * Return the first product token from a UA header, useful for segmenting by\n * client without hard-coding every bot name. Falls back to `'Other'` for empty\n * input.\n */\nexport function firstUserAgentProduct(userAgent: string | null | undefined): string {\n if (!userAgent || typeof userAgent !== 'string') return 'Other'\n const compatibleMatch = userAgent.match(/compatible;\\s*([^/;\\s]+)(?:\\/[^\\s;]*)?/i)\n if (compatibleMatch && compatibleMatch[1]) return compatibleMatch[1].trim()\n const first = userAgent.trim().split('/')[0]?.trim().split(/\\s+/)[0]?.trim()\n return first || 'Other'\n}\n\n/**\n * Detect likely headless/automated browsers by checking for missing headers\n * that real browsers always send. Playwright, Puppeteer, and similar tools\n * spoof the UA but often omit standard browser headers.\n *\n * Signals checked (each scores 1 point):\n * - Missing `Accept-Language` — every real browser sends this\n * - Missing `Sec-Fetch-Mode` — sent by all modern browsers\n * - Missing `Sec-CH-UA` — Client Hints, Chromium 89+\n * - `Sec-CH-UA` contains \"HeadlessChrome\"\n * - Missing or bare Accept header — browsers send detailed accept lists\n * - `Connection: close` with browser UA — browsers use keep-alive\n *\n * Returns a score (0-6), the signals that fired, and a boolean `likely`\n * flag (score >= 2 with a browser-like UA).\n */\nexport function detectHeadless(req: Request): HeadlessDetection {\n const signals: string[] = []\n const ua = (req.headers.get('user-agent') || '').toLowerCase()\n const isBrowserUA =\n ua.includes('mozilla') || ua.includes('chrome') || ua.includes('safari') || ua.includes('firefox')\n\n if (!isBrowserUA) return { score: 0, signals: [], likely: false }\n\n if (!req.headers.get('accept-language')) {\n signals.push('missing-accept-language')\n }\n if (!req.headers.get('sec-fetch-mode')) {\n signals.push('missing-sec-fetch-mode')\n }\n const secChUa = req.headers.get('sec-ch-ua')\n if (!secChUa) {\n signals.push('missing-sec-ch-ua')\n } else if (secChUa.toLowerCase().includes('headlesschrome')) {\n signals.push('headless-chrome-hint')\n }\n const accept = req.headers.get('accept') || ''\n if (!accept || accept === '*/*') {\n signals.push('missing-or-bare-accept')\n }\n if ((req.headers.get('connection') || '').toLowerCase() === 'close') {\n signals.push('connection-close')\n }\n\n const score = signals.length\n return { score, signals, likely: score >= 2 }\n}\n\nexport interface HeadlessDetection {\n /** Number of suspicious signals found (0-6). */\n score: number\n /** Names of the specific signals that fired. */\n signals: string[]\n /** True when score >= 2 — strong headless indication. */\n likely: boolean\n}\n\nexport type AgentKind =\n | 'declared-crawler'\n | 'coding-agent-hint'\n | 'headless-likely'\n | 'browser'\n | 'other'\n\nexport interface AgentClassification {\n /**\n * Categorical tag for the request:\n *\n * - `'declared-crawler'` — {@link AI_BOT_PATTERN} matched. High confidence.\n * - `'coding-agent-hint'` — {@link HTTP_CLIENT_PATTERN} matched. Loose\n * signal; could be a coding agent, a curl script, or any automation.\n * - `'headless-likely'` — Browser-like UA but missing standard headers.\n * Strong signal of Playwright/Puppeteer automation (Aider, OpenCode, etc.).\n * - `'browser'` — Looks like a real browser with expected headers present.\n * - `'other'` — Unrecognised or empty.\n */\n kind: AgentKind\n /** Human-readable label, same string {@link parseBotName} returns. */\n label: string\n /** Strict: `true` only when the UA matches a branded AI crawler. */\n isAiBot: boolean\n /** Loose: `true` for known HTTP-library / automation UAs. */\n codingAgentHint: boolean\n /** Headless browser detection result. Only populated when `req` is passed. */\n headless?: HeadlessDetection\n}\n\n/**\n * UA-only classification. Use {@link classifyRequest} for full detection\n * including headless browser heuristics.\n */\nexport function classifyAgent(userAgent: string | null | undefined): AgentClassification {\n const label = parseBotName(userAgent)\n const aiBot = isAiBot(userAgent)\n const httpClient = isHttpClient(userAgent)\n\n let kind: AgentKind\n if (aiBot) kind = 'declared-crawler'\n else if (httpClient) kind = 'coding-agent-hint'\n else if (label === 'Browser') kind = 'browser'\n else kind = 'other'\n\n return { kind, label, isAiBot: aiBot, codingAgentHint: httpClient }\n}\n\n/**\n * Full request classification — combines UA parsing with header-based\n * headless detection. When a browser-like UA is missing standard headers,\n * the kind is promoted from `'browser'` to `'headless-likely'`.\n */\nexport function classifyRequest(req: Request): AgentClassification {\n const userAgent = req.headers.get('user-agent') || ''\n const base = classifyAgent(userAgent)\n const headless = detectHeadless(req)\n\n let kind = base.kind\n if (kind === 'browser' && headless.likely) {\n kind = 'headless-likely'\n }\n\n return { ...base, kind, headless }\n}\n","import { isAiBot } from './bots.js'\n\nexport type MarkdownServeReason =\n | 'ua-rewrite'\n | 'md-suffix'\n | 'accept-header'\n\nexport interface MarkdownDecision {\n /** Why this request should be served Markdown. */\n reason: MarkdownServeReason\n /**\n * The request's original logical path, with any trailing `.md` stripped.\n * Use this when mapping to a mirror file.\n */\n strippedPath: string\n}\n\n/**\n * Decide whether the request should be served Markdown instead of HTML.\n * Returns `null` when the request should go through your normal handler.\n *\n * Covers three triggers:\n * - Known AI-bot UA on any URL (`ua-rewrite`)\n * - Explicit `.md` suffix on the URL (`md-suffix`)\n * - `Accept: text/markdown` header (`accept-header`)\n *\n * This helper intentionally does not perform the rewrite itself — routing is\n * framework-specific (NextResponse.rewrite for Next.js, ctx.rewrite for\n * Hono, etc.). Use the returned decision to build the appropriate response.\n */\nexport function markdownServeDecision(req: Request): MarkdownDecision | null {\n let pathname = '/'\n try {\n pathname = new URL(req.url).pathname\n } catch {\n pathname = req.url || '/'\n }\n\n const ua = req.headers.get('user-agent') || ''\n if (isAiBot(ua)) {\n return { reason: 'ua-rewrite', strippedPath: pathname }\n }\n\n if (pathname.endsWith('.md')) {\n return { reason: 'md-suffix', strippedPath: pathname.replace(/\\.md$/, '') }\n }\n\n const accept = req.headers.get('accept') || ''\n if (accept.includes('text/markdown')) {\n return { reason: 'accept-header', strippedPath: pathname }\n }\n\n return null\n}\n\nexport interface MarkdownHeadersInput {\n /**\n * If provided, rendered as `x-markdown-tokens` so agents can budget context\n * before parsing the body. Typically `Math.ceil(body.length / 4)`.\n */\n tokens?: number\n /**\n * Content-Signal directive (see contentsignals.org). Defaults to\n * `'search=yes, ai-input=yes, ai-train=no'` — change if you want to permit\n * training or restrict indexing.\n */\n contentSignal?: string\n}\n\n/**\n * Build the set of response headers to attach to a Markdown response. Safe\n * defaults: UTF-8 text/markdown, Vary: accept, and a Content-Signal directive\n * that permits search + agent input but denies training.\n */\nexport function markdownHeaders(input: MarkdownHeadersInput = {}): Record<string, string> {\n const headers: Record<string, string> = {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Content-Signal': input.contentSignal ?? 'search=yes, ai-input=yes, ai-train=no',\n Vary: 'accept'\n }\n if (typeof input.tokens === 'number' && input.tokens > 0) {\n headers['x-markdown-tokens'] = Math.max(1, Math.ceil(input.tokens)).toString()\n }\n return headers\n}\n\nexport interface SynthesizePointerInput {\n origin: string\n pathname: string\n /** URL of the site's curated index, usually `/llms.txt`. */\n llmsTxtUrl?: string\n /** URL of the full enumerated index, usually `/llms-full.txt`. */\n llmsFullTxtUrl?: string\n /** URL of the machine-readable path manifest, usually `/md/index.json`. */\n markdownIndexUrl?: string\n /** Site name to title the pointer document. Defaults to the origin hostname. */\n siteName?: string\n}\n\n/**\n * Generate a minimal pointer Markdown document for URLs that don't have a\n * pre-built mirror. Keeps the `Accept: text/markdown` contract intact\n * site-wide — agents always get *something* parseable, not a 404.\n */\nexport function synthesizeMarkdownPointer(input: SynthesizePointerInput): string {\n const site =\n input.siteName ??\n (() => {\n try {\n return new URL(input.origin).hostname\n } catch {\n return input.origin\n }\n })()\n const url = `${input.origin}${input.pathname}`\n const lines: string[] = [`# ${site}`, '', `This page (${url}) does not have a dedicated Markdown mirror yet.`, '']\n const links: string[] = []\n if (input.llmsTxtUrl) links.push(`- [${input.llmsTxtUrl}](${input.llmsTxtUrl}) — curated index of docs`)\n if (input.llmsFullTxtUrl)\n links.push(`- [${input.llmsFullTxtUrl}](${input.llmsFullTxtUrl}) — full enumerated index`)\n if (input.markdownIndexUrl)\n links.push(`- [${input.markdownIndexUrl}](${input.markdownIndexUrl}) — JSON index of all Markdown paths`)\n if (links.length) {\n lines.push('For machine-readable documentation, see:', '', ...links, '')\n }\n return lines.join('\\n')\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/bots.ts","../src/markdown.ts"],"names":["AI_BOT_PATTERN","isAiBot","userAgent","markdownServeDecision","req","pathname","hasSuffix","strippedPath","ua","markdownHeaders","input","headers","synthesizeMarkdownPointer","site","url","lines","links"],"mappings":"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","file":"markdown.js","sourcesContent":["/**\n * User-agent substrings that identify **publicly declared** AI crawlers — the\n * branded bots that identify themselves by name (OpenAI's GPTBot, Anthropic's\n * ClaudeBot, Perplexity-User, Google-Extended, etc.). High-confidence: when\n * this matches, the request almost certainly comes from that vendor's crawler\n * fleet.\n *\n * Does NOT include **coding-agent traffic** (Claude Code, Cline, Cursor,\n * Windsurf, Aider, OpenCode, VS Code). Those tools use generic HTTP library\n * UAs (axios, curl, got, colly, Electron) or spoof full browser UAs — they\n * can't be distinguished from non-AI traffic by UA alone. See\n * {@link HTTP_CLIENT_PATTERN} for the loose heuristic layer.\n *\n * Sources consulted when updating: darkvisitors.com, vendor docs from OpenAI,\n * Anthropic, Google, Perplexity, Cohere, Apple, Bytedance.\n */\nexport const AI_BOT_PATTERN =\n /ClaudeBot|Claude-User|Claude-SearchBot|Claude-Web|Anthropic|GPTBot|ChatGPT-User|OAI-SearchBot|PerplexityBot|Perplexity-User|Google-Extended|Google-CloudVertexBot|Google-Agent|GoogleAgent-Mariner|Gemini-Deep-Research|Applebot|cohere|Bytespider|CCBot|Amazonbot|Amzn-SearchBot|NovaAct|AzureAI-SearchBot|Meta-ExternalAgent|meta-externalfetcher|meta-webindexer|FacebookBot|DuckAssistBot|MistralAI-User|YouBot|AI2Bot|Diffbot|DeepSeek|PanguBot|Webzio-Extended|omgili|Timpibot|Grok|Manus-User|quillbot|MyCentralAIScraperBot|Cursor|Windsurf/i\n\n/**\n * HTTP library / runtime signatures frequently used by coding agents. Matching\n * any of these is a **loose** signal — legitimate curl scripts, CI jobs, and\n * server-to-server traffic use the same libraries. Use this for the wider\n * net (`coding_agent_hint: true`) and pair with other signals (request\n * shape, JA4 fingerprint, path patterns) for higher confidence.\n *\n * Based on behavioural signatures observed by Addy Osmani:\n * Claude Code → axios/1.8.4\n * Cline, Junie → curl/8.4.0\n * Cursor → got (sindresorhus/got)\n * Windsurf → colly\n * VS Code → Electron / Chromium\n *\n * Aider and OpenCode use Playwright-driven full Mozilla/Safari UAs and are\n * indistinguishable from real browsers at the UA layer.\n */\nexport const HTTP_CLIENT_PATTERN =\n /axios\\/|curl\\/|(?:^|[\\s(])got(?:\\/|[\\s(])|\\bcolly\\b|Electron\\/|node-fetch\\/|python-requests\\/|Go-http-client\\/|okhttp\\/|aiohttp\\/|Deno\\//i\n\nexport function isAiBot(userAgent: string | null | undefined): boolean {\n if (!userAgent) return false\n return AI_BOT_PATTERN.test(userAgent)\n}\n\nexport function isHttpClient(userAgent: string | null | undefined): boolean {\n if (!userAgent) return false\n return HTTP_CLIENT_PATTERN.test(userAgent)\n}\n\n/**\n * Map a user-agent string to a coarse, human-readable label. Returns one of:\n *\n * - A branded-crawler name (`'Claude'`, `'ChatGPT'`, …) — pair with\n * {@link isAiBot} for `is_ai_bot: true` segmentation.\n * - An HTTP-library name (`'curl'`, `'axios'`, `'got'`, `'colly'`,\n * `'Electron'`, …) — hint of a coding agent or automation; not\n * conclusive. Pair with {@link isHttpClient}.\n * - `'Browser'` for typical desktop browsers (possibly spoofed by\n * Playwright-based agents like Aider/OpenCode — this label alone can't\n * tell you).\n * - `'Other'` for anything unrecognised or empty input.\n */\nexport function parseBotName(userAgent: string | null | undefined): string {\n if (!userAgent || typeof userAgent !== 'string') return 'Other'\n const s = userAgent.toLowerCase()\n\n // Publicly declared AI crawlers (high confidence).\n if (s.includes('chatgpt-user') || s.includes('gptbot') || s.includes('oai-searchbot') || s.includes('openai'))\n return 'ChatGPT'\n if (\n s.includes('claudebot') ||\n s.includes('claude-user') ||\n s.includes('claude-searchbot') ||\n s.includes('claude-web') ||\n s.includes('anthropic')\n )\n return 'Claude'\n if (s.includes('perplexitybot') || s.includes('perplexity-user')) return 'Perplexity'\n if (s.includes('ccbot')) return 'Common Crawl'\n if (\n s.includes('google-extended') ||\n s.includes('googlebot') ||\n s.includes('google-cloudvertexbot') ||\n s.includes('google-agent') ||\n s.includes('googleagent-mariner') ||\n s.includes('gemini-deep-research')\n )\n return 'Google'\n if (s.includes('applebot')) return 'Apple'\n if (s.includes('bingbot')) return 'Bing'\n if (s.includes('bytespider')) return 'Bytespider'\n if (s.includes('amazonbot') || s.includes('amzn-searchbot') || s.includes('novaact')) return 'Amazon'\n if (\n s.includes('meta-externalagent') ||\n s.includes('meta-externalfetcher') ||\n s.includes('meta-webindexer') ||\n s.includes('facebookbot')\n )\n return 'Meta'\n if (s.includes('mistralai-user')) return 'Mistral'\n if (s.includes('duckassistbot')) return 'DuckDuckGo'\n if (s.includes('youbot')) return 'You.com'\n if (s.includes('diffbot')) return 'Diffbot'\n if (s.includes('ai2bot')) return 'AI2'\n if (s.includes('cohere')) return 'Cohere'\n if (s.includes('cursor')) return 'Cursor'\n if (s.includes('windsurf')) return 'Windsurf'\n if (s.includes('deepseek')) return 'DeepSeek'\n if (s.includes('pangubot')) return 'Huawei'\n if (s.includes('webzio') || s.includes('omgili')) return 'Webz.io'\n if (s.includes('timpibot')) return 'Timpi'\n if (s.includes('grok') || s.includes('xai-')) return 'xAI'\n if (s.includes('manus-user')) return 'Manus'\n if (s.includes('quillbot')) return 'QuillBot'\n if (s.includes('azureai-searchbot')) return 'Microsoft'\n if (s.includes('mycentralaiscraperbot')) return 'MyCentralAI'\n if (s.includes('petalbot')) return 'PetalBot'\n\n // SEO crawlers and monitoring bots.\n if (s.includes('ahrefsbot')) return 'Ahrefs'\n if (s.includes('semrushbot')) return 'Semrush'\n if (s.includes('mj12bot')) return 'Majestic'\n if (s.includes('dotbot')) return 'Moz'\n if (s.includes('rogerbot')) return 'Moz'\n if (s.includes('screaming frog')) return 'Screaming Frog'\n if (s.includes('sitebulb')) return 'Sitebulb'\n if (s.includes('linkfluence')) return 'Linkfluence'\n if (s.includes('dataforseo')) return 'DataForSEO'\n if (s.includes('serpstatbot')) return 'Serpstat'\n\n // Monitoring and feed bots.\n if (s.includes('uptimerobot')) return 'UptimeRobot'\n if (s.includes('pingdom')) return 'Pingdom'\n if (s.includes('statuscake')) return 'StatusCake'\n if (s.includes('newrelicpinger')) return 'New Relic'\n if (s.includes('datadogagent') || s.includes('datadog')) return 'Datadog'\n if (s.includes('slackbot')) return 'Slack'\n if (s.includes('twitterbot')) return 'Twitter'\n if (s.includes('linkedinbot')) return 'LinkedIn'\n if (s.includes('discordbot')) return 'Discord'\n if (s.includes('telegrambot')) return 'Telegram'\n if (s.includes('whatsapp')) return 'WhatsApp'\n\n // AI search and indexing bots.\n if (s.includes('linkupbot')) return 'Linkup'\n if (s.includes('sogou')) return 'Sogou'\n if (s.includes('yandexbot')) return 'Yandex'\n if (s.includes('baiduspider')) return 'Baidu'\n\n // Link preview fetchers.\n if (s.includes('facebookexternalhit')) return 'Facebook'\n if (s.includes('com.apple.webkit')) return 'Apple URL Preview'\n\n // Uptime and monitoring.\n if (s.includes('ohdear')) return 'Oh Dear'\n\n // Generic scrapers.\n if (s.includes('scrapy')) return 'Scrapy'\n if (s.includes('headlesschrome')) return 'Headless Chrome'\n if (s.includes('phantomjs')) return 'PhantomJS'\n if (s.includes('wget')) return 'wget'\n if (s.includes('httpie')) return 'HTTPie'\n if (s.includes('guzzlehttp')) return 'Guzzle'\n\n // HTTP library / runtime signatures (loose — coding agent or automation).\n // Check Electron before Browser since Electron UAs contain Chrome/Safari.\n if (s.includes('electron/')) return 'Electron'\n if (/curl\\//.test(s)) return 'curl'\n if (/axios\\//.test(s)) return 'axios'\n if (/(?:^|[\\s(])got(?:\\/|[\\s(])/.test(s)) return 'got'\n if (/\\bcolly\\b/.test(s)) return 'colly'\n if (/node-fetch\\//.test(s)) return 'node-fetch'\n if (/python-requests\\//.test(s)) return 'python-requests'\n if (/go-http-client\\//.test(s)) return 'Go http client'\n if (/okhttp\\//.test(s)) return 'OkHttp'\n if (/aiohttp\\//.test(s)) return 'aiohttp'\n if (/deno\\//.test(s)) return 'Deno'\n\n // Real browsers (or UAs spoofed to look like them — see Aider/OpenCode note).\n if (s.includes('mozilla') || s.includes('chrome') || s.includes('safari') || s.includes('firefox'))\n return 'Browser'\n\n return 'Other'\n}\n\n/**\n * Return the first product token from a UA header, useful for segmenting by\n * client without hard-coding every bot name. Falls back to `'Other'` for empty\n * input.\n */\nexport function firstUserAgentProduct(userAgent: string | null | undefined): string {\n if (!userAgent || typeof userAgent !== 'string') return 'Other'\n const compatibleMatch = userAgent.match(/compatible;\\s*([^/;\\s]+)(?:\\/[^\\s;]*)?/i)\n if (compatibleMatch && compatibleMatch[1]) return compatibleMatch[1].trim()\n const first = userAgent.trim().split('/')[0]?.trim().split(/\\s+/)[0]?.trim()\n return first || 'Other'\n}\n\n/**\n * Detect likely headless/automated browsers by checking for missing headers\n * that real browsers always send. Playwright, Puppeteer, and similar tools\n * spoof the UA but often omit standard browser headers.\n *\n * Signals checked (each scores 1 point):\n * - Missing `Accept-Language` — every real browser sends this\n * - Missing `Sec-Fetch-Mode` — sent by all modern browsers\n * - Missing `Sec-CH-UA` — Client Hints, Chromium 89+\n * - `Sec-CH-UA` contains \"HeadlessChrome\"\n * - Missing or bare Accept header — browsers send detailed accept lists\n * - `Connection: close` with browser UA — browsers use keep-alive\n *\n * Returns a score (0-6), the signals that fired, and a boolean `likely`\n * flag (score >= 2 with a browser-like UA).\n */\nexport function detectHeadless(req: Request): HeadlessDetection {\n const signals: string[] = []\n const ua = (req.headers.get('user-agent') || '').toLowerCase()\n const isBrowserUA =\n ua.includes('mozilla') || ua.includes('chrome') || ua.includes('safari') || ua.includes('firefox')\n\n if (!isBrowserUA) return { score: 0, signals: [], likely: false }\n\n if (!req.headers.get('accept-language')) {\n signals.push('missing-accept-language')\n }\n if (!req.headers.get('sec-fetch-mode')) {\n signals.push('missing-sec-fetch-mode')\n }\n const secChUa = req.headers.get('sec-ch-ua')\n if (!secChUa) {\n signals.push('missing-sec-ch-ua')\n } else if (secChUa.toLowerCase().includes('headlesschrome')) {\n signals.push('headless-chrome-hint')\n }\n const accept = req.headers.get('accept') || ''\n if (!accept || accept === '*/*') {\n signals.push('missing-or-bare-accept')\n }\n if ((req.headers.get('connection') || '').toLowerCase() === 'close') {\n signals.push('connection-close')\n }\n\n const score = signals.length\n return { score, signals, likely: score >= 2 }\n}\n\nexport interface HeadlessDetection {\n /** Number of suspicious signals found (0-6). */\n score: number\n /** Names of the specific signals that fired. */\n signals: string[]\n /** True when score >= 2 — strong headless indication. */\n likely: boolean\n}\n\nexport type AgentKind =\n | 'declared-crawler'\n | 'coding-agent-hint'\n | 'headless-likely'\n | 'browser'\n | 'other'\n\nexport interface AgentClassification {\n /**\n * Categorical tag for the request:\n *\n * - `'declared-crawler'` — {@link AI_BOT_PATTERN} matched. High confidence.\n * - `'coding-agent-hint'` — {@link HTTP_CLIENT_PATTERN} matched. Loose\n * signal; could be a coding agent, a curl script, or any automation.\n * - `'headless-likely'` — Browser-like UA but missing standard headers.\n * Strong signal of Playwright/Puppeteer automation (Aider, OpenCode, etc.).\n * - `'browser'` — Looks like a real browser with expected headers present.\n * - `'other'` — Unrecognised or empty.\n */\n kind: AgentKind\n /** Human-readable label, same string {@link parseBotName} returns. */\n label: string\n /** Strict: `true` only when the UA matches a branded AI crawler. */\n isAiBot: boolean\n /** Loose: `true` for known HTTP-library / automation UAs. */\n codingAgentHint: boolean\n /** Headless browser detection result. Only populated when `req` is passed. */\n headless?: HeadlessDetection\n}\n\n/**\n * UA-only classification. Use {@link classifyRequest} for full detection\n * including headless browser heuristics.\n */\nexport function classifyAgent(userAgent: string | null | undefined): AgentClassification {\n const label = parseBotName(userAgent)\n const aiBot = isAiBot(userAgent)\n const httpClient = isHttpClient(userAgent)\n\n let kind: AgentKind\n if (aiBot) kind = 'declared-crawler'\n else if (httpClient) kind = 'coding-agent-hint'\n else if (label === 'Browser') kind = 'browser'\n else kind = 'other'\n\n return { kind, label, isAiBot: aiBot, codingAgentHint: httpClient }\n}\n\n/**\n * Full request classification — combines UA parsing with header-based\n * headless detection. When a browser-like UA is missing standard headers,\n * the kind is promoted from `'browser'` to `'headless-likely'`.\n */\nexport function classifyRequest(req: Request): AgentClassification {\n const userAgent = req.headers.get('user-agent') || ''\n const base = classifyAgent(userAgent)\n const headless = detectHeadless(req)\n\n let kind = base.kind\n let label = base.label\n if (kind === 'browser' && headless.likely) {\n kind = 'headless-likely'\n // Relabel too. Leaving it as 'Browser' meant automation with a spoofed\n // browser UA — 79% of one production site's agent traffic — was\n // indistinguishable from a human in any `bot_name` breakdown, and was\n // silently excluded by the obvious `bot_name != 'Browser'` filter.\n label = 'Headless'\n }\n\n return { ...base, kind, label, headless }\n}\n","import { isAiBot } from './bots.js'\n\nexport type MarkdownServeReason =\n | 'ua-rewrite'\n | 'md-suffix'\n | 'accept-header'\n\nexport interface MarkdownDecision {\n /** Why this request should be served Markdown. */\n reason: MarkdownServeReason\n /**\n * The request's original logical path, with any trailing `.md` stripped.\n * Use this when mapping to a mirror file.\n */\n strippedPath: string\n}\n\n/**\n * Decide whether the request should be served Markdown instead of HTML.\n * Returns `null` when the request should go through your normal handler.\n *\n * Covers three triggers:\n * - Known AI-bot UA on any URL (`ua-rewrite`)\n * - Explicit `.md` suffix on the URL (`md-suffix`)\n * - `Accept: text/markdown` header (`accept-header`)\n *\n * This helper intentionally does not perform the rewrite itself — routing is\n * framework-specific (NextResponse.rewrite for Next.js, ctx.rewrite for\n * Hono, etc.). Use the returned decision to build the appropriate response.\n */\nexport function markdownServeDecision(req: Request): MarkdownDecision | null {\n let pathname = '/'\n try {\n pathname = new URL(req.url).pathname\n } catch {\n pathname = req.url || '/'\n }\n\n // Strip once, up front. Stripping only inside the md-suffix branch meant an\n // AI bot following a `.md` link — the very links this library advertises via\n // `Link: rel=\"alternate\"` and llms.txt — matched `ua-rewrite` first and kept\n // the extension, so callers building `/md${strippedPath}.md` requested\n // `/md/docs/intro.md.md` and silently fell back to a pointer document.\n const hasSuffix = pathname.endsWith('.md')\n const strippedPath = hasSuffix ? pathname.slice(0, -3) : pathname\n\n const ua = req.headers.get('user-agent') || ''\n if (isAiBot(ua)) {\n return { reason: hasSuffix ? 'md-suffix' : 'ua-rewrite', strippedPath }\n }\n\n if (hasSuffix) {\n return { reason: 'md-suffix', strippedPath }\n }\n\n const accept = req.headers.get('accept') || ''\n if (accept.includes('text/markdown')) {\n return { reason: 'accept-header', strippedPath }\n }\n\n return null\n}\n\nexport interface MarkdownHeadersInput {\n /**\n * If provided, rendered as `x-markdown-tokens` so agents can budget context\n * before parsing the body. Typically `Math.ceil(body.length / 4)`.\n */\n tokens?: number\n /**\n * Content-Signal directive (see contentsignals.org). Defaults to\n * `'search=yes, ai-input=yes, ai-train=no'` — change if you want to permit\n * training or restrict indexing.\n */\n contentSignal?: string\n}\n\n/**\n * Build the set of response headers to attach to a Markdown response. Safe\n * defaults: UTF-8 text/markdown, Vary: accept, and a Content-Signal directive\n * that permits search + agent input but denies training.\n */\nexport function markdownHeaders(input: MarkdownHeadersInput = {}): Record<string, string> {\n const headers: Record<string, string> = {\n 'Content-Type': 'text/markdown; charset=utf-8',\n 'Content-Signal': input.contentSignal ?? 'search=yes, ai-input=yes, ai-train=no',\n Vary: 'accept'\n }\n if (typeof input.tokens === 'number' && input.tokens > 0) {\n headers['x-markdown-tokens'] = Math.max(1, Math.ceil(input.tokens)).toString()\n }\n return headers\n}\n\nexport interface SynthesizePointerInput {\n origin: string\n pathname: string\n /** URL of the site's curated index, usually `/llms.txt`. */\n llmsTxtUrl?: string\n /** URL of the full enumerated index, usually `/llms-full.txt`. */\n llmsFullTxtUrl?: string\n /** URL of the machine-readable path manifest, usually `/md/index.json`. */\n markdownIndexUrl?: string\n /** Site name to title the pointer document. Defaults to the origin hostname. */\n siteName?: string\n}\n\n/**\n * Generate a minimal pointer Markdown document for URLs that don't have a\n * pre-built mirror. Keeps the `Accept: text/markdown` contract intact\n * site-wide — agents always get *something* parseable, not a 404.\n */\nexport function synthesizeMarkdownPointer(input: SynthesizePointerInput): string {\n const site =\n input.siteName ??\n (() => {\n try {\n return new URL(input.origin).hostname\n } catch {\n return input.origin\n }\n })()\n const url = `${input.origin}${input.pathname}`\n const lines: string[] = [`# ${site}`, '', `This page (${url}) does not have a dedicated Markdown mirror yet.`, '']\n const links: string[] = []\n if (input.llmsTxtUrl) links.push(`- [${input.llmsTxtUrl}](${input.llmsTxtUrl}) — curated index of docs`)\n if (input.llmsFullTxtUrl)\n links.push(`- [${input.llmsFullTxtUrl}](${input.llmsFullTxtUrl}) — full enumerated index`)\n if (input.markdownIndexUrl)\n links.push(`- [${input.markdownIndexUrl}](${input.markdownIndexUrl}) — JSON index of all Markdown paths`)\n if (links.length) {\n lines.push('For machine-readable documentation, see:', '', ...links, '')\n }\n return lines.join('\\n')\n}\n"]}
|
|
@@ -7,8 +7,38 @@ interface CaptureEvent {
|
|
|
7
7
|
interface AnalyticsAdapter {
|
|
8
8
|
capture(event: CaptureEvent): Promise<void> | void;
|
|
9
9
|
}
|
|
10
|
+
interface BotVerificationLike {
|
|
11
|
+
verdict: string;
|
|
12
|
+
verified: boolean | null;
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
10
15
|
interface TrackVisitOptions {
|
|
11
16
|
analytics: AnalyticsAdapter;
|
|
17
|
+
/**
|
|
18
|
+
* Secret used to key the `distinctId` HMAC. Falls back to
|
|
19
|
+
* `AGENT_ANALYTICS_ID_SECRET`, then to a random per-instance value.
|
|
20
|
+
*
|
|
21
|
+
* Identifiers only correlate across instances and deploys when this is
|
|
22
|
+
* stable, and only stay non-reversible while it stays secret — the user agent
|
|
23
|
+
* ships in plaintext on the same event, so anyone holding the secret can
|
|
24
|
+
* recover the client IP by brute force.
|
|
25
|
+
*/
|
|
26
|
+
idSecret?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Called when capture fails — a rejected adapter, a non-2xx from the
|
|
29
|
+
* analytics backend, a malformed request. Errors never propagate to the
|
|
30
|
+
* response path, so without this a wrong API key is silent.
|
|
31
|
+
*/
|
|
32
|
+
onError?: (error: Error) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Identity verifier. Import `verifyRequest` from
|
|
35
|
+
* `@apideck/agent-analytics/verify` and pass it here to add
|
|
36
|
+
* `bot_verification` to the event.
|
|
37
|
+
*
|
|
38
|
+
* Injected rather than imported so the published IP range tables — the
|
|
39
|
+
* largest thing in the package — only reach bundles that use them.
|
|
40
|
+
*/
|
|
41
|
+
verify?: (req: Request) => BotVerificationLike;
|
|
12
42
|
/**
|
|
13
43
|
* Label describing how the request arrived (e.g. `'page-view'`, `'md-suffix'`,
|
|
14
44
|
* `'ua-rewrite'`). Emitted as a `source` property on the captured event so
|
|
@@ -55,6 +85,23 @@ interface TrackVisitOptions {
|
|
|
55
85
|
* Enable for log-style exports (e.g. Peec.ai's crawl-insights CSV).
|
|
56
86
|
*/
|
|
57
87
|
captureCountry?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* When `true`, check the request's claimed crawler identity against the
|
|
90
|
+
* vendor's published IP ranges and emit `bot_verified` (tri-state) plus
|
|
91
|
+
* `bot_verification` (`verified` | `spoofed` | `unverifiable` |
|
|
92
|
+
* `not-claimed`) on the event.
|
|
93
|
+
*
|
|
94
|
+
* UA strings are trivially forgeable — `curl -A "ChatGPT-User"` is
|
|
95
|
+
* indistinguishable from the real thing without this check. Off by default
|
|
96
|
+
* because it only means something when the client IP is trustworthy: on
|
|
97
|
+
* Vercel and Cloudflare the edge overwrites `x-forwarded-for`, but behind a
|
|
98
|
+
* proxy that passes the client-supplied header through, an attacker controls
|
|
99
|
+
* the value and a `verified` verdict is worthless.
|
|
100
|
+
*
|
|
101
|
+
* Only vendors that publish a machine-readable range feed can be verified —
|
|
102
|
+
* currently OpenAI, Anthropic, Perplexity, and Apple. Everything else yields
|
|
103
|
+
* `unverifiable`, never `spoofed`.
|
|
104
|
+
*/
|
|
58
105
|
/**
|
|
59
106
|
* When `true`, emit `region`, `city`, `latitude`, `longitude`, and
|
|
60
107
|
* `timezone` derived from Vercel's `x-vercel-ip-*` edge headers. Values
|
|
@@ -67,4 +114,4 @@ interface TrackVisitOptions {
|
|
|
67
114
|
captureGeo?: boolean;
|
|
68
115
|
}
|
|
69
116
|
|
|
70
|
-
export type { AnalyticsAdapter as A, CaptureEvent as C, TrackVisitOptions as T };
|
|
117
|
+
export type { AnalyticsAdapter as A, BotVerificationLike as B, CaptureEvent as C, TrackVisitOptions as T };
|
|
@@ -7,8 +7,38 @@ interface CaptureEvent {
|
|
|
7
7
|
interface AnalyticsAdapter {
|
|
8
8
|
capture(event: CaptureEvent): Promise<void> | void;
|
|
9
9
|
}
|
|
10
|
+
interface BotVerificationLike {
|
|
11
|
+
verdict: string;
|
|
12
|
+
verified: boolean | null;
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
10
15
|
interface TrackVisitOptions {
|
|
11
16
|
analytics: AnalyticsAdapter;
|
|
17
|
+
/**
|
|
18
|
+
* Secret used to key the `distinctId` HMAC. Falls back to
|
|
19
|
+
* `AGENT_ANALYTICS_ID_SECRET`, then to a random per-instance value.
|
|
20
|
+
*
|
|
21
|
+
* Identifiers only correlate across instances and deploys when this is
|
|
22
|
+
* stable, and only stay non-reversible while it stays secret — the user agent
|
|
23
|
+
* ships in plaintext on the same event, so anyone holding the secret can
|
|
24
|
+
* recover the client IP by brute force.
|
|
25
|
+
*/
|
|
26
|
+
idSecret?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Called when capture fails — a rejected adapter, a non-2xx from the
|
|
29
|
+
* analytics backend, a malformed request. Errors never propagate to the
|
|
30
|
+
* response path, so without this a wrong API key is silent.
|
|
31
|
+
*/
|
|
32
|
+
onError?: (error: Error) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Identity verifier. Import `verifyRequest` from
|
|
35
|
+
* `@apideck/agent-analytics/verify` and pass it here to add
|
|
36
|
+
* `bot_verification` to the event.
|
|
37
|
+
*
|
|
38
|
+
* Injected rather than imported so the published IP range tables — the
|
|
39
|
+
* largest thing in the package — only reach bundles that use them.
|
|
40
|
+
*/
|
|
41
|
+
verify?: (req: Request) => BotVerificationLike;
|
|
12
42
|
/**
|
|
13
43
|
* Label describing how the request arrived (e.g. `'page-view'`, `'md-suffix'`,
|
|
14
44
|
* `'ua-rewrite'`). Emitted as a `source` property on the captured event so
|
|
@@ -55,6 +85,23 @@ interface TrackVisitOptions {
|
|
|
55
85
|
* Enable for log-style exports (e.g. Peec.ai's crawl-insights CSV).
|
|
56
86
|
*/
|
|
57
87
|
captureCountry?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* When `true`, check the request's claimed crawler identity against the
|
|
90
|
+
* vendor's published IP ranges and emit `bot_verified` (tri-state) plus
|
|
91
|
+
* `bot_verification` (`verified` | `spoofed` | `unverifiable` |
|
|
92
|
+
* `not-claimed`) on the event.
|
|
93
|
+
*
|
|
94
|
+
* UA strings are trivially forgeable — `curl -A "ChatGPT-User"` is
|
|
95
|
+
* indistinguishable from the real thing without this check. Off by default
|
|
96
|
+
* because it only means something when the client IP is trustworthy: on
|
|
97
|
+
* Vercel and Cloudflare the edge overwrites `x-forwarded-for`, but behind a
|
|
98
|
+
* proxy that passes the client-supplied header through, an attacker controls
|
|
99
|
+
* the value and a `verified` verdict is worthless.
|
|
100
|
+
*
|
|
101
|
+
* Only vendors that publish a machine-readable range feed can be verified —
|
|
102
|
+
* currently OpenAI, Anthropic, Perplexity, and Apple. Everything else yields
|
|
103
|
+
* `unverifiable`, never `spoofed`.
|
|
104
|
+
*/
|
|
58
105
|
/**
|
|
59
106
|
* When `true`, emit `region`, `city`, `latitude`, `longitude`, and
|
|
60
107
|
* `timezone` derived from Vercel's `x-vercel-ip-*` edge headers. Values
|
|
@@ -67,4 +114,4 @@ interface TrackVisitOptions {
|
|
|
67
114
|
captureGeo?: boolean;
|
|
68
115
|
}
|
|
69
116
|
|
|
70
|
-
export type { AnalyticsAdapter as A, CaptureEvent as C, TrackVisitOptions as T };
|
|
117
|
+
export type { AnalyticsAdapter as A, BotVerificationLike as B, CaptureEvent as C, TrackVisitOptions as T };
|
package/dist/verify.cjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
'use strict';function b(t){if(!t||typeof t!="string")return "Other";let e=t.toLowerCase();return e.includes("chatgpt-user")||e.includes("gptbot")||e.includes("oai-searchbot")||e.includes("openai")?"ChatGPT":e.includes("claudebot")||e.includes("claude-user")||e.includes("claude-searchbot")||e.includes("claude-web")||e.includes("anthropic")?"Claude":e.includes("perplexitybot")||e.includes("perplexity-user")?"Perplexity":e.includes("ccbot")?"Common Crawl":e.includes("google-extended")||e.includes("googlebot")||e.includes("google-cloudvertexbot")||e.includes("google-agent")||e.includes("googleagent-mariner")||e.includes("gemini-deep-research")?"Google":e.includes("applebot")?"Apple":e.includes("bingbot")?"Bing":e.includes("bytespider")?"Bytespider":e.includes("amazonbot")||e.includes("amzn-searchbot")||e.includes("novaact")?"Amazon":e.includes("meta-externalagent")||e.includes("meta-externalfetcher")||e.includes("meta-webindexer")||e.includes("facebookbot")?"Meta":e.includes("mistralai-user")?"Mistral":e.includes("duckassistbot")?"DuckDuckGo":e.includes("youbot")?"You.com":e.includes("diffbot")?"Diffbot":e.includes("ai2bot")?"AI2":e.includes("cohere")?"Cohere":e.includes("cursor")?"Cursor":e.includes("windsurf")?"Windsurf":e.includes("deepseek")?"DeepSeek":e.includes("pangubot")?"Huawei":e.includes("webzio")||e.includes("omgili")?"Webz.io":e.includes("timpibot")?"Timpi":e.includes("grok")||e.includes("xai-")?"xAI":e.includes("manus-user")?"Manus":e.includes("quillbot")?"QuillBot":e.includes("azureai-searchbot")?"Microsoft":e.includes("mycentralaiscraperbot")?"MyCentralAI":e.includes("petalbot")?"PetalBot":e.includes("ahrefsbot")?"Ahrefs":e.includes("semrushbot")?"Semrush":e.includes("mj12bot")?"Majestic":e.includes("dotbot")||e.includes("rogerbot")?"Moz":e.includes("screaming frog")?"Screaming Frog":e.includes("sitebulb")?"Sitebulb":e.includes("linkfluence")?"Linkfluence":e.includes("dataforseo")?"DataForSEO":e.includes("serpstatbot")?"Serpstat":e.includes("uptimerobot")?"UptimeRobot":e.includes("pingdom")?"Pingdom":e.includes("statuscake")?"StatusCake":e.includes("newrelicpinger")?"New Relic":e.includes("datadogagent")||e.includes("datadog")?"Datadog":e.includes("slackbot")?"Slack":e.includes("twitterbot")?"Twitter":e.includes("linkedinbot")?"LinkedIn":e.includes("discordbot")?"Discord":e.includes("telegrambot")?"Telegram":e.includes("whatsapp")?"WhatsApp":e.includes("linkupbot")?"Linkup":e.includes("sogou")?"Sogou":e.includes("yandexbot")?"Yandex":e.includes("baiduspider")?"Baidu":e.includes("facebookexternalhit")?"Facebook":e.includes("com.apple.webkit")?"Apple URL Preview":e.includes("ohdear")?"Oh Dear":e.includes("scrapy")?"Scrapy":e.includes("headlesschrome")?"Headless Chrome":e.includes("phantomjs")?"PhantomJS":e.includes("wget")?"wget":e.includes("httpie")?"HTTPie":e.includes("guzzlehttp")?"Guzzle":e.includes("electron/")?"Electron":/curl\//.test(e)?"curl":/axios\//.test(e)?"axios":/(?:^|[\s(])got(?:\/|[\s(])/.test(e)?"got":/\bcolly\b/.test(e)?"colly":/node-fetch\//.test(e)?"node-fetch":/python-requests\//.test(e)?"python-requests":/go-http-client\//.test(e)?"Go http client":/okhttp\//.test(e)?"OkHttp":/aiohttp\//.test(e)?"aiohttp":/deno\//.test(e)?"Deno":e.includes("mozilla")||e.includes("chrome")||e.includes("safari")||e.includes("firefox")?"Browser":"Other"}var d={ChatGPT:["104.208.184.192/28","104.208.184.208/28","104.210.139.192/28","104.210.139.224/28","104.210.140.128/28","128.85.198.32/28","13.65.138.112/28","13.65.138.96/28","13.67.72.16/28","13.70.107.160/28","13.71.2.208/28","13.76.115.224/28","13.76.115.240/28","13.76.116.80/28","13.76.32.208/28","13.83.167.128/28","13.83.237.176/28","132.196.82.48/28","132.196.86.0/24","134.149.233.80/28","135.116.136.160/28","135.13.64.240/28","135.220.73.208/28","135.220.73.240/28","135.234.64.0/24","135.237.131.208/28","135.237.133.48/28","137.135.191.176/28","138.91.30.48/28","138.91.46.96/28","145.132.1.32/28","145.132.136.96/28","145.133.0.176/28","158.158.5.32/28","168.63.252.240/28","172.162.248.64/28","172.170.1.80/28","172.170.225.0/28","172.170.241.80/28","172.170.8.208/28","172.171.4.176/28","172.175.152.224/28","172.178.140.144/28","172.178.141.112/28","172.178.141.128/28","172.182.193.224/28","172.182.193.80/28","172.182.194.144/28","172.182.194.32/28","172.182.195.48/28","172.182.202.0/25","172.182.204.0/24","172.182.207.0/25","172.182.209.208/28","172.182.211.192/28","172.182.213.192/28","172.182.214.0/24","172.182.215.0/24","172.182.224.0/28","172.183.143.224/28","172.183.222.128/28","172.192.112.208/28","172.192.88.192/28","172.192.97.32/28","172.197.160.192/28","172.197.161.208/28","172.197.170.80/28","172.197.203.16/28","172.199.137.80/28","172.202.102.112/28","172.203.190.128/28","172.204.27.16/28","172.204.28.224/28","172.204.96.32/28","172.204.96.48/28","172.204.96.80/28","172.205.189.192/28","172.207.1.32/28","172.208.128.32/28","172.208.128.48/28","172.212.159.64/28","172.212.172.160/28","172.213.21.16/28","172.215.215.32/28","172.215.218.96/28","191.233.1.112/28","191.233.1.128/28","191.233.194.32/28","191.233.196.112/28","191.233.199.160/28","191.233.2.0/28","191.235.66.16/28","191.235.99.80/28","191.237.249.64/28","191.239.245.16/28","20.102.212.144/28","20.113.211.112/28","20.113.225.112/28","20.125.112.224/28","20.125.144.144/28","20.125.66.80/28","20.14.99.96/28","20.161.75.208/28","20.168.18.32/28","20.168.7.192/28","20.168.7.240/28","20.169.6.224/28","20.169.7.48/28","20.169.72.112/28","20.169.73.176/28","20.169.73.32/28","20.169.73.64/28","20.169.77.0/25","20.169.78.112/28","20.169.78.128/28","20.169.78.144/28","20.169.78.160/28","20.169.78.176/28","20.169.78.192/28","20.169.78.208/28","20.169.78.48/28","20.169.78.64/28","20.169.78.80/28","20.169.78.96/28","20.169.86.224/28","20.169.86.240/28","20.169.87.112/28","20.17.108.96/28","20.170.184.16/28","20.170.184.32/28","20.170.184.48/28","20.170.184.64/28","20.170.184.80/28","20.171.123.64/28","20.171.206.0/24","20.171.207.0/24","20.171.53.224/28","20.172.29.32/28","20.193.233.240/28","20.193.50.32/28","20.194.0.208/28","20.194.1.0/28","20.198.67.96/28","20.199.211.160/28","20.199.242.0/28","20.200.212.240/28","20.204.24.240/28","20.210.154.128/28","20.210.174.208/28","20.210.211.192/28","20.215.187.208/28","20.215.188.192/28","20.215.214.16/28","20.215.219.128/28","20.215.219.160/28","20.215.219.208/28","20.215.220.112/28","20.215.220.128/28","20.215.220.144/28","20.215.220.160/28","20.215.220.176/28","20.215.220.192/28","20.215.220.208/28","20.215.220.64/28","20.215.220.80/28","20.215.220.96/28","20.218.30.240/28","20.219.71.192/28","20.222.36.192/28","20.226.32.80/28","20.227.140.32/28","20.228.106.176/28","20.235.75.208/28","20.235.87.224/28","20.249.63.208/28","20.25.151.224/28","20.250.136.64/28","20.250.136.80/28","20.250.6.128/28","20.27.94.128/28","20.42.10.176/28","20.45.178.144/28","20.48.120.208/28","20.52.125.160/28","20.55.129.0/28","20.55.229.144/28","20.57.199.192/28","20.63.180.96/28","20.63.221.64/28","20.79.59.112/28","20.81.183.64/28","20.83.243.176/28","20.97.189.96/28","23.102.140.144/28","23.102.141.32/28","23.98.142.176/28","23.98.179.16/28","23.98.186.176/28","23.98.186.192/28","23.98.186.64/28","23.98.186.96/28","4.151.119.48/28","4.151.241.240/28","4.151.71.176/28","4.189.118.208/28","4.189.119.48/28","4.196.118.112/28","4.197.115.112/28","4.197.19.176/28","4.197.22.112/28","4.197.64.0/28","4.197.64.16/28","4.197.64.48/28","4.197.64.64/28","4.198.72.16/28","4.198.96.112/28","4.201.232.64/28","4.201.232.80/28","4.203.96.80/28","4.205.128.176/28","4.218.24.64/28","4.226.200.16/28","4.226.226.32/28","4.227.36.0/25","40.116.73.208/28","40.122.235.112/28","40.67.175.0/25","40.67.183.160/28","40.67.183.176/28","40.78.161.48/28","40.81.134.128/28","40.81.134.144/28","40.81.234.144/28","40.81.67.96/28","40.84.181.32/28","40.84.221.208/28","40.84.221.224/28","40.90.214.16/28","48.193.44.32/28","48.221.184.112/28","48.221.184.80/28","48.221.184.96/28","48.221.40.176/28","51.107.70.192/28","51.116.2.80/28","51.116.221.96/28","51.56.40.80/28","51.57.0.96/28","51.59.24.64/28","51.59.24.80/28","51.59.40.80/28","51.59.40.96/28","51.59.48.80/28","51.59.48.96/28","51.8.102.0/24","51.8.155.112/28","51.8.155.48/28","51.8.155.64/28","51.8.187.224/28","52.148.129.32/28","52.153.130.64/28","52.154.22.48/28","52.156.77.144/28","52.159.227.32/28","52.159.249.96/28","52.161.49.224/28","52.161.49.96/28","52.165.212.16/28","52.165.212.32/28","52.165.212.48/28","52.172.129.160/28","52.172.251.112/28","52.173.219.112/28","52.173.219.96/28","52.173.221.16/28","52.173.221.176/28","52.173.221.208/28","52.173.234.16/28","52.173.234.80/28","52.173.235.80/28","52.183.217.240/28","52.187.246.128/28","52.190.137.144/28","52.190.137.16/28","52.190.139.48/28","52.190.142.64/28","52.190.190.16/28","52.225.75.208/28","52.230.152.0/24","52.230.163.32/28","52.230.164.176/28","52.231.30.48/28","52.231.34.176/28","52.231.39.144/28","52.231.39.192/28","52.231.49.48/28","52.231.50.64/28","52.236.94.144/28","52.241.146.208/28","52.242.132.224/28","52.242.132.240/28","52.242.245.208/28","52.252.113.240/28","52.255.109.112/28","52.255.109.128/28","52.255.109.144/28","52.255.109.80/28","52.255.109.96/28","52.255.111.0/28","52.255.111.112/28","52.255.111.32/28","52.255.111.48/28","52.255.111.80/28","57.154.174.112/28","57.154.175.0/28","57.154.187.32/28","68.154.28.96/28","68.218.30.112/28","68.220.57.64/28","68.221.67.192/28","68.221.67.224/28","68.221.67.240/28","70.153.139.208/28","70.153.189.192/28","70.153.190.16/28","70.153.76.16/28","70.153.87.224/28","70.156.144.64/28","70.156.152.80/28","70.156.152.96/28","74.161.200.96/28","74.224.217.64/28","74.226.253.160/28","74.249.86.176/28","74.7.175.128/25","74.7.227.0/25","74.7.227.128/25","74.7.228.0/25","74.7.228.128/25","74.7.229.0/25","74.7.229.128/25","74.7.230.0/25","74.7.241.0/25","74.7.241.128/25","74.7.242.0/25","74.7.242.128/25","74.7.243.0/25","74.7.243.128/25","74.7.244.0/25","74.7.35.112/28","74.7.35.48/28","74.7.36.64/28","74.7.36.80/28","74.7.36.96/28","85.211.241.128/28","9.129.0.0/17","9.160.128.16/28","9.160.128.32/28","9.160.128.64/28","9.160.163.128/28","9.160.164.128/28","9.160.34.144/28","9.160.36.16/28","9.160.96.16/28","9.163.101.48/28","9.205.25.128/28","9.205.8.48/28","9.223.181.208/28","9.234.96.192/28","9.234.97.128/28","9.234.97.96/28","9.235.40.32/28"],Claude:["136.107.176.208/32","216.73.216.0/22","34.11.34.31/32","34.150.241.79/32","34.162.191.81/32","34.162.230.222/32","34.162.244.71/32","34.182.140.95/32","34.182.161.143/32","34.182.218.27/32","34.182.220.85/32","34.182.222.37/32","34.182.225.167/32","34.182.226.151/32","34.182.226.221/32","34.186.108.163/32","34.85.172.162/32","35.221.29.174/32","35.245.175.129/32","35.245.89.239/32"],Perplexity:["107.20.236.150/32","18.210.92.235/32","18.97.1.228/30","18.97.9.96/29","3.211.124.183/32","3.222.232.239/32","3.224.62.45/32","3.231.139.107/32"],Apple:["17.22.237.0/24","17.22.245.0/24","17.22.253.0/24","17.241.193.160/27","17.241.200.160/27","17.241.208.160/27","17.241.219.0/24","17.241.227.0/24","17.241.75.0/24","17.246.15.0/24","17.246.19.0/24","17.246.23.0/24"]},h=Object.keys(d);function p(t){let e=t.split(".");if(e.length!==4)return null;let i=0;for(let n of e){if(!/^\d{1,3}$/.test(n)||n.length>1&&n[0]==="0")return null;let o=Number(n);if(o>255)return null;i=i<<8|o;}return i>>>0}function x(t){let e=t,i=e.indexOf("%");if(i!==-1&&(e=e.slice(0,i)),!e||e.indexOf(":")===-1)return null;let n=null,o=e.lastIndexOf(":"),l=e.slice(o+1);if(l.indexOf(".")!==-1){if(n=p(l),n===null)return null;e=e.slice(0,o+1)+"0:0";}let r=e.split("::");if(r.length>2)return null;let s=r[0]?r[0].split(":"):[],c=r.length===2?r[1]?r[1].split(":"):[]:null,f;if(c===null){if(f=s,f.length!==8)return null}else {let a=8-s.length-c.length;if(a<0)return null;f=[...s,...Array(a).fill("0"),...c];}let u=0n;for(let a of f){if(!/^[0-9a-fA-F]{1,4}$/.test(a))return null;u=u<<16n|BigInt(parseInt(a,16));}return n!==null&&(u=u>>32n<<32n|BigInt(n>>>0)),u}function B(t){let e=new Map,i=[],n=[];for(let o of t){let l=o.lastIndexOf("/");if(l===-1)continue;let r=o.slice(0,l),s=Number(o.slice(l+1));if(!(!Number.isInteger(s)||s<0))if(r.indexOf(":")!==-1){if(s>128)continue;let c=x(r);if(c===null)continue;n.push({net:s===0?0n:c>>BigInt(128-s)<<BigInt(128-s),bits:s});}else {if(s>32)continue;let c=p(r);if(c===null)continue;let f=s===0?0:4294967295<<32-s>>>0,u={net:(c&f)>>>0,mask:f};if(s>=8){let a=u.net>>>24,g=e.get(a);g?g.push(u):e.set(a,[u]);}else i.push(u);}}return {v4:e,v4Wide:i,v6:n}}function R(t,e){if(!t)return false;let i=t.trim();if(!i)return false;if(i.indexOf(":")!==-1){let o=x(i);if(o===null)return false;let l=0xffffn<<32n;if(o>>32n===l>>32n){let r=Number(o&0xffffffffn)>>>0;if(m(r,e))return true}for(let r of e.v6)if(r.bits===0||o>>BigInt(128-r.bits)<<BigInt(128-r.bits)===r.net)return true;return false}let n=p(i);return n===null?false:m(n,e)}function m(t,e){let i=e.v4.get(t>>>24);if(i){for(let n of i)if((t&n.mask)>>>0===n.net)return true}for(let n of e.v4Wide)if((t&n.mask)>>>0===n.net)return true;return false}var A=/claude-code|perplexity-user/i,C=/ClaudeBot|Claude-SearchBot|GPTBot|OAI-SearchBot|ChatGPT-User|PerplexityBot|Applebot/i,y=new Map;function k(t){if(!(t in d))return;let e=y.get(t);return e||(e=B(d[t]??[]),y.set(t,e)),e}function V(){return h}function I(t,e){let i=t??"",n=b(t),o=k(n);if(!o){let s=n!=="Other"&&n!=="Browser";return {verdict:s?"unverifiable":"not-claimed",...s?{reason:"no-published-ranges"}:{},claimed:s?n:null,verified:null}}if(A.test(i)||!C.test(i))return {verdict:"unverifiable",reason:"client-side-agent",claimed:n,verified:null};let l=(e??"").trim();if(!l)return {verdict:"unverifiable",reason:"no-client-ip",claimed:n,verified:null};let r=R(l,o);return {verdict:r?"verified":"spoofed",claimed:n,verified:r}}function w(t){let i=(t.headers.get("x-forwarded-for")||"").split(",")[0]?.trim();return i||(t.headers.get("cf-connecting-ip")||t.headers.get("x-real-ip")||"").trim()}function D(t){return I(t.headers.get("user-agent"),w(t))}
|
|
2
|
+
exports.clientIpFromRequest=w;exports.verifiableVendors=V;exports.verifyBotIdentity=I;exports.verifyRequest=D;//# sourceMappingURL=verify.cjs.map
|
|
3
|
+
//# sourceMappingURL=verify.cjs.map
|