@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
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @traceten/ai-crawl — server-side AI crawler tracking.
3
+ *
4
+ * AI crawlers (GPTBot, ClaudeBot, PerplexityBot, …) never execute
5
+ * JavaScript, so the browser snippet cannot see them. The observation point
6
+ * has to be the customer's server — this package is that observation point.
7
+ * It pre-filters locally, matches user agents with anchored tokens, and
8
+ * reports plausible AI crawls to `POST /v1/ai-crawls`. Classification,
9
+ * verification and confidence are decided server-side.
10
+ *
11
+ * Adapters: `@traceten/ai-crawl/next`, `/cloudflare-pages`,
12
+ * `/cloudflare-workers`, `/express`, `/hono`.
13
+ */
14
+
15
+ export {
16
+ AUTH_TOKEN_PREFIX,
17
+ DEFAULT_ALLOWED_METHODS,
18
+ DEFAULT_ENDPOINT,
19
+ defineAiCrawlConfig,
20
+ isResolvedConfig,
21
+ } from "./config.js";
22
+ export {
23
+ DEFAULT_DENY_EXTENSIONS,
24
+ DEFAULT_DENY_PATH_PREFIXES,
25
+ DENY_SEC_FETCH_DESTS,
26
+ isCrawlerFacingPath,
27
+ passesPreFilter,
28
+ } from "./filter.js";
29
+ export { matchCrawler } from "./matcher.js";
30
+ export { resolveCrawlerIp } from "./ip.js";
31
+ export { REPORT_TIMEOUT_MS, buildPayload, sendReport, type SendOptions } from "./report.js";
32
+ export {
33
+ evaluateRequest,
34
+ factsFromFetchRequest,
35
+ trackFacts,
36
+ type FetchLikeRequest,
37
+ } from "./track.js";
38
+ export type {
39
+ AiCrawlConfig,
40
+ AiCrawlDeliveryError,
41
+ AiCrawlWirePayload,
42
+ CrawlerCategory,
43
+ CrawlerMatch,
44
+ RequestFacts,
45
+ ResolvedAiCrawlConfig,
46
+ } from "./types.js";
package/src/ip.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Crawler IP resolution.
3
+ *
4
+ * The crawler's TCP connection terminates at the CUSTOMER's server, so the
5
+ * crawler IP is only observable here — Traceten's edge sees the customer's
6
+ * origin IP, not the crawler's. This module
7
+ * derives the best-available value; the payload OMITS the field rather than
8
+ * send a wrong one.
9
+ *
10
+ * Resolution order:
11
+ * 1. `cf-connecting-ip` — ONLY when `trustCfConnectingIp` is set. The
12
+ * Cloudflare adapters set it automatically (the platform strips and
13
+ * rewrites the header there); anywhere else it is client-forgeable,
14
+ * and a forged vendor-range IP paired with a vendor UA is exactly the
15
+ * spoof the server-side verification exists to catch.
16
+ * 2. `x-forwarded-for` — ONLY when `trustProxy` is true, selecting the
17
+ * entry `proxyDepth` hops from the right (each trusted proxy appends
18
+ * one entry; anything further left is attacker-controllable)
19
+ * 3. Socket remote address, where the runtime exposes one
20
+ */
21
+
22
+ import type { RequestFacts, ResolvedAiCrawlConfig } from "./types.js";
23
+
24
+ const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
25
+ // Loose IPv6 shape check — up to 8 hex groups separated by colons ("::"
26
+ // compression appears as empty groups). Deliberately permissive: this is a
27
+ // plausibility gate, not a validator; the edge re-validates.
28
+ const IPV6_RE = /^[0-9a-f]{0,4}(:[0-9a-f]{0,4}){1,7}$/i;
29
+
30
+ /** Normalise a candidate; returns `undefined` when it is not a plausible IP. */
31
+ export function normalizeIp(raw: string | null | undefined): string | undefined {
32
+ if (!raw) return undefined;
33
+ let value = raw.trim();
34
+ if (value === "") return undefined;
35
+
36
+ // Bracketed IPv6, possibly with a port: [::1]:8080
37
+ if (value.startsWith("[")) {
38
+ const close = value.indexOf("]");
39
+ if (close === -1) return undefined;
40
+ value = value.slice(1, close);
41
+ } else {
42
+ // IPv4 with a port: 1.2.3.4:5678 (a bare IPv6 also contains ":", so only
43
+ // strip when what remains is a valid IPv4).
44
+ const colon = value.indexOf(":");
45
+ if (colon !== -1 && value.indexOf(":", colon + 1) === -1) {
46
+ const head = value.slice(0, colon);
47
+ if (IPV4_RE.test(head)) value = head;
48
+ }
49
+ }
50
+
51
+ // Node reports IPv4-mapped addresses as ::ffff:1.2.3.4.
52
+ if (value.toLowerCase().startsWith("::ffff:")) {
53
+ const tail = value.slice(7);
54
+ if (IPV4_RE.test(tail)) value = tail;
55
+ }
56
+
57
+ const v4 = IPV4_RE.exec(value);
58
+ if (v4 !== null) {
59
+ for (let i = 1; i <= 4; i++) {
60
+ if (Number(v4[i]) > 255) return undefined;
61
+ }
62
+ return value;
63
+ }
64
+ if (value.includes(":") && value.length <= 45 && IPV6_RE.test(value)) {
65
+ return value.toLowerCase();
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Select the client entry from an `x-forwarded-for` value given the number
72
+ * of trusted proxies that append to it. Returns `undefined` when the chain
73
+ * is shorter than `proxyDepth` (a wrong value is worse than none).
74
+ */
75
+ export function ipFromForwardedFor(headerValue: string, proxyDepth: number): string | undefined {
76
+ const entries = headerValue
77
+ .split(",")
78
+ .map((e) => e.trim())
79
+ .filter((e) => e !== "");
80
+ if (entries.length === 0 || proxyDepth < 1 || entries.length < proxyDepth) {
81
+ return undefined;
82
+ }
83
+ return normalizeIp(entries[entries.length - proxyDepth]);
84
+ }
85
+
86
+ /**
87
+ * Resolve the crawler IP from a request. Returns `undefined` when no
88
+ * trustworthy value is derivable. Never throws.
89
+ */
90
+ export function resolveCrawlerIp(
91
+ cfg: ResolvedAiCrawlConfig,
92
+ facts: RequestFacts,
93
+ ): string | undefined {
94
+ try {
95
+ if (cfg.trustCfConnectingIp) {
96
+ const cf = normalizeIp(facts.header("cf-connecting-ip"));
97
+ if (cf !== undefined) return cf;
98
+ }
99
+
100
+ if (cfg.trustProxy) {
101
+ const xff = facts.header("x-forwarded-for");
102
+ if (xff) {
103
+ const fromXff = ipFromForwardedFor(xff, cfg.proxyDepth);
104
+ if (fromXff !== undefined) return fromXff;
105
+ }
106
+ }
107
+
108
+ return normalizeIp(facts.socketAddr);
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
package/src/matcher.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Anchored two-tier user-agent matching.
3
+ *
4
+ * NEVER `includes()` on the whole string — that is the reference package's
5
+ * defect (its page claims token matching; its code does substring matching,
6
+ * so a browser UA containing `grok` or `copilot` false-positives). Follows
7
+ * the same anchoring discipline Traceten's server-side classifier uses,
8
+ * adapted for crawler UAs where the token sits mid-string
9
+ * (`Mozilla/5.0 … compatible; GPTBot/1.1; +https://openai.com/gptbot`).
10
+ *
11
+ * Boundary rule: a token matches only when the character before it and (for
12
+ * tier 1) the character after it are NOT token characters `[a-z0-9._-]`.
13
+ * So `compatible; GPTBot/1.1` matches, and `not-really-GPTBot` does not
14
+ * (preceded by `-`), and `GPTBotnet` does not (followed by `n`).
15
+ */
16
+
17
+ import { AGENT_TOKENS, PROVIDER_ALIASES } from "./crawlers.js";
18
+ import type { CrawlerMatch } from "./types.js";
19
+
20
+ /** Characters that can appear inside a UA product token. */
21
+ function isTokenChar(ch: string): boolean {
22
+ return (
23
+ (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "." || ch === "_" || ch === "-"
24
+ );
25
+ }
26
+
27
+ /** Anchoring mode. */
28
+ export type AnchorMode = "token" | "prefix";
29
+
30
+ /**
31
+ * True when `needle` occurs in `haystack` (both lowercase) anchored at a
32
+ * token boundary.
33
+ *
34
+ * `"token"` (tier 1): boundary required on BOTH sides — `GPTBot/1.1`
35
+ * matches, `not-really-GPTBot` and `GPTBotnet` do not.
36
+ *
37
+ * `"prefix"` (tier 2): left boundary required; the right side must be a
38
+ * boundary OR a compound-token delimiter (`-`, `_`, `.`) — so `claude`
39
+ * catches a future `Claude-NewAgent/1.0` without matching `claudius`.
40
+ * A needle that itself ends in a delimiter (e.g. `oai-`, `meta-`) is a free
41
+ * prefix past that delimiter.
42
+ */
43
+ export function hasAnchoredToken(haystack: string, needle: string, mode: AnchorMode): boolean {
44
+ const needleEndsWithDelimiter =
45
+ needle.endsWith("-") || needle.endsWith("_") || needle.endsWith(".");
46
+ let idx = haystack.indexOf(needle);
47
+ while (idx !== -1) {
48
+ let leftOk = idx === 0 || !isTokenChar(haystack.charAt(idx - 1));
49
+ // Prefix (tier-2) matches skip occurrences that start a URL host —
50
+ // `SomeScraper/1.0 (+https://anthropic.com/policies)` is a scraper
51
+ // citing a vendor page, not a vendor agent. Tier-1 tokens keep matching
52
+ // anywhere: real crawler tokens never begin a hostname.
53
+ if (leftOk && mode === "prefix" && idx >= 3 && haystack.slice(idx - 3, idx) === "://") {
54
+ leftOk = false;
55
+ }
56
+ const rightIdx = idx + needle.length;
57
+ const nextCh = rightIdx >= haystack.length ? "" : haystack.charAt(rightIdx);
58
+ let rightOk: boolean;
59
+ if (mode === "token") {
60
+ rightOk = nextCh === "" || !isTokenChar(nextCh);
61
+ } else {
62
+ rightOk =
63
+ needleEndsWithDelimiter ||
64
+ nextCh === "" ||
65
+ !isTokenChar(nextCh) ||
66
+ nextCh === "-" ||
67
+ nextCh === "_" ||
68
+ nextCh === ".";
69
+ }
70
+ if (leftOk && rightOk) return true;
71
+ idx = haystack.indexOf(needle, idx + 1);
72
+ }
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * Match a raw User-Agent string against the local crawler list.
78
+ *
79
+ * Tier 1: exact agent tokens, boundary-anchored on both sides.
80
+ * Tier 2: coarse provider aliases, left-boundary-anchored prefixes, so a new
81
+ * agent from a known vendor is captured without a release. Tier-2 matches
82
+ * carry category `ai_crawler` locally; the server assigns the real one.
83
+ *
84
+ * Returns `null` for no match. Never throws.
85
+ */
86
+ export function matchCrawler(userAgent: string | null | undefined): CrawlerMatch | null {
87
+ if (!userAgent) return null;
88
+ let ua: string;
89
+ try {
90
+ ua = userAgent.toLowerCase();
91
+ } catch {
92
+ return null;
93
+ }
94
+ if (ua.length === 0 || ua.length > 4096) return null;
95
+
96
+ for (const entry of AGENT_TOKENS) {
97
+ if (hasAnchoredToken(ua, entry.token, "token")) {
98
+ return {
99
+ agent: entry.agent,
100
+ provider: entry.provider,
101
+ category: entry.category,
102
+ tier: "exact",
103
+ };
104
+ }
105
+ }
106
+
107
+ for (const entry of PROVIDER_ALIASES) {
108
+ if (hasAnchoredToken(ua, entry.alias, "prefix")) {
109
+ return {
110
+ agent: null,
111
+ provider: entry.provider,
112
+ category: "ai_crawler",
113
+ tier: "provider",
114
+ };
115
+ }
116
+ }
117
+
118
+ return null;
119
+ }
package/src/report.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Payload construction + delivery.
3
+ *
4
+ * Contract: never throws, never blocks the response, 1500 ms
5
+ * timeout, `keepalive: true`, snake_case on the wire. Delivery failures are
6
+ * silent — a broken analytics call must never surface on a customer's site.
7
+ *
8
+ * Silent is not the same as invisible: an opt-in `onError` reports a non-2xx
9
+ * or a network failure to the caller. Without it a rejected report cannot be
10
+ * told from a delivered one, and a bad token reads as "no crawlers visited".
11
+ */
12
+
13
+ import type {
14
+ AiCrawlDeliveryError,
15
+ AiCrawlWirePayload,
16
+ ResolvedAiCrawlConfig,
17
+ } from "./types.js";
18
+
19
+ export const REPORT_TIMEOUT_MS = 1500;
20
+
21
+ /** Rebuild the reported URL against `publicOrigin` when configured. Never throws. */
22
+ export function buildReportUrl(cfg: ResolvedAiCrawlConfig, url: string): string {
23
+ try {
24
+ if (cfg.publicOrigin === undefined) return url;
25
+ const schemeIdx = url.indexOf("://");
26
+ if (schemeIdx === -1) {
27
+ // Bare path — just prefix the origin.
28
+ return cfg.publicOrigin + (url.startsWith("/") ? url : `/${url}`);
29
+ }
30
+ const pathStart = url.indexOf("/", schemeIdx + 3);
31
+ return pathStart === -1 ? cfg.publicOrigin + "/" : cfg.publicOrigin + url.slice(pathStart);
32
+ } catch {
33
+ return url;
34
+ }
35
+ }
36
+
37
+ export interface ReportInput {
38
+ url: string;
39
+ method: string;
40
+ userAgent: string;
41
+ status?: number | undefined;
42
+ ip?: string | undefined;
43
+ }
44
+
45
+ /** Build the wire payload. Optional fields are OMITTED, never null/wrong. */
46
+ export function buildPayload(cfg: ResolvedAiCrawlConfig, input: ReportInput): AiCrawlWirePayload {
47
+ const payload: AiCrawlWirePayload = {
48
+ site_id: cfg.siteId,
49
+ url: buildReportUrl(cfg, input.url),
50
+ method: input.method.toUpperCase(),
51
+ user_agent: input.userAgent.slice(0, 1024),
52
+ ts: Date.now(),
53
+ };
54
+ if (typeof input.status === "number" && input.status >= 100 && input.status <= 599) {
55
+ payload.status = input.status;
56
+ }
57
+ if (input.ip !== undefined) {
58
+ payload.ip = input.ip;
59
+ }
60
+ return payload;
61
+ }
62
+
63
+ /** Abort signal with a 1500 ms timeout, where the runtime supports it. */
64
+ function timeoutSignal(): AbortSignal | undefined {
65
+ try {
66
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
67
+ return AbortSignal.timeout(REPORT_TIMEOUT_MS);
68
+ }
69
+ } catch {
70
+ /* ignore */
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ /** Delivery options. */
76
+ export interface SendOptions {
77
+ /**
78
+ * Set `keepalive: true` on the fetch. Default `true` for fire-and-forget
79
+ * paths (Express, bare fetch) where nothing else keeps delivery alive
80
+ * past the response. Callers that schedule via `waitUntil` pass `false`:
81
+ * `waitUntil` already extends the lifetime, and `keepalive` is not
82
+ * reliably supported on workerd — if the option ever threw there, every
83
+ * report from the Cloudflare adapters would be lost, silently.
84
+ */
85
+ keepalive?: boolean;
86
+ }
87
+
88
+ /**
89
+ * Send a report. Returns a promise that ALWAYS resolves — network errors,
90
+ * timeouts and non-2xx responses are swallowed. Suitable for `waitUntil`
91
+ * (which needs the promise) and for fire-and-forget (ignore it).
92
+ */
93
+ export function sendReport(
94
+ cfg: ResolvedAiCrawlConfig,
95
+ payload: AiCrawlWirePayload,
96
+ options?: SendOptions,
97
+ ): Promise<void> {
98
+ try {
99
+ const fetchImpl = cfg.fetch ?? (typeof fetch !== "undefined" ? fetch : undefined);
100
+ if (fetchImpl === undefined) return Promise.resolve();
101
+
102
+ const signal = timeoutSignal();
103
+ const init: RequestInit = {
104
+ method: "POST",
105
+ headers: {
106
+ "content-type": "application/json",
107
+ authorization: `Bearer ${cfg.authToken}`,
108
+ },
109
+ body: JSON.stringify(payload),
110
+ };
111
+ // Omit the property entirely when disabled — safer than `keepalive:
112
+ // false` on runtimes that reject the option.
113
+ if (options?.keepalive !== false) init.keepalive = true;
114
+ if (signal !== undefined) init.signal = signal;
115
+
116
+ return fetchImpl(cfg.endpoint, init).then(
117
+ (res) => {
118
+ // A rejected report used to be indistinguishable from a delivered one:
119
+ // both branches discarded their argument, so a 401 from a bad token or
120
+ // a 403 from blocked egress read as success and the customer saw
121
+ // "no crawlers" forever. Still silent by default: `onError` is opt-in.
122
+ if (res !== undefined && res !== null && res.ok !== true) {
123
+ notifyError(cfg, { kind: "http", status: res.status, endpoint: cfg.endpoint });
124
+ }
125
+ return undefined;
126
+ },
127
+ (cause: unknown) => {
128
+ notifyError(cfg, { kind: "network", cause, endpoint: cfg.endpoint });
129
+ return undefined;
130
+ },
131
+ );
132
+ } catch {
133
+ return Promise.resolve();
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Hand a delivery failure to the customer's callback.
139
+ *
140
+ * Guarded: a throwing `onError` must not escape into the host's response path,
141
+ * which is the whole reason delivery is silent in the first place.
142
+ */
143
+ function notifyError(cfg: ResolvedAiCrawlConfig, error: AiCrawlDeliveryError): void {
144
+ try {
145
+ cfg.onError?.(error);
146
+ } catch {
147
+ // Deliberately empty — see above.
148
+ }
149
+ }
package/src/track.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shared orchestrator used by every adapter:
3
+ * pre-filter → anchored UA match → category opt-outs → IP resolution → send.
4
+ *
5
+ * `trackFacts` never throws and never blocks: it either schedules the send
6
+ * via the runtime's `waitUntil` or fires-and-forgets it.
7
+ */
8
+
9
+ import { passesPreFilter } from "./filter.js";
10
+ import { resolveCrawlerIp } from "./ip.js";
11
+ import { matchCrawler } from "./matcher.js";
12
+ import { buildPayload, sendReport } from "./report.js";
13
+ import type {
14
+ AiCrawlWirePayload,
15
+ CrawlerCategory,
16
+ RequestFacts,
17
+ ResolvedAiCrawlConfig,
18
+ } from "./types.js";
19
+
20
+ /** Minimal structural view of a fetch-API `Request` — avoids DOM/runtime type deps. */
21
+ export interface FetchLikeRequest {
22
+ readonly method: string;
23
+ readonly url: string;
24
+ readonly headers: { get(name: string): string | null };
25
+ }
26
+
27
+ /** Build {@link RequestFacts} from a fetch-API `Request`. Never throws. */
28
+ export function factsFromFetchRequest(request: FetchLikeRequest): RequestFacts {
29
+ return {
30
+ method: request.method,
31
+ url: request.url,
32
+ header: (name: string): string | null => {
33
+ try {
34
+ return request.headers.get(name);
35
+ } catch {
36
+ return null;
37
+ }
38
+ },
39
+ };
40
+ }
41
+
42
+ /** Is this category disabled by config? */
43
+ function categoryDisabled(cfg: ResolvedAiCrawlConfig, category: CrawlerCategory): boolean {
44
+ switch (category) {
45
+ case "answer_fetch":
46
+ return cfg.disableAnswerFetch;
47
+ case "search_index":
48
+ return cfg.disableSearchCrawlers;
49
+ case "training":
50
+ return cfg.disableTrainingCrawlers;
51
+ case "ai_crawler":
52
+ return cfg.disableOtherCrawlers;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Evaluate a request. Returns the wire payload when it should be reported,
58
+ * `null` otherwise. Pure apart from `Date.now()`; never throws.
59
+ */
60
+ export function evaluateRequest(
61
+ cfg: ResolvedAiCrawlConfig,
62
+ facts: RequestFacts,
63
+ status?: number | undefined,
64
+ ): AiCrawlWirePayload | null {
65
+ try {
66
+ const secFetchDest = facts.header("sec-fetch-dest");
67
+ if (!passesPreFilter(cfg, facts.method, facts.url, secFetchDest)) return null;
68
+
69
+ const userAgent = facts.header("user-agent");
70
+ const match = matchCrawler(userAgent);
71
+ if (match === null) return null;
72
+ if (categoryDisabled(cfg, match.category)) return null;
73
+
74
+ return buildPayload(cfg, {
75
+ url: facts.url,
76
+ method: facts.method,
77
+ userAgent: userAgent as string, // non-null: matchCrawler(null) === null
78
+ status,
79
+ ip: resolveCrawlerIp(cfg, facts),
80
+ });
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Evaluate and deliver. Uses `waitUntil` where the runtime provides one so
88
+ * the report outlives the response without ever delaying it; otherwise
89
+ * fire-and-forget. Never throws.
90
+ */
91
+ export function trackFacts(
92
+ cfg: ResolvedAiCrawlConfig,
93
+ facts: RequestFacts,
94
+ status?: number | undefined,
95
+ waitUntil?: ((promise: Promise<unknown>) => void) | undefined,
96
+ ): void {
97
+ try {
98
+ const payload = evaluateRequest(cfg, facts, status);
99
+ if (payload === null) return;
100
+
101
+ // With a waitUntil, delivery lifetime is already guaranteed — skip
102
+ // keepalive (unverified on workerd). Without one, keepalive is what
103
+ // keeps the request alive past the response.
104
+ const delivery = sendReport(cfg, payload, {
105
+ keepalive: typeof waitUntil !== "function",
106
+ });
107
+ if (typeof waitUntil === "function") {
108
+ try {
109
+ waitUntil(delivery);
110
+ } catch {
111
+ /* runtime rejected the promise registration — delivery still runs */
112
+ }
113
+ }
114
+ } catch {
115
+ /* silent by contract */
116
+ }
117
+ }