@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/dist/adapters/cloudflare-pages.d.ts +42 -0
- package/dist/adapters/cloudflare-pages.d.ts.map +1 -0
- package/dist/adapters/cloudflare-pages.js +46 -0
- package/dist/adapters/cloudflare-pages.js.map +1 -0
- package/dist/adapters/cloudflare-workers.d.ts +41 -0
- package/dist/adapters/cloudflare-workers.d.ts.map +1 -0
- package/dist/adapters/cloudflare-workers.js +49 -0
- package/dist/adapters/cloudflare-workers.js.map +1 -0
- package/dist/adapters/express.d.ts +49 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/express.js +91 -0
- package/dist/adapters/express.js.map +1 -0
- package/dist/adapters/hono.d.ts +48 -0
- package/dist/adapters/hono.d.ts.map +1 -0
- package/dist/adapters/hono.js +64 -0
- package/dist/adapters/hono.js.map +1 -0
- package/dist/adapters/next.d.ts +41 -0
- package/dist/adapters/next.d.ts.map +1 -0
- package/dist/adapters/next.js +70 -0
- package/dist/adapters/next.js.map +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +99 -0
- package/dist/config.js.map +1 -0
- package/dist/crawlers.d.ts +68 -0
- package/dist/crawlers.d.ts.map +1 -0
- package/dist/crawlers.js +248 -0
- package/dist/crawlers.js.map +1 -0
- package/dist/filter.d.ts +33 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +169 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/ip.d.ts +35 -0
- package/dist/ip.d.ts.map +1 -0
- package/dist/ip.js +109 -0
- package/dist/ip.js.map +1 -0
- package/dist/matcher.d.ts +44 -0
- package/dist/matcher.d.ts.map +1 -0
- package/dist/matcher.js +111 -0
- package/dist/matcher.js.map +1 -0
- package/dist/report.d.ts +43 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +116 -0
- package/dist/report.js.map +1 -0
- package/dist/track.d.ts +30 -0
- package/dist/track.d.ts.map +1 -0
- package/dist/track.js +96 -0
- package/dist/track.js.map +1 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/package.json +87 -0
- package/src/adapters/cloudflare-pages.ts +64 -0
- package/src/adapters/cloudflare-workers.ts +70 -0
- package/src/adapters/express.ts +113 -0
- package/src/adapters/hono.ts +89 -0
- package/src/adapters/next.ts +87 -0
- package/src/config.ts +127 -0
- package/src/crawlers.ts +269 -0
- package/src/filter.ts +178 -0
- package/src/index.ts +46 -0
- package/src/ip.ts +112 -0
- package/src/matcher.ts +119 -0
- package/src/report.ts +149 -0
- package/src/track.ts +117 -0
- 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
|
+
}
|