@traceten/ai-crawl 0.1.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +260 -0
  4. package/dist/adapters/cloudflare-pages.d.ts +42 -0
  5. package/dist/adapters/cloudflare-pages.d.ts.map +1 -0
  6. package/dist/adapters/cloudflare-pages.js +46 -0
  7. package/dist/adapters/cloudflare-pages.js.map +1 -0
  8. package/dist/adapters/cloudflare-workers.d.ts +41 -0
  9. package/dist/adapters/cloudflare-workers.d.ts.map +1 -0
  10. package/dist/adapters/cloudflare-workers.js +49 -0
  11. package/dist/adapters/cloudflare-workers.js.map +1 -0
  12. package/dist/adapters/express.d.ts +49 -0
  13. package/dist/adapters/express.d.ts.map +1 -0
  14. package/dist/adapters/express.js +91 -0
  15. package/dist/adapters/express.js.map +1 -0
  16. package/dist/adapters/hono.d.ts +48 -0
  17. package/dist/adapters/hono.d.ts.map +1 -0
  18. package/dist/adapters/hono.js +64 -0
  19. package/dist/adapters/hono.js.map +1 -0
  20. package/dist/adapters/next.d.ts +41 -0
  21. package/dist/adapters/next.d.ts.map +1 -0
  22. package/dist/adapters/next.js +70 -0
  23. package/dist/adapters/next.js.map +1 -0
  24. package/dist/config.d.ts +21 -0
  25. package/dist/config.d.ts.map +1 -0
  26. package/dist/config.js +99 -0
  27. package/dist/config.js.map +1 -0
  28. package/dist/crawlers.d.ts +68 -0
  29. package/dist/crawlers.d.ts.map +1 -0
  30. package/dist/crawlers.js +248 -0
  31. package/dist/crawlers.js.map +1 -0
  32. package/dist/filter.d.ts +33 -0
  33. package/dist/filter.d.ts.map +1 -0
  34. package/dist/filter.js +169 -0
  35. package/dist/filter.js.map +1 -0
  36. package/dist/index.d.ts +21 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +20 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/ip.d.ts +35 -0
  41. package/dist/ip.d.ts.map +1 -0
  42. package/dist/ip.js +109 -0
  43. package/dist/ip.js.map +1 -0
  44. package/dist/matcher.d.ts +44 -0
  45. package/dist/matcher.d.ts.map +1 -0
  46. package/dist/matcher.js +111 -0
  47. package/dist/matcher.js.map +1 -0
  48. package/dist/report.d.ts +43 -0
  49. package/dist/report.d.ts.map +1 -0
  50. package/dist/report.js +116 -0
  51. package/dist/report.js.map +1 -0
  52. package/dist/track.d.ts +30 -0
  53. package/dist/track.d.ts.map +1 -0
  54. package/dist/track.js +96 -0
  55. package/dist/track.js.map +1 -0
  56. package/dist/types.d.ts +184 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +11 -0
  59. package/dist/types.js.map +1 -0
  60. package/package.json +87 -0
  61. package/src/adapters/cloudflare-pages.ts +64 -0
  62. package/src/adapters/cloudflare-workers.ts +70 -0
  63. package/src/adapters/express.ts +113 -0
  64. package/src/adapters/hono.ts +89 -0
  65. package/src/adapters/next.ts +87 -0
  66. package/src/config.ts +127 -0
  67. package/src/crawlers.ts +269 -0
  68. package/src/filter.ts +178 -0
  69. package/src/index.ts +46 -0
  70. package/src/ip.ts +112 -0
  71. package/src/matcher.ts +119 -0
  72. package/src/report.ts +149 -0
  73. package/src/track.ts +117 -0
  74. package/src/types.ts +190 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Next.js adapter — call from `proxy.ts` (Next ≥15.5; formerly
3
+ * `middleware.ts`).
4
+ *
5
+ * ```ts
6
+ * // proxy.ts
7
+ * import { defineAiCrawlConfig } from "@traceten/ai-crawl";
8
+ * import { trackAICrawlerRequest } from "@traceten/ai-crawl/next";
9
+ *
10
+ * const config = defineAiCrawlConfig({
11
+ * siteId: process.env.TRACETEN_SITE_ID!,
12
+ * authToken: process.env.TRACETEN_CRAWL_TOKEN!,
13
+ * });
14
+ *
15
+ * export function proxy(request: NextRequest, event: NextFetchEvent) {
16
+ * trackAICrawlerRequest(request, event, config);
17
+ * return NextResponse.next();
18
+ * }
19
+ * ```
20
+ *
21
+ * Validate the config at module scope with `defineAiCrawlConfig` so a
22
+ * missing token fails the deploy, not your visitors' requests. If a raw
23
+ * (unvalidated) config reaches this function it is validated lazily; an
24
+ * invalid one logs ONE loud error and no-ops — per-request code paths never
25
+ * throw.
26
+ *
27
+ * The middleware runs before the response exists, so no `status` is sent —
28
+ * the edge treats a crawl report without a status as "served".
29
+ */
30
+
31
+ import { defineAiCrawlConfig, isResolvedConfig } from "../config.js";
32
+ import { factsFromFetchRequest, trackFacts, type FetchLikeRequest } from "../track.js";
33
+ import type { AiCrawlConfig, ResolvedAiCrawlConfig } from "../types.js";
34
+
35
+ /** Structural subset of Next's `NextFetchEvent`. */
36
+ export interface WaitUntilEvent {
37
+ waitUntil(promise: Promise<unknown>): void;
38
+ }
39
+
40
+ const resolvedCache = new WeakMap<AiCrawlConfig, ResolvedAiCrawlConfig | null>();
41
+
42
+ /** Resolve lazily, warning loudly ONCE per config object on invalid input. */
43
+ function resolveLazily(
44
+ config: AiCrawlConfig | ResolvedAiCrawlConfig,
45
+ ): ResolvedAiCrawlConfig | null {
46
+ if (isResolvedConfig(config)) return config;
47
+ if (resolvedCache.has(config)) return resolvedCache.get(config) ?? null;
48
+ let resolved: ResolvedAiCrawlConfig | null = null;
49
+ try {
50
+ resolved = defineAiCrawlConfig(config);
51
+ } catch (err) {
52
+ try {
53
+ // eslint-disable-next-line no-console
54
+ console.error(
55
+ "[@traceten/ai-crawl] invalid config — crawler tracking is DISABLED:",
56
+ err instanceof Error ? err.message : err,
57
+ );
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ }
62
+ resolvedCache.set(config, resolved);
63
+ return resolved;
64
+ }
65
+
66
+ /**
67
+ * Report the request if it looks like an AI crawler. Never throws, never
68
+ * blocks — delivery is scheduled via `event.waitUntil` when available.
69
+ */
70
+ export function trackAICrawlerRequest(
71
+ request: FetchLikeRequest,
72
+ event: WaitUntilEvent | undefined,
73
+ config: AiCrawlConfig | ResolvedAiCrawlConfig,
74
+ ): void {
75
+ try {
76
+ const cfg = resolveLazily(config);
77
+ if (cfg === null) return;
78
+ trackFacts(
79
+ cfg,
80
+ factsFromFetchRequest(request),
81
+ undefined,
82
+ event !== undefined ? (p) => event.waitUntil(p) : undefined,
83
+ );
84
+ } catch {
85
+ /* silent by contract */
86
+ }
87
+ }
package/src/config.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Config validation + defaulting.
3
+ *
4
+ * This is the ONE place the package is allowed to throw: at construction,
5
+ * in the customer's module scope / server boot — never per-request. A
6
+ * missing `authToken` must fail loudly here rather than silently sending
7
+ * requests that the endpoint will 401.
8
+ */
9
+
10
+ import { DEFAULT_DENY_EXTENSIONS, DEFAULT_DENY_PATH_PREFIXES } from "./filter.js";
11
+ import type { AiCrawlConfig, ResolvedAiCrawlConfig } from "./types.js";
12
+
13
+ export const DEFAULT_ENDPOINT = "https://ingest.traceten.com/v1/ai-crawls";
14
+ export const DEFAULT_ALLOWED_METHODS: readonly string[] = ["GET", "HEAD"];
15
+ export const AUTH_TOKEN_PREFIX = "tt_bot_";
16
+
17
+ function fail(message: string): never {
18
+ throw new Error(`[@traceten/ai-crawl] ${message}`);
19
+ }
20
+
21
+ /**
22
+ * Validate a config and apply defaults. THROWS on invalid input — call it at
23
+ * module scope (or in your middleware factory), so a misconfiguration fails
24
+ * your deploy, not your visitors' requests.
25
+ */
26
+ export function defineAiCrawlConfig(config: AiCrawlConfig): ResolvedAiCrawlConfig {
27
+ if (typeof config !== "object" || config === null) {
28
+ fail("config must be an object");
29
+ }
30
+
31
+ if (typeof config.siteId !== "string" || config.siteId.trim() === "") {
32
+ fail(
33
+ "siteId is required — use your site key (the ttid_… data-site value on the install page)",
34
+ );
35
+ }
36
+
37
+ if (typeof config.authToken !== "string" || config.authToken.trim() === "") {
38
+ fail(
39
+ "authToken is required. POST /v1/ai-crawls rejects unauthenticated reports, " +
40
+ "so without it every report would silently 401. Create a crawl token " +
41
+ "(tt_bot_...) in the Traceten dashboard and keep it server-side.",
42
+ );
43
+ }
44
+ if (!config.authToken.startsWith(AUTH_TOKEN_PREFIX)) {
45
+ fail(
46
+ `authToken must start with "${AUTH_TOKEN_PREFIX}". It looks like you passed ` +
47
+ "something else (perhaps the public snippet key?) — the crawl token is a " +
48
+ "server-side secret created in the dashboard.",
49
+ );
50
+ }
51
+
52
+ const proxyDepth = config.proxyDepth ?? 1;
53
+ if (!Number.isInteger(proxyDepth) || proxyDepth < 1) {
54
+ fail(
55
+ "proxyDepth must be a positive integer (number of trusted proxies appending to x-forwarded-for)",
56
+ );
57
+ }
58
+
59
+ if (config.endpoint !== undefined) {
60
+ // https only: the request carries the tt_bot_ Bearer token and crawler
61
+ // IPs. A typo'd http:// endpoint would leak both in cleartext, silently.
62
+ // Plain http is allowed solely for local development loopback.
63
+ const isHttps = /^https:\/\//.test(config.endpoint);
64
+ const isLocalHttp = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/.test(
65
+ config.endpoint,
66
+ );
67
+ if (!isHttps && !isLocalHttp) {
68
+ fail(
69
+ "endpoint must be an https:// URL (http:// is allowed only for localhost). " +
70
+ "Reports carry your tt_bot_ token and crawler IPs — never send them in cleartext.",
71
+ );
72
+ }
73
+ }
74
+ if (config.publicOrigin !== undefined && !/^https?:\/\/[^/]+$/.test(config.publicOrigin)) {
75
+ fail('publicOrigin must be an origin with no path, e.g. "https://example.com"');
76
+ }
77
+
78
+ const allowedMethods = (config.allowedMethods ?? DEFAULT_ALLOWED_METHODS).map((m) =>
79
+ String(m).toUpperCase(),
80
+ );
81
+ if (allowedMethods.length === 0) {
82
+ fail("allowedMethods must not be empty");
83
+ }
84
+
85
+ // Extras EXTEND the built-in lists; they never replace them.
86
+ const denyPathPrefixes = [
87
+ ...DEFAULT_DENY_PATH_PREFIXES,
88
+ // Trailing slashes are stripped so the segment-boundary match in
89
+ // filter.ts (`=== prefix || startsWith(prefix + "/")`) works either way.
90
+ ...(config.extraDenyPathPrefixes ?? []).map((p) => String(p).toLowerCase().replace(/\/+$/, "")),
91
+ ];
92
+ const denyExtensions = [
93
+ ...DEFAULT_DENY_EXTENSIONS,
94
+ ...(config.extraDenyExtensions ?? []).map((e) => {
95
+ const ext = String(e).toLowerCase();
96
+ return ext.startsWith(".") ? ext : `.${ext}`;
97
+ }),
98
+ ];
99
+
100
+ const resolved: ResolvedAiCrawlConfig = {
101
+ siteId: config.siteId.trim(),
102
+ authToken: config.authToken.trim(),
103
+ endpoint: config.endpoint ?? DEFAULT_ENDPOINT,
104
+ allowedMethods,
105
+ denyPathPrefixes,
106
+ denyExtensions,
107
+ disableAnswerFetch: config.disableAnswerFetch === true,
108
+ disableSearchCrawlers: config.disableSearchCrawlers === true,
109
+ disableTrainingCrawlers: config.disableTrainingCrawlers === true,
110
+ disableOtherCrawlers: config.disableOtherCrawlers === true,
111
+ trustProxy: config.trustProxy === true,
112
+ trustCfConnectingIp: config.trustCfConnectingIp === true,
113
+ proxyDepth,
114
+ publicOrigin: config.publicOrigin,
115
+ onError: typeof config.onError === "function" ? config.onError : undefined,
116
+ fetch: config.fetch,
117
+ __resolved: true,
118
+ };
119
+ return resolved;
120
+ }
121
+
122
+ /** True when the object already passed through {@link defineAiCrawlConfig}. */
123
+ export function isResolvedConfig(
124
+ config: AiCrawlConfig | ResolvedAiCrawlConfig,
125
+ ): config is ResolvedAiCrawlConfig {
126
+ return (config as ResolvedAiCrawlConfig).__resolved === true;
127
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Local crawler token data — a deliberately-loose COST FILTER, not the source
3
+ * of truth. Traceten's server-side crawler registry decides provider,
4
+ * category, verification and confidence; this list only decides "worth
5
+ * reporting". Keep it small and keep every token anchored against current
6
+ * vendor documentation.
7
+ *
8
+ * Recent reconciliation notes, kept because they explain non-obvious entries:
9
+ * - Deleted `GoogleAgent` (a hyphenation typo of the real `Google-Agent`,
10
+ * which stays), `FacebookBot`, Microsoft `Copilot` and `AliyunBot` — no
11
+ * vendor documents any of them.
12
+ * - Added `Google-GeminiNotebook`, Google's 2026-07-16 rename of
13
+ * `Google-NotebookLM`; the old token is honoured only until August 2026.
14
+ * BOTH are listed on purpose — stale traffic still carries the old one.
15
+ * - Added `MistralAI-Training`, documented on Mistral's crawler page.
16
+ * - Recategorised `GoogleOther` (training → ai_crawler; Google states it
17
+ * "doesn't affect any specific product") and `Bytespider` (training →
18
+ * search_index; ByteDance documents only a Toutiao Search index crawler).
19
+ *
20
+ * DELIBERATE EXCLUSIONS: `Google-Extended` and `Applebot-Extended` are
21
+ * robots.txt control tokens, not crawlers — they never appear as a live
22
+ * user agent, so listing them here would be dead code that can never fire.
23
+ *
24
+ * ⚠️ THE EXCLUSION ONLY ACTUALLY HOLDS FOR `Google-Extended`. Nothing matches
25
+ * it, so it is never reported. `Applebot-Extended` IS reported, by two
26
+ * independent paths:
27
+ * 1. Apple's UA strings carry `+http://www.apple.com/go/applebot`, whose
28
+ * `applebot` substring is a TIER-1 exact token match (anchored between
29
+ * `/` and `)`), so the report lands as agent `Applebot`, training.
30
+ * 2. The bare string `Applebot-Extended` hits the tier-2 alias
31
+ * `{ alias: "applebot" }` below, because prefix anchoring accepts a
32
+ * trailing `-`. That yields provider `apple`, agent null, `ai_crawler`.
33
+ * Neither is a bug — attributing an Apple fetch to Apple is right, and this
34
+ * file is a cost filter, not the classifier. Do NOT write, in code comments
35
+ * or in customer copy, that these tokens are "never reported" — only
36
+ * `Google-Extended` qualifies.
37
+ *
38
+ * All tokens are stored lowercase; matching lowercases the UA once.
39
+ */
40
+
41
+ import type { CrawlerCategory } from "./types.js";
42
+
43
+ export interface AgentToken {
44
+ /** Lowercased agent token, matched with token-boundary anchoring. */
45
+ readonly token: string;
46
+ /** Canonical display name of the agent (original casing). */
47
+ readonly agent: string;
48
+ readonly provider: string;
49
+ readonly category: CrawlerCategory;
50
+ }
51
+
52
+ /** Tier 1 — exact agent tokens. First match wins. */
53
+ export const AGENT_TOKENS: readonly AgentToken[] = [
54
+ // ── answer_fetch · user-triggered live fetches ─────────────────────────
55
+ { token: "chatgpt-user", agent: "ChatGPT-User", provider: "openai", category: "answer_fetch" },
56
+ { token: "claude-user", agent: "Claude-User", provider: "anthropic", category: "answer_fetch" },
57
+ {
58
+ token: "perplexity-user",
59
+ agent: "Perplexity-User",
60
+ provider: "perplexity",
61
+ category: "answer_fetch",
62
+ },
63
+ { token: "google-agent", agent: "Google-Agent", provider: "google", category: "answer_fetch" },
64
+ {
65
+ token: "google-gemininotebook",
66
+ agent: "Google-GeminiNotebook",
67
+ provider: "google",
68
+ category: "answer_fetch",
69
+ },
70
+ // Superseded by Google-GeminiNotebook on 2026-07-16, honoured until August
71
+ // 2026. Kept: traffic already in flight still carries it.
72
+ {
73
+ token: "google-notebooklm",
74
+ agent: "Google-NotebookLM",
75
+ provider: "google",
76
+ category: "answer_fetch",
77
+ },
78
+ {
79
+ token: "google-read-aloud",
80
+ agent: "Google-Read-Aloud",
81
+ provider: "google",
82
+ category: "answer_fetch",
83
+ },
84
+ {
85
+ token: "mistralai-user",
86
+ agent: "MistralAI-User",
87
+ provider: "mistral",
88
+ category: "answer_fetch",
89
+ },
90
+ { token: "amzn-user", agent: "Amzn-User", provider: "amazon", category: "answer_fetch" },
91
+ {
92
+ token: "duckassistbot",
93
+ agent: "DuckAssistBot",
94
+ provider: "duckduckgo",
95
+ category: "answer_fetch",
96
+ },
97
+ {
98
+ token: "meta-externalfetcher",
99
+ agent: "meta-externalfetcher",
100
+ provider: "meta",
101
+ category: "answer_fetch",
102
+ },
103
+ { token: "kimi-user", agent: "Kimi-User", provider: "moonshot", category: "answer_fetch" },
104
+ { token: "xai-searchbot", agent: "xAI-SearchBot", provider: "xai", category: "answer_fetch" },
105
+ { token: "grok-deepsearch", agent: "Grok-DeepSearch", provider: "xai", category: "answer_fetch" },
106
+ { token: "qwen-user", agent: "Qwen-User", provider: "alibaba", category: "answer_fetch" },
107
+
108
+ // ── search_index · AI search index crawlers ────────────────────────────
109
+ { token: "oai-searchbot", agent: "OAI-SearchBot", provider: "openai", category: "search_index" },
110
+ {
111
+ token: "claude-searchbot",
112
+ agent: "Claude-SearchBot",
113
+ provider: "anthropic",
114
+ category: "search_index",
115
+ },
116
+ {
117
+ token: "perplexitybot",
118
+ agent: "PerplexityBot",
119
+ provider: "perplexity",
120
+ category: "search_index",
121
+ },
122
+ { token: "googlebot", agent: "Googlebot", provider: "google", category: "search_index" },
123
+ {
124
+ token: "google-inspectiontool",
125
+ agent: "Google-InspectionTool",
126
+ provider: "google",
127
+ category: "search_index",
128
+ },
129
+ {
130
+ token: "mistralai-index",
131
+ agent: "MistralAI-Index",
132
+ provider: "mistral",
133
+ category: "search_index",
134
+ },
135
+ { token: "bingbot", agent: "Bingbot", provider: "microsoft", category: "search_index" },
136
+ { token: "msnbot", agent: "msnbot", provider: "microsoft", category: "search_index" },
137
+ {
138
+ token: "amzn-searchbot",
139
+ agent: "Amzn-SearchBot",
140
+ provider: "amazon",
141
+ category: "search_index",
142
+ },
143
+ {
144
+ token: "meta-webindexer",
145
+ agent: "meta-webindexer",
146
+ provider: "meta",
147
+ category: "search_index",
148
+ },
149
+ {
150
+ token: "kimi-searchbot",
151
+ agent: "Kimi-SearchBot",
152
+ provider: "moonshot",
153
+ category: "search_index",
154
+ },
155
+ { token: "tiktokspider", agent: "TikTokSpider", provider: "bytedance", category: "search_index" },
156
+ { token: "bytespider", agent: "Bytespider", provider: "bytedance", category: "search_index" },
157
+ { token: "baiduspider", agent: "Baiduspider", provider: "baidu", category: "search_index" },
158
+ { token: "youbot", agent: "YouBot", provider: "youcom", category: "search_index" },
159
+
160
+ // ── training · corpus crawlers ─────────────────────────────────────────
161
+ { token: "gptbot", agent: "GPTBot", provider: "openai", category: "training" },
162
+ { token: "claudebot", agent: "ClaudeBot", provider: "anthropic", category: "training" },
163
+ {
164
+ token: "mistralai-training",
165
+ agent: "MistralAI-Training",
166
+ provider: "mistral",
167
+ category: "training",
168
+ },
169
+ {
170
+ token: "google-cloudvertexbot",
171
+ agent: "Google-CloudVertexBot",
172
+ provider: "google",
173
+ category: "training",
174
+ },
175
+ { token: "applebot", agent: "Applebot", provider: "apple", category: "training" },
176
+ { token: "amazonbot", agent: "Amazonbot", provider: "amazon", category: "training" },
177
+ {
178
+ token: "meta-externalagent",
179
+ agent: "meta-externalagent",
180
+ provider: "meta",
181
+ category: "training",
182
+ },
183
+ { token: "kimibot", agent: "KimiBot", provider: "moonshot", category: "training" },
184
+ { token: "ccbot", agent: "CCBot", provider: "commoncrawl", category: "training" },
185
+ { token: "erniebot", agent: "ERNIEBot", provider: "baidu", category: "training" },
186
+ { token: "qwenbot", agent: "QwenBot", provider: "alibaba", category: "training" },
187
+ { token: "chatglm-spider", agent: "ChatGLM-Spider", provider: "zhipu", category: "training" },
188
+ { token: "deepseekbot", agent: "DeepSeekBot", provider: "deepseek", category: "training" },
189
+ { token: "cohere-ai", agent: "cohere-ai", provider: "cohere", category: "training" },
190
+ {
191
+ token: "cohere-training-data-crawler",
192
+ agent: "cohere-training-data-crawler",
193
+ provider: "cohere",
194
+ category: "training",
195
+ },
196
+ { token: "ai2bot", agent: "AI2Bot", provider: "allenai", category: "training" },
197
+
198
+ // ── ai_crawler · AI-adjacent, uncategorised ────────────────────────────
199
+ { token: "googleother", agent: "GoogleOther", provider: "google", category: "ai_crawler" },
200
+ {
201
+ token: "meta-externalads",
202
+ agent: "meta-externalads",
203
+ provider: "meta",
204
+ category: "ai_crawler",
205
+ },
206
+ {
207
+ token: "facebookexternalhit",
208
+ agent: "facebookexternalhit",
209
+ provider: "meta",
210
+ category: "ai_crawler",
211
+ },
212
+ { token: "oai-adsbot", agent: "OAI-AdsBot", provider: "openai", category: "ai_crawler" },
213
+ { token: "grokbot", agent: "GrokBot", provider: "xai", category: "ai_crawler" },
214
+ { token: "xai-bot", agent: "xAI-Bot", provider: "xai", category: "ai_crawler" },
215
+ { token: "xai-grok", agent: "xAI-Grok", provider: "xai", category: "ai_crawler" },
216
+ { token: "xai-web-crawler", agent: "xAI-Web-Crawler", provider: "xai", category: "ai_crawler" },
217
+ { token: "grok", agent: "Grok", provider: "xai", category: "ai_crawler" },
218
+ { token: "doubaobot", agent: "Doubaobot", provider: "bytedance", category: "ai_crawler" },
219
+ { token: "yiyanbot", agent: "YiyanBot", provider: "baidu", category: "ai_crawler" },
220
+ { token: "tongyibot", agent: "TongyiBot", provider: "alibaba", category: "ai_crawler" },
221
+ ];
222
+
223
+ export interface ProviderAlias {
224
+ /** Lowercased alias, matched as a LEFT-anchored prefix at a token boundary. */
225
+ readonly alias: string;
226
+ readonly provider: string;
227
+ }
228
+
229
+ /**
230
+ * Tier 2 — coarse provider aliases, so a NEW agent from a known vendor
231
+ * (e.g. a hypothetical `ChatGPT-Reader/1.0`) is still captured without a
232
+ * package release. Left-boundary-anchored prefix match: the alias must start
233
+ * at the beginning of a token (start of string or after a non-token
234
+ * character), which is what defeats `not-really-GPTBot`-style spoofs while
235
+ * still catching `Claude-NewAgent/1.0`.
236
+ *
237
+ * Tier-2 matches are reported with category `ai_crawler` locally (the server
238
+ * assigns the real category) and are gated by `disableOtherCrawlers`.
239
+ */
240
+ export const PROVIDER_ALIASES: readonly ProviderAlias[] = [
241
+ { alias: "gptbot", provider: "openai" },
242
+ { alias: "chatgpt", provider: "openai" },
243
+ { alias: "oai-", provider: "openai" },
244
+ { alias: "claudebot", provider: "anthropic" },
245
+ { alias: "claude-", provider: "anthropic" },
246
+ { alias: "anthropic", provider: "anthropic" },
247
+ { alias: "perplexity", provider: "perplexity" },
248
+ { alias: "mistralai", provider: "mistral" },
249
+ { alias: "duckassist", provider: "duckduckgo" },
250
+ { alias: "kimi", provider: "moonshot" },
251
+ { alias: "grok", provider: "xai" },
252
+ { alias: "xai-", provider: "xai" },
253
+ { alias: "qwen", provider: "alibaba" },
254
+ { alias: "tongyi", provider: "alibaba" },
255
+ { alias: "bytespider", provider: "bytedance" },
256
+ { alias: "doubao", provider: "bytedance" },
257
+ { alias: "baiduspider", provider: "baidu" },
258
+ { alias: "erniebot", provider: "baidu" },
259
+ { alias: "chatglm", provider: "zhipu" },
260
+ { alias: "deepseek", provider: "deepseek" },
261
+ { alias: "cohere", provider: "cohere" },
262
+ { alias: "ai2bot", provider: "allenai" },
263
+ { alias: "ccbot", provider: "commoncrawl" },
264
+ { alias: "youbot", provider: "youcom" },
265
+ { alias: "meta-", provider: "meta" },
266
+ { alias: "applebot", provider: "apple" },
267
+ { alias: "amazonbot", provider: "amazon" },
268
+ { alias: "amzn-", provider: "amazon" },
269
+ ];
package/src/filter.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Local pre-filter — everything here runs BEFORE any network call, so the
3
+ * overwhelming majority of requests (assets, API calls, browser subresource
4
+ * fetches) cost nothing.
5
+ *
6
+ * Order of checks:
7
+ * 1. Method gate (GET/HEAD by default)
8
+ * 2. sec-fetch-dest deny-list (browser subresource fetches; crawlers
9
+ * don't send the header, so absence passes)
10
+ * 3. Crawler-facing path re-allow — /robots.txt, /llms.txt,
11
+ * /llms-full.txt, *sitemap*.xml skip BOTH deny-lists below. A GPTBot
12
+ * hit on /llms.txt is one of the highest-signal events available and a
13
+ * naive `.txt` rule would eat it.
14
+ * 4. Path-prefix deny-list (extendable, never replaceable)
15
+ * 5. Static-extension deny-list (extendable, never replaceable)
16
+ */
17
+
18
+ import type { ResolvedAiCrawlConfig } from "./types.js";
19
+
20
+ /** Built-in path-prefix deny-list (plain prefix match on the pathname). */
21
+ export const DEFAULT_DENY_PATH_PREFIXES: readonly string[] = [
22
+ "/api",
23
+ "/_next",
24
+ "/_nuxt",
25
+ "/_astro",
26
+ "/static",
27
+ "/assets",
28
+ "/public",
29
+ "/images",
30
+ "/img",
31
+ "/fonts",
32
+ "/favicon",
33
+ "/build",
34
+ "/dist",
35
+ "/admin",
36
+ "/webhook",
37
+ "/webhooks",
38
+ "/cdn-cgi",
39
+ "/.well-known",
40
+ ];
41
+
42
+ /** Built-in static-extension deny-list (leading dot, lowercase). */
43
+ export const DEFAULT_DENY_EXTENSIONS: readonly string[] = [
44
+ ".js",
45
+ ".mjs",
46
+ ".cjs",
47
+ ".css",
48
+ ".map",
49
+ ".json",
50
+ ".xml",
51
+ ".txt",
52
+ ".ico",
53
+ ".png",
54
+ ".jpg",
55
+ ".jpeg",
56
+ ".gif",
57
+ ".webp",
58
+ ".avif",
59
+ ".svg",
60
+ ".bmp",
61
+ ".tiff",
62
+ ".woff",
63
+ ".woff2",
64
+ ".ttf",
65
+ ".otf",
66
+ ".eot",
67
+ ".mp4",
68
+ ".webm",
69
+ ".avi",
70
+ ".mov",
71
+ ".mp3",
72
+ ".wav",
73
+ ".ogg",
74
+ ".flac",
75
+ ".zip",
76
+ ".gz",
77
+ ".tar",
78
+ ".rar",
79
+ ".7z",
80
+ ".wasm",
81
+ ];
82
+
83
+ /** `sec-fetch-dest` values that identify browser subresource fetches. */
84
+ export const DENY_SEC_FETCH_DESTS: readonly string[] = [
85
+ "audio",
86
+ "embed",
87
+ "font",
88
+ "image",
89
+ "manifest",
90
+ "object",
91
+ "script",
92
+ "style",
93
+ "track",
94
+ "video",
95
+ "worker",
96
+ ];
97
+
98
+ /**
99
+ * Crawler-facing paths re-allowed PAST both deny-lists. Exact matches plus
100
+ * the `*sitemap*.xml` pattern.
101
+ */
102
+ const CRAWLER_PATH_EXACT: readonly string[] = ["/robots.txt", "/llms.txt", "/llms-full.txt"];
103
+
104
+ /** True for /robots.txt, /llms.txt, /llms-full.txt and *sitemap*.xml paths. */
105
+ export function isCrawlerFacingPath(pathname: string): boolean {
106
+ if (CRAWLER_PATH_EXACT.includes(pathname)) return true;
107
+ return pathname.endsWith(".xml") && pathname.includes("sitemap");
108
+ }
109
+
110
+ /** Extract a lowercase pathname from an absolute URL or a bare path. Never throws. */
111
+ export function extractPathname(url: string): string {
112
+ try {
113
+ let path = url;
114
+ // Strip scheme://host for absolute URLs without allocating a URL object.
115
+ const schemeIdx = path.indexOf("://");
116
+ if (schemeIdx !== -1) {
117
+ const pathStart = path.indexOf("/", schemeIdx + 3);
118
+ path = pathStart === -1 ? "/" : path.slice(pathStart);
119
+ }
120
+ const q = path.indexOf("?");
121
+ if (q !== -1) path = path.slice(0, q);
122
+ const h = path.indexOf("#");
123
+ if (h !== -1) path = path.slice(0, h);
124
+ if (path === "") path = "/";
125
+ return path.toLowerCase();
126
+ } catch {
127
+ return "/";
128
+ }
129
+ }
130
+
131
+ /** Last-segment extension (with dot, lowercase), or `null`. */
132
+ function pathExtension(pathname: string): string | null {
133
+ const lastSlash = pathname.lastIndexOf("/");
134
+ const lastDot = pathname.lastIndexOf(".");
135
+ if (lastDot === -1 || lastDot < lastSlash || lastDot === pathname.length - 1) {
136
+ return null;
137
+ }
138
+ return pathname.slice(lastDot);
139
+ }
140
+
141
+ /**
142
+ * The full local pre-filter. Returns `true` when the request should proceed
143
+ * to UA matching. Never throws.
144
+ */
145
+ export function passesPreFilter(
146
+ cfg: ResolvedAiCrawlConfig,
147
+ method: string | null | undefined,
148
+ url: string | null | undefined,
149
+ secFetchDest: string | null | undefined,
150
+ ): boolean {
151
+ try {
152
+ if (!method || !url) return false;
153
+ if (!cfg.allowedMethods.includes(method.toUpperCase())) return false;
154
+
155
+ if (secFetchDest && DENY_SEC_FETCH_DESTS.includes(secFetchDest.toLowerCase())) {
156
+ return false;
157
+ }
158
+
159
+ const pathname = extractPathname(url);
160
+
161
+ // Crawler-facing paths skip both deny-lists.
162
+ if (isCrawlerFacingPath(pathname)) return true;
163
+
164
+ // Segment-boundary prefix match: `/api` denies `/api` and `/api/users`
165
+ // but NOT `/apidocs-overview`; `/public` does not eat `/publications`.
166
+ // A plain startsWith here silently unreports real crawls on real paths.
167
+ for (const prefix of cfg.denyPathPrefixes) {
168
+ if (pathname === prefix || pathname.startsWith(prefix + "/")) return false;
169
+ }
170
+
171
+ const ext = pathExtension(pathname);
172
+ if (ext !== null && cfg.denyExtensions.includes(ext)) return false;
173
+
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }