@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - Unreleased
|
|
9
|
+
|
|
10
|
+
Initial public release.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Local pre-filter and crawler-token matcher, zero runtime dependencies.
|
|
15
|
+
- Adapters: Next.js, Cloudflare Workers, Cloudflare Pages Functions, Express, Hono.
|
|
16
|
+
- Crawler IP resolution and reporting to `POST /v1/ai-crawls`.
|
|
17
|
+
- Optional `onError` callback, reporting non-2xx and network failures without breaking the never-throws contract.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Traceten
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# @traceten/ai-crawl
|
|
2
|
+
|
|
3
|
+
[](https://github.com/traceten/ai-crawl/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@traceten/ai-crawl)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
|
|
8
|
+
Server-side AI crawler tracking for Traceten. See the GPTBot, ClaudeBot and PerplexityBot crawls that browser JavaScript can never see.
|
|
9
|
+
|
|
10
|
+
AI crawlers do not execute JavaScript. They issue one HTTP GET for the HTML and leave, so the Traceten snippet is structurally blind to them. The only possible observation point is your server, edge function, or worker. This package is that observation point.
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
1. Pre-filters locally: only GET/HEAD, skips assets and API routes, skips browser subresource fetches. Non-crawler traffic costs one string scan and no network call.
|
|
15
|
+
2. Matches the user agent against a local list of known AI crawler tokens, anchored at token boundaries (a UA containing `not-really-GPTBot` does not match).
|
|
16
|
+
3. Reports plausible AI crawls to `POST https://ingest.traceten.com/v1/ai-crawls`, authenticated with your crawl token. Delivery is scheduled after the response (via `waitUntil` where the runtime has one) and never delays or breaks a request.
|
|
17
|
+
|
|
18
|
+
Provider, category (answer fetch, search index, training, other), verification and confidence are decided by Traceten's servers against the full crawler registry, so new crawlers are covered without upgrading this package.
|
|
19
|
+
|
|
20
|
+
## Contract
|
|
21
|
+
|
|
22
|
+
- Never blocks the response. Never throws per-request. Failure is silent.
|
|
23
|
+
- Zero runtime dependencies.
|
|
24
|
+
- 1500 ms delivery timeout, `keepalive: true`.
|
|
25
|
+
- Requires Node 18+ or an edge runtime with `fetch`.
|
|
26
|
+
- Each adapter bundle is under 8 KB gzipped (CI-enforced).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @traceten/ai-crawl
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { defineAiCrawlConfig } from "@traceten/ai-crawl";
|
|
38
|
+
|
|
39
|
+
const config = defineAiCrawlConfig({
|
|
40
|
+
siteId: "ttid_7Rb4TrC1dTbnD8w3s1TS12", // your site key (the ttid_… data-site value on the install page; case-sensitive)
|
|
41
|
+
authToken: process.env.TRACETEN_CRAWL_TOKEN!, // tt_bot_..., created in the dashboard. REQUIRED.
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`defineAiCrawlConfig` throws on invalid input. Call it at module scope or server boot so a missing token fails your deploy, not your visitors' requests. The endpoint rejects unauthenticated reports, so a missing token would otherwise mean every report silently returns 401.
|
|
46
|
+
|
|
47
|
+
The `authToken` is a server-side secret. Do not put it in browser code, and do not reuse the public snippet key in its place.
|
|
48
|
+
|
|
49
|
+
### Options
|
|
50
|
+
|
|
51
|
+
| Option | Default | Meaning |
|
|
52
|
+
| ------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| `endpoint` | `https://ingest.traceten.com/v1/ai-crawls` | Ingestion endpoint |
|
|
54
|
+
| `allowedMethods` | `["GET", "HEAD"]` | Methods considered at all |
|
|
55
|
+
| `extraDenyPathPrefixes` | `[]` | Added to the built-in deny-list (`/api`, `/_next`, `/static`, ...). Extends, never replaces. Prefixes match whole path segments: `/api` denies `/api` and `/api/users` but not `/apidocs` |
|
|
56
|
+
| `extraDenyExtensions` | `[]` | Added to the built-in static-extension deny-list. Extends, never replaces |
|
|
57
|
+
| `disableAnswerFetch` | `false` | Skip user-triggered fetchers (ChatGPT-User, Claude-User, ...) |
|
|
58
|
+
| `disableSearchCrawlers` | `false` | Skip AI search index crawlers (OAI-SearchBot, PerplexityBot, ...) |
|
|
59
|
+
| `disableTrainingCrawlers` | `false` | Skip training crawlers (GPTBot, ClaudeBot, CCBot, ...) |
|
|
60
|
+
| `disableOtherCrawlers` | `false` | Skip uncategorised AI bots and unrecognised agents from known vendors |
|
|
61
|
+
| `trustProxy` | `false` | Trust `x-forwarded-for` for IP resolution. Off by default because the header is spoofable |
|
|
62
|
+
| `proxyDepth` | `1` | Number of trusted proxies appending to `x-forwarded-for` (used only with `trustProxy`) |
|
|
63
|
+
| `trustCfConnectingIp` | `false` | Trust the `cf-connecting-ip` header. The Cloudflare Workers and Pages adapters enable this automatically; set it manually only when your origin sits behind Cloudflare. Off by default because any client can forge the header on a non-Cloudflare origin |
|
|
64
|
+
| `publicOrigin` | none | Rebuild reported URLs on this origin, for containers behind reverse proxies that see internal hosts |
|
|
65
|
+
| `onError` | none | Called when a report does not land: a non-2xx, or a network/timeout failure. Off by default; delivery stays silent either way |
|
|
66
|
+
|
|
67
|
+
### Knowing when reports are not landing
|
|
68
|
+
|
|
69
|
+
Delivery is silent by design: a broken analytics call must never surface on your
|
|
70
|
+
site, so `sendReport` never throws and never retries. The cost is that a
|
|
71
|
+
rejected report looks exactly like a delivered one. A wrong `authToken`, or
|
|
72
|
+
egress your platform blocks, both read as "no AI crawlers visited" indefinitely.
|
|
73
|
+
|
|
74
|
+
`onError` closes that gap without changing the safety posture:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
export const aiCrawl = defineAiCrawlConfig({
|
|
78
|
+
siteId: "ttid_7Rb4TrC1dTbnD8w3s1TS12",
|
|
79
|
+
authToken: process.env.TRACETEN_BOT_TOKEN!,
|
|
80
|
+
onError: (e) => {
|
|
81
|
+
// e.kind is "http" (with e.status) or "network" (with e.cause)
|
|
82
|
+
console.warn("[traceten] crawl report failed", e.kind, e.status ?? e.cause);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Your callback runs inside a `try/catch`, so throwing from it cannot break the
|
|
88
|
+
host response. A `401` or `403` means the token or the origin; repeated
|
|
89
|
+
`network` failures mean egress. Worth wiring in staging at minimum: it is the
|
|
90
|
+
difference between "no crawlers came" and "nothing we sent was accepted".
|
|
91
|
+
|
|
92
|
+
Crawler-facing paths (`/robots.txt`, `/llms.txt`, `/llms-full.txt`, `*sitemap*.xml`) are always reported even though `.txt` and `.xml` are otherwise denied. A GPTBot hit on `/llms.txt` is one of the highest-signal events this product can capture.
|
|
93
|
+
|
|
94
|
+
## Adapters
|
|
95
|
+
|
|
96
|
+
### Next.js (proxy / middleware)
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// proxy.ts (Next 15.5+; middleware.ts on earlier versions)
|
|
100
|
+
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";
|
|
101
|
+
import { defineAiCrawlConfig } from "@traceten/ai-crawl";
|
|
102
|
+
import { trackAICrawlerRequest } from "@traceten/ai-crawl/next";
|
|
103
|
+
|
|
104
|
+
const config = defineAiCrawlConfig({
|
|
105
|
+
siteId: process.env.TRACETEN_SITE_ID!,
|
|
106
|
+
authToken: process.env.TRACETEN_CRAWL_TOKEN!,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export function proxy(request: NextRequest, event: NextFetchEvent) {
|
|
110
|
+
trackAICrawlerRequest(request, event, config);
|
|
111
|
+
return NextResponse.next();
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Cloudflare Workers
|
|
116
|
+
|
|
117
|
+
For a site already behind Cloudflare this needs no application deploy at all.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { defineAiCrawlConfig } from "@traceten/ai-crawl";
|
|
121
|
+
import { withAICrawlerTracking } from "@traceten/ai-crawl/cloudflare-workers";
|
|
122
|
+
|
|
123
|
+
const config = defineAiCrawlConfig({
|
|
124
|
+
siteId: "ttid_7Rb4TrC1dTbnD8w3s1TS12",
|
|
125
|
+
authToken: "tt_bot_...", // prefer an env binding in production
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export default {
|
|
129
|
+
fetch: withAICrawlerTracking(async (request, env, ctx) => {
|
|
130
|
+
return fetch(request); // your existing origin logic
|
|
131
|
+
}, config),
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The wrapper captures the real response status and reports through `ctx.waitUntil`. Errors from your handler propagate untouched.
|
|
136
|
+
|
|
137
|
+
### Cloudflare Pages Functions
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// functions/_middleware.ts
|
|
141
|
+
import { createAICrawlerPagesMiddleware } from "@traceten/ai-crawl/cloudflare-pages";
|
|
142
|
+
|
|
143
|
+
export const onRequest = createAICrawlerPagesMiddleware({
|
|
144
|
+
siteId: "ttid_7Rb4TrC1dTbnD8w3s1TS12",
|
|
145
|
+
authToken: "tt_bot_...",
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Express
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import express from "express";
|
|
153
|
+
import { createTracetenAICrawlerMiddleware } from "@traceten/ai-crawl/express";
|
|
154
|
+
|
|
155
|
+
const app = express();
|
|
156
|
+
app.use(
|
|
157
|
+
createTracetenAICrawlerMiddleware({
|
|
158
|
+
siteId: process.env.TRACETEN_SITE_ID!,
|
|
159
|
+
authToken: process.env.TRACETEN_CRAWL_TOKEN!,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Calls `next()` immediately and reports on the response `finish` event, so the real status code is captured and no request is ever delayed.
|
|
165
|
+
|
|
166
|
+
Set `publicOrigin` here. Without it the reported URL is built from the request's `Host` header (which a client can set to anything) with an assumed `https` scheme. With `publicOrigin` the reported URL is always on your real origin:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
createTracetenAICrawlerMiddleware({
|
|
170
|
+
siteId: process.env.TRACETEN_SITE_ID!,
|
|
171
|
+
authToken: process.env.TRACETEN_CRAWL_TOKEN!,
|
|
172
|
+
publicOrigin: "https://www.example.com",
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Hono / generic Request+Response
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { Hono } from "hono";
|
|
180
|
+
import { aiCrawlerTracking } from "@traceten/ai-crawl/hono";
|
|
181
|
+
|
|
182
|
+
const app = new Hono();
|
|
183
|
+
app.use("*", aiCrawlerTracking({ siteId: "...", authToken: "tt_bot_..." }));
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Any other framework that exposes fetch-API objects can use `trackAICrawlerFetch(request, response, config, waitUntil?)` from the same entry point.
|
|
187
|
+
|
|
188
|
+
## What is sent, including the crawler IP
|
|
189
|
+
|
|
190
|
+
For each matched crawl the package sends exactly:
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
|
|
195
|
+
"url": "https://example.com/docs/pricing",
|
|
196
|
+
"method": "GET",
|
|
197
|
+
"status": 200,
|
|
198
|
+
"user_agent": "Mozilla/5.0 ... compatible; GPTBot/1.1; +https://openai.com/gptbot",
|
|
199
|
+
"ip": "20.15.240.64",
|
|
200
|
+
"ts": 1765432100000
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The `ip` field is the crawler's IP address, resolved from `cf-connecting-ip` (only where trusted, see `trustCfConnectingIp`), then `x-forwarded-for` (only when `trustProxy` is set), then the socket address. The field is omitted when no trustworthy value can be derived.
|
|
205
|
+
|
|
206
|
+
Sending it is deliberate and required. The crawler's TCP connection terminates at your server, so its IP is observable only there. Traceten uses it to verify the crawl against network evidence (for example OpenAI publishes GPTBot's IP ranges), which is what separates a real GPTBot crawl from a spoofed user agent. Verification runs at Traceten's edge while the value is in scope, and the reported IP is always treated as a claim to be verified, never as proof by itself.
|
|
207
|
+
|
|
208
|
+
Retention depends on the verdict. Traceten keeps the raw address in exactly two cases: it fell inside a range the vendor publishes for that crawler, or its reverse DNS forward-confirmed to the vendor's own domain. In every other case, including an address that only matches the provider's wider network, the address is discarded at the edge and only an HMAC-SHA-256 hash under a per-site derived key is stored. Those rows are hash-only: Traceten does not resolve or store the network operator (ASN) or the country for a crawler IP, and writes neither field. Verification that needs a reverse-DNS lookup sends the address to Cloudflare's public DNS resolver before anything about it is known. This package never sends visitor analytics, cookies, or request bodies.
|
|
209
|
+
|
|
210
|
+
No batching in v1: each matched crawl is one report. Crawl volume on typical sites is orders of magnitude below pageview volume because of the pre-filter.
|
|
211
|
+
|
|
212
|
+
## Privacy and GDPR
|
|
213
|
+
|
|
214
|
+
- Traceten processes crawl reports on your behalf, as your processor under the Traceten Data Processing Agreement.
|
|
215
|
+
- Your privacy notice should reflect this transfer: crawler request metadata (URL, user agent, IP address) is shared with Traceten for bot and crawler detection.
|
|
216
|
+
- The typical legal basis is legitimate interest in detecting and attributing automated crawler traffic. Confirm this with your own counsel for your jurisdiction.
|
|
217
|
+
- Hashed unverified IPs are pseudonymized personal data. Crawl records are records of automated software requests rather than of a visitor, and they carry no visitor or session identifier, so Traceten's per-visitor access and deletion API cannot reach them — it has no key to look one up by. That is a limit of the API, not a claim that the records can never be matched: the hash is deterministic per site, so where a data subject supplies the address itself, Traceten can find and erase their rows. Those requests are handled manually — email privacy@traceten.com with the address, the site and a date range. (This is the GDPR Article 11(2) situation: no obligation to acquire extra data to identify someone, full rights where the data subject supplies what makes identification possible.) What otherwise bounds these records: the 90-day limit below, hash-only storage for anything unverified, and whole-account erasure, which removes every crawl record and rollup for every site on the account. See [the crawl privacy notes](https://docs.traceten.com/privacy/data-collected#ai-crawler-ip-addresses).
|
|
218
|
+
- Individual crawl records are deleted after 90 days on every plan. The daily rollups derived from them are counts grouped by crawler and by page path, with no address, no hash and no personal identifier. Page paths therefore persist in aggregate after the records containing them expire.
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
npm install
|
|
224
|
+
npm test # vitest
|
|
225
|
+
npm run typecheck
|
|
226
|
+
npm run build # tsc + size-gate bundles + 8KB check
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The local token list lives in `src/crawlers.ts`. It's a deliberately-loose cost filter behind Traceten's server-side crawler registry, which is the actual source of truth for provider, category, verification and confidence — this list only decides "worth reporting locally." `Google-Extended` and `Applebot-Extended` are excluded on purpose: they are robots.txt control tokens and never appear as live user agents.
|
|
230
|
+
|
|
231
|
+
## Versioning
|
|
232
|
+
|
|
233
|
+
This package follows [Semantic Versioning](https://semver.org/). Before `1.0.0`,
|
|
234
|
+
minor versions may include breaking changes — pin an exact version in production
|
|
235
|
+
until then. See [CHANGELOG.md](./CHANGELOG.md) for release history.
|
|
236
|
+
|
|
237
|
+
## Contributing
|
|
238
|
+
|
|
239
|
+
Issues and pull requests are welcome. For anything beyond a small fix, please
|
|
240
|
+
open an issue first to discuss the change. Run the checks below before
|
|
241
|
+
submitting a PR — CI enforces the same steps on every pull request:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
npm install
|
|
245
|
+
npm run typecheck
|
|
246
|
+
npm run build
|
|
247
|
+
npm test
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
MIT © Traceten — see [LICENSE](./LICENSE).
|
|
253
|
+
|
|
254
|
+
## Links
|
|
255
|
+
|
|
256
|
+
- [Documentation](https://docs.traceten.com/install/ai-crawler-tracking)
|
|
257
|
+
- [Traceten](https://traceten.com) — AI traffic attribution for the AI search era
|
|
258
|
+
- [Issues](https://github.com/traceten/ai-crawl/issues)
|
|
259
|
+
- [Changelog](./CHANGELOG.md)
|
|
260
|
+
- [Node SDK](https://github.com/traceten/sdk-node) · [Python SDK](https://github.com/traceten/sdk-python) · [Go SDK](https://github.com/traceten/traceten-go)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Pages Functions adapter — drop into `functions/_middleware.ts`.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* // functions/_middleware.ts
|
|
6
|
+
* import { defineAiCrawlConfig } from "@traceten/ai-crawl";
|
|
7
|
+
* import { createAICrawlerPagesMiddleware } from "@traceten/ai-crawl/cloudflare-pages";
|
|
8
|
+
*
|
|
9
|
+
* export const onRequest = createAICrawlerPagesMiddleware(
|
|
10
|
+
* defineAiCrawlConfig({
|
|
11
|
+
* siteId: "…", // or read from context.env inside your own wrapper
|
|
12
|
+
* authToken: "tt_bot_…",
|
|
13
|
+
* }),
|
|
14
|
+
* );
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Construction throws on invalid config (deploy-time failure); the returned
|
|
18
|
+
* middleware never throws and never delays the response — delivery goes
|
|
19
|
+
* through `context.waitUntil` and observes the REAL response status.
|
|
20
|
+
*/
|
|
21
|
+
import { type FetchLikeRequest } from "../track.js";
|
|
22
|
+
import type { AiCrawlConfig, ResolvedAiCrawlConfig } from "../types.js";
|
|
23
|
+
/** Structural subset of Pages Functions' `EventContext`. */
|
|
24
|
+
export interface PagesContext {
|
|
25
|
+
request: FetchLikeRequest;
|
|
26
|
+
next(): Promise<{
|
|
27
|
+
status: number;
|
|
28
|
+
}> | {
|
|
29
|
+
status: number;
|
|
30
|
+
};
|
|
31
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
32
|
+
}
|
|
33
|
+
type PagesMiddleware = (context: PagesContext) => Promise<{
|
|
34
|
+
status: number;
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Build a Pages Functions middleware. THROWS at construction when the config
|
|
38
|
+
* is invalid — that is deploy time, exactly where a bad token should fail.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createAICrawlerPagesMiddleware(config: AiCrawlConfig | ResolvedAiCrawlConfig): PagesMiddleware;
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=cloudflare-pages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare-pages.d.ts","sourceRoot":"","sources":["../../src/adapters/cloudflare-pages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,EAAqC,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACvF,OAAO,KAAK,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAExE,4DAA4D;AAC5D,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,gBAAgB,CAAC;IAC1B,IAAI,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACzD,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;CAC5C;AAED,KAAK,eAAe,GAAG,CAAC,OAAO,EAAE,YAAY,KAAK,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAE9E;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,aAAa,GAAG,qBAAqB,GAC5C,eAAe,CAuBjB"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Pages Functions adapter — drop into `functions/_middleware.ts`.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* // functions/_middleware.ts
|
|
6
|
+
* import { defineAiCrawlConfig } from "@traceten/ai-crawl";
|
|
7
|
+
* import { createAICrawlerPagesMiddleware } from "@traceten/ai-crawl/cloudflare-pages";
|
|
8
|
+
*
|
|
9
|
+
* export const onRequest = createAICrawlerPagesMiddleware(
|
|
10
|
+
* defineAiCrawlConfig({
|
|
11
|
+
* siteId: "…", // or read from context.env inside your own wrapper
|
|
12
|
+
* authToken: "tt_bot_…",
|
|
13
|
+
* }),
|
|
14
|
+
* );
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Construction throws on invalid config (deploy-time failure); the returned
|
|
18
|
+
* middleware never throws and never delays the response — delivery goes
|
|
19
|
+
* through `context.waitUntil` and observes the REAL response status.
|
|
20
|
+
*/
|
|
21
|
+
import { defineAiCrawlConfig, isResolvedConfig } from "../config.js";
|
|
22
|
+
import { factsFromFetchRequest, trackFacts } from "../track.js";
|
|
23
|
+
/**
|
|
24
|
+
* Build a Pages Functions middleware. THROWS at construction when the config
|
|
25
|
+
* is invalid — that is deploy time, exactly where a bad token should fail.
|
|
26
|
+
*/
|
|
27
|
+
export function createAICrawlerPagesMiddleware(config) {
|
|
28
|
+
const resolved = isResolvedConfig(config) ? config : defineAiCrawlConfig(config);
|
|
29
|
+
// On Pages the platform strips and rewrites cf-connecting-ip, so it is
|
|
30
|
+
// trustworthy here (and only here / Workers) by default.
|
|
31
|
+
const cfg = { ...resolved, trustCfConnectingIp: true };
|
|
32
|
+
return async (context) => {
|
|
33
|
+
// The customer's response comes first and is never touched. If next()
|
|
34
|
+
// itself throws, that error propagates untouched — swallowing it would
|
|
35
|
+
// change the site's behaviour.
|
|
36
|
+
const response = await context.next();
|
|
37
|
+
try {
|
|
38
|
+
trackFacts(cfg, factsFromFetchRequest(context.request), typeof response?.status === "number" ? response.status : undefined, (p) => context.waitUntil(p));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* silent by contract */
|
|
42
|
+
}
|
|
43
|
+
return response;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=cloudflare-pages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare-pages.js","sourceRoot":"","sources":["../../src/adapters/cloudflare-pages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,EAAE,qBAAqB,EAAE,UAAU,EAAyB,MAAM,aAAa,CAAC;AAYvF;;;GAGG;AACH,MAAM,UAAU,8BAA8B,CAC5C,MAA6C;IAE7C,MAAM,QAAQ,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC;IACjF,uEAAuE;IACvE,yDAAyD;IACzD,MAAM,GAAG,GAA0B,EAAE,GAAG,QAAQ,EAAE,mBAAmB,EAAE,IAAI,EAAE,CAAC;IAE9E,OAAO,KAAK,EAAE,OAAqB,EAAE,EAAE;QACrC,sEAAsE;QACtE,uEAAuE;QACvE,+BAA+B;QAC/B,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE,CAAC;QACtC,IAAI,CAAC;YACH,UAAU,CACR,GAAG,EACH,qBAAqB,CAAC,OAAO,CAAC,OAAO,CAAC,EACtC,OAAO,QAAQ,EAAE,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,EAClE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAC5B,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,wBAAwB;QAC1B,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers adapter — wrap your fetch handler.
|
|
3
|
+
*
|
|
4
|
+
* For a customer already behind Cloudflare this is copy-paste with no
|
|
5
|
+
* application deploy, which is why this adapter matters disproportionately.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { defineAiCrawlConfig } from "@traceten/ai-crawl";
|
|
9
|
+
* import { withAICrawlerTracking } from "@traceten/ai-crawl/cloudflare-workers";
|
|
10
|
+
*
|
|
11
|
+
* const config = defineAiCrawlConfig({ siteId: "…", authToken: "tt_bot_…" });
|
|
12
|
+
*
|
|
13
|
+
* export default {
|
|
14
|
+
* fetch: withAICrawlerTracking(async (request, env, ctx) => {
|
|
15
|
+
* return await handle(request);
|
|
16
|
+
* }, config),
|
|
17
|
+
* };
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Construction throws on invalid config; the wrapper never throws its own
|
|
21
|
+
* errors, captures the REAL status code from the handler's response, and
|
|
22
|
+
* schedules delivery via `ctx.waitUntil`. Handler errors propagate untouched.
|
|
23
|
+
*/
|
|
24
|
+
import { type FetchLikeRequest } from "../track.js";
|
|
25
|
+
import type { AiCrawlConfig, ResolvedAiCrawlConfig } from "../types.js";
|
|
26
|
+
/** Structural subset of the Workers `ExecutionContext`. */
|
|
27
|
+
export interface WorkersExecutionContext {
|
|
28
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
29
|
+
}
|
|
30
|
+
type WorkersHandler<Env, Res extends {
|
|
31
|
+
status: number;
|
|
32
|
+
}> = (request: FetchLikeRequest, env: Env, ctx: WorkersExecutionContext) => Promise<Res> | Res;
|
|
33
|
+
/**
|
|
34
|
+
* Wrap a Workers fetch handler with AI crawler tracking. THROWS at
|
|
35
|
+
* construction (module scope — deploy time) when the config is invalid.
|
|
36
|
+
*/
|
|
37
|
+
export declare function withAICrawlerTracking<Env, Res extends {
|
|
38
|
+
status: number;
|
|
39
|
+
}>(handler: WorkersHandler<Env, Res>, config: AiCrawlConfig | ResolvedAiCrawlConfig): WorkersHandler<Env, Res>;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=cloudflare-workers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare-workers.d.ts","sourceRoot":"","sources":["../../src/adapters/cloudflare-workers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,EAAqC,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACvF,OAAO,KAAK,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAExE,2DAA2D;AAC3D,MAAM,WAAW,uBAAuB;IACtC,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;CAC5C;AAED,KAAK,cAAc,CAAC,GAAG,EAAE,GAAG,SAAS;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,IAAI,CACzD,OAAO,EAAE,gBAAgB,EACzB,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,uBAAuB,KACzB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;AAExB;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,GAAG,SAAS;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,EACvE,OAAO,EAAE,cAAc,CAAC,GAAG,EAAE,GAAG,CAAC,EACjC,MAAM,EAAE,aAAa,GAAG,qBAAqB,GAC5C,cAAc,CAAC,GAAG,EAAE,GAAG,CAAC,CAuB1B"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers adapter — wrap your fetch handler.
|
|
3
|
+
*
|
|
4
|
+
* For a customer already behind Cloudflare this is copy-paste with no
|
|
5
|
+
* application deploy, which is why this adapter matters disproportionately.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { defineAiCrawlConfig } from "@traceten/ai-crawl";
|
|
9
|
+
* import { withAICrawlerTracking } from "@traceten/ai-crawl/cloudflare-workers";
|
|
10
|
+
*
|
|
11
|
+
* const config = defineAiCrawlConfig({ siteId: "…", authToken: "tt_bot_…" });
|
|
12
|
+
*
|
|
13
|
+
* export default {
|
|
14
|
+
* fetch: withAICrawlerTracking(async (request, env, ctx) => {
|
|
15
|
+
* return await handle(request);
|
|
16
|
+
* }, config),
|
|
17
|
+
* };
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Construction throws on invalid config; the wrapper never throws its own
|
|
21
|
+
* errors, captures the REAL status code from the handler's response, and
|
|
22
|
+
* schedules delivery via `ctx.waitUntil`. Handler errors propagate untouched.
|
|
23
|
+
*/
|
|
24
|
+
import { defineAiCrawlConfig, isResolvedConfig } from "../config.js";
|
|
25
|
+
import { factsFromFetchRequest, trackFacts } from "../track.js";
|
|
26
|
+
/**
|
|
27
|
+
* Wrap a Workers fetch handler with AI crawler tracking. THROWS at
|
|
28
|
+
* construction (module scope — deploy time) when the config is invalid.
|
|
29
|
+
*/
|
|
30
|
+
export function withAICrawlerTracking(handler, config) {
|
|
31
|
+
const resolved = isResolvedConfig(config) ? config : defineAiCrawlConfig(config);
|
|
32
|
+
// On Workers the platform strips and rewrites cf-connecting-ip, so it is
|
|
33
|
+
// trustworthy here (and only here / Pages) by default.
|
|
34
|
+
const cfg = { ...resolved, trustCfConnectingIp: true };
|
|
35
|
+
return async (request, env, ctx) => {
|
|
36
|
+
// Handler errors propagate untouched — tracking must not change behaviour.
|
|
37
|
+
const response = await handler(request, env, ctx);
|
|
38
|
+
try {
|
|
39
|
+
trackFacts(cfg, factsFromFetchRequest(request), typeof response?.status === "number" ? response.status : undefined, ctx !== undefined && typeof ctx.waitUntil === "function"
|
|
40
|
+
? (p) => ctx.waitUntil(p)
|
|
41
|
+
: undefined);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* silent by contract */
|
|
45
|
+
}
|
|
46
|
+
return response;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=cloudflare-workers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare-workers.js","sourceRoot":"","sources":["../../src/adapters/cloudflare-workers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,EAAE,qBAAqB,EAAE,UAAU,EAAyB,MAAM,aAAa,CAAC;AAcvF;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,OAAiC,EACjC,MAA6C;IAE7C,MAAM,QAAQ,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC;IACjF,yEAAyE;IACzE,uDAAuD;IACvD,MAAM,GAAG,GAA0B,EAAE,GAAG,QAAQ,EAAE,mBAAmB,EAAE,IAAI,EAAE,CAAC;IAE9E,OAAO,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;QACjC,2EAA2E;QAC3E,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;QAClD,IAAI,CAAC;YACH,UAAU,CACR,GAAG,EACH,qBAAqB,CAAC,OAAO,CAAC,EAC9B,OAAO,QAAQ,EAAE,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,EAClE,GAAG,KAAK,SAAS,IAAI,OAAO,GAAG,CAAC,SAAS,KAAK,UAAU;gBACtD,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC;gBACzB,CAAC,CAAC,SAAS,CACd,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,wBAAwB;QAC1B,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express adapter (also works for Connect-style and bare Node servers with a
|
|
3
|
+
* compatible `(req, res, next)` signature).
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { createTracetenAICrawlerMiddleware } from "@traceten/ai-crawl/express";
|
|
7
|
+
*
|
|
8
|
+
* app.use(
|
|
9
|
+
* createTracetenAICrawlerMiddleware({
|
|
10
|
+
* siteId: process.env.TRACETEN_SITE_ID!,
|
|
11
|
+
* authToken: process.env.TRACETEN_CRAWL_TOKEN!,
|
|
12
|
+
* }),
|
|
13
|
+
* );
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Construction throws on invalid config (server boot — where it should).
|
|
17
|
+
* The middleware calls `next()` IMMEDIATELY and attaches a `finish`
|
|
18
|
+
* listener, so the real status code is reported and the request is never
|
|
19
|
+
* delayed. Everything inside is wrapped; failure is silent.
|
|
20
|
+
*
|
|
21
|
+
* Structural types only — no dependency on Express, not even for types.
|
|
22
|
+
*/
|
|
23
|
+
import type { AiCrawlConfig, ResolvedAiCrawlConfig } from "../types.js";
|
|
24
|
+
/** Structural subset of `express.Request` / Node's `IncomingMessage`. */
|
|
25
|
+
export interface NodeLikeRequest {
|
|
26
|
+
method?: string | undefined;
|
|
27
|
+
/** Path + query as received (`/docs/x?y=1`). */
|
|
28
|
+
url?: string | undefined;
|
|
29
|
+
/** Express keeps the pre-router-mount path here; prefer it when present. */
|
|
30
|
+
originalUrl?: string | undefined;
|
|
31
|
+
headers: Record<string, string | string[] | undefined>;
|
|
32
|
+
/** Express convenience; used for URL scheme when present. */
|
|
33
|
+
protocol?: string | undefined;
|
|
34
|
+
socket?: {
|
|
35
|
+
remoteAddress?: string | undefined;
|
|
36
|
+
} | undefined;
|
|
37
|
+
}
|
|
38
|
+
/** Structural subset of `express.Response` / Node's `ServerResponse`. */
|
|
39
|
+
export interface NodeLikeResponse {
|
|
40
|
+
statusCode?: number | undefined;
|
|
41
|
+
on(event: "finish", listener: () => void): unknown;
|
|
42
|
+
}
|
|
43
|
+
export type NodeLikeNext = (err?: unknown) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Create the middleware. THROWS at construction (server boot) when the
|
|
46
|
+
* config is invalid; the returned middleware itself never throws.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createTracetenAICrawlerMiddleware(config: AiCrawlConfig | ResolvedAiCrawlConfig): (req: NodeLikeRequest, res: NodeLikeResponse, next: NodeLikeNext) => void;
|
|
49
|
+
//# sourceMappingURL=express.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../../src/adapters/express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAgB,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEtF,yEAAyE;AACzE,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IACvD,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,MAAM,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GAAG,SAAS,CAAC;CAC7D;AAED,yEAAyE;AACzE,MAAM,WAAW,gBAAgB;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC;CACpD;AAED,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;AA0BnD;;;GAGG;AACH,wBAAgB,iCAAiC,CAC/C,MAAM,EAAE,aAAa,GAAG,qBAAqB,GAC5C,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,gBAAgB,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAiC3E"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express adapter (also works for Connect-style and bare Node servers with a
|
|
3
|
+
* compatible `(req, res, next)` signature).
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { createTracetenAICrawlerMiddleware } from "@traceten/ai-crawl/express";
|
|
7
|
+
*
|
|
8
|
+
* app.use(
|
|
9
|
+
* createTracetenAICrawlerMiddleware({
|
|
10
|
+
* siteId: process.env.TRACETEN_SITE_ID!,
|
|
11
|
+
* authToken: process.env.TRACETEN_CRAWL_TOKEN!,
|
|
12
|
+
* }),
|
|
13
|
+
* );
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Construction throws on invalid config (server boot — where it should).
|
|
17
|
+
* The middleware calls `next()` IMMEDIATELY and attaches a `finish`
|
|
18
|
+
* listener, so the real status code is reported and the request is never
|
|
19
|
+
* delayed. Everything inside is wrapped; failure is silent.
|
|
20
|
+
*
|
|
21
|
+
* Structural types only — no dependency on Express, not even for types.
|
|
22
|
+
*/
|
|
23
|
+
import { defineAiCrawlConfig, isResolvedConfig } from "../config.js";
|
|
24
|
+
import { evaluateRequest } from "../track.js";
|
|
25
|
+
import { sendReport } from "../report.js";
|
|
26
|
+
function headerLookup(req) {
|
|
27
|
+
return (name) => {
|
|
28
|
+
try {
|
|
29
|
+
const value = req.headers[name.toLowerCase()];
|
|
30
|
+
if (value === undefined)
|
|
31
|
+
return null;
|
|
32
|
+
return Array.isArray(value) ? (value[0] ?? null) : value;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Build an absolute URL for the request, preferring `publicOrigin`. */
|
|
40
|
+
function requestUrl(cfg, req) {
|
|
41
|
+
const path = req.originalUrl ?? req.url ?? "/";
|
|
42
|
+
if (path.indexOf("://") !== -1)
|
|
43
|
+
return path; // already absolute (rare)
|
|
44
|
+
if (cfg.publicOrigin !== undefined)
|
|
45
|
+
return cfg.publicOrigin + path;
|
|
46
|
+
const hostHeader = req.headers["host"];
|
|
47
|
+
const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;
|
|
48
|
+
if (host === undefined || host === "")
|
|
49
|
+
return path;
|
|
50
|
+
const proto = req.protocol === "http" ? "http" : "https";
|
|
51
|
+
return `${proto}://${host}${path}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create the middleware. THROWS at construction (server boot) when the
|
|
55
|
+
* config is invalid; the returned middleware itself never throws.
|
|
56
|
+
*/
|
|
57
|
+
export function createTracetenAICrawlerMiddleware(config) {
|
|
58
|
+
const cfg = isResolvedConfig(config) ? config : defineAiCrawlConfig(config);
|
|
59
|
+
return (req, res, next) => {
|
|
60
|
+
try {
|
|
61
|
+
const facts = {
|
|
62
|
+
method: req.method ?? "GET",
|
|
63
|
+
url: requestUrl(cfg, req),
|
|
64
|
+
header: headerLookup(req),
|
|
65
|
+
socketAddr: req.socket?.remoteAddress,
|
|
66
|
+
};
|
|
67
|
+
// Evaluate up-front so non-crawler traffic attaches no listener at
|
|
68
|
+
// all; report on `finish` so the REAL status code is captured.
|
|
69
|
+
const payload = evaluateRequest(cfg, facts);
|
|
70
|
+
if (payload !== null) {
|
|
71
|
+
res.on("finish", () => {
|
|
72
|
+
try {
|
|
73
|
+
const status = res.statusCode;
|
|
74
|
+
if (typeof status === "number" && status >= 100 && status <= 599) {
|
|
75
|
+
payload.status = status;
|
|
76
|
+
}
|
|
77
|
+
void sendReport(cfg, payload);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* silent by contract */
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* silent by contract */
|
|
87
|
+
}
|
|
88
|
+
next();
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=express.js.map
|