@statlark/bots 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -2
- package/dist/cloudflare.d.ts +8 -0
- package/dist/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare.js +16 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/express.d.ts +10 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +25 -0
- package/dist/express.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +213 -0
- package/dist/index.js.map +1 -0
- package/dist/next.d.ts +21 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +23 -0
- package/dist/next.js.map +1 -0
- package/package.json +60 -4
- package/src/cloudflare.ts +23 -0
- package/src/env.d.ts +4 -0
- package/src/express.ts +29 -0
- package/src/index.ts +234 -0
- package/src/next.ts +32 -0
package/README.md
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @statlark/bots
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
See which AI assistants, search engines and training crawlers read your site.
|
|
4
|
+
|
|
5
|
+
Most crawlers request raw HTML and never run JavaScript, so a tracker script can't see them.
|
|
6
|
+
This reports them from your server instead. Reports go out in the background, so they don't
|
|
7
|
+
hold up your responses. They time out after 2 seconds and never throw or fail a request.
|
|
8
|
+
|
|
9
|
+
Full guide: <https://statlark.com/docs/bot-traffic>
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @statlark/bots
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Create a bot key under **Settings → Bot tracking**, then set it on your server as
|
|
18
|
+
`STATLARK_BOT_KEY`. It's a secret: never give it a `NEXT_PUBLIC_` or `VITE_` prefix, and never
|
|
19
|
+
put it in browser code. With no key, the package warns once and does nothing.
|
|
20
|
+
|
|
21
|
+
## Next.js
|
|
22
|
+
|
|
23
|
+
In `proxy.ts`, first, before any login check. Before Next 16 the file is `middleware.ts` and
|
|
24
|
+
the function is `middleware`:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";
|
|
28
|
+
import { trackBot } from "@statlark/bots/next";
|
|
29
|
+
|
|
30
|
+
export function proxy(request: NextRequest, event: NextFetchEvent) {
|
|
31
|
+
trackBot(request, event);
|
|
32
|
+
return NextResponse.next();
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
On Vercel it reads the address from `x-real-ip`. Anywhere else, pass `{ ip }` yourself, or the
|
|
37
|
+
check can't run. `x-forwarded-for` is never used: outside Vercel its leftmost entry is
|
|
38
|
+
client-supplied, so a scanner could forge a verified request.
|
|
39
|
+
|
|
40
|
+
## Cloudflare Workers and Pages
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { trackBot } from "@statlark/bots/cloudflare";
|
|
44
|
+
|
|
45
|
+
export default {
|
|
46
|
+
async fetch(request, env, ctx) {
|
|
47
|
+
trackBot(request, ctx, env);
|
|
48
|
+
return handle(request, env);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Static files never reach a Worker by default, so existing pages and `robots.txt` go uncounted
|
|
54
|
+
unless the Worker runs first. Set `run_worker_first` to patterns that leave your asset folders
|
|
55
|
+
out:
|
|
56
|
+
|
|
57
|
+
```toml
|
|
58
|
+
[assets]
|
|
59
|
+
directory = "./public"
|
|
60
|
+
binding = "ASSETS"
|
|
61
|
+
run_worker_first = ["/*", "!/assets/*"] # use your own asset folders
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every request that reaches the Worker counts towards Cloudflare's limits. On the Workers Free
|
|
65
|
+
plan that's 100,000 a day, and past it those URLs fail until midnight UTC, your homepage
|
|
66
|
+
included. Excluding your asset folders keeps those requests free. Exclude folders, not file
|
|
67
|
+
types: a path that matches an exclusion never reaches your Worker. On Pages, requests that
|
|
68
|
+
reach a Function count the same way, so keep asset folders out in `_routes.json`, but not your
|
|
69
|
+
pages or `robots.txt`.
|
|
70
|
+
|
|
71
|
+
## Express
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import express from "express";
|
|
75
|
+
import { statlarkBots } from "@statlark/bots/express";
|
|
76
|
+
|
|
77
|
+
const app = express();
|
|
78
|
+
app.use(statlarkBots());
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The IP comes from `req.ip`, so Express's `trust proxy` setting decides it, for your whole app:
|
|
82
|
+
|
|
83
|
+
- Nothing in front of Node: don't set `trust proxy`. With any value, `req.ip` comes from the
|
|
84
|
+
`X-Forwarded-For` the client sent.
|
|
85
|
+
- Behind proxies you control that add `X-Forwarded-For`: set it to exactly that many hops, for
|
|
86
|
+
example `app.set("trust proxy", 1)` for one. Never `true`, and never more hops than there are.
|
|
87
|
+
- Already set in your app: leave it as it is.
|
|
88
|
+
- Not sure: leave it out. Real crawlers then read as fake, which is safe, and the guide's
|
|
89
|
+
troubleshooting section has the fix.
|
|
90
|
+
|
|
91
|
+
This package is ESM-only. `require()` works on Node ^20.19 or >=22.12; on older versions use
|
|
92
|
+
`import` or `await import()`. A CommonJS TypeScript project needs `"module": "nodenext"` on
|
|
93
|
+
TypeScript 5.8 or later.
|
|
94
|
+
|
|
95
|
+
## Any other server
|
|
96
|
+
|
|
97
|
+
`reportBotHit` works anywhere with `fetch`:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { reportBotHit } from "@statlark/bots";
|
|
101
|
+
|
|
102
|
+
void reportBotHit({ url: request.url, userAgent: request.headers.get("user-agent"), ip });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Or call the endpoint yourself:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
curl -X POST https://collect.statlark.com/bots \
|
|
109
|
+
-H "Authorization: Bearer $STATLARK_BOT_KEY" \
|
|
110
|
+
-H "Content-Type: application/json" \
|
|
111
|
+
-d '{"url":"https://example.com/pricing","user_agent":"GPTBot/1.1","ip":"132.196.86.10"}'
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Filter by user agent first, so you don't send every visitor to us. The guide has the filter.
|
|
115
|
+
|
|
116
|
+
## What's sent
|
|
117
|
+
|
|
118
|
+
The page's origin and path (the query string and fragment are stripped first) and the user
|
|
119
|
+
agent. When an IP is available, a crawler with a published address list also sends it, so
|
|
120
|
+
Statlark can check the crawler is genuine. Any other request that looks like a crawler sends
|
|
121
|
+
`source` instead: a SHA-256 hash of your bot key and the address, with an IPv6 address cut to
|
|
122
|
+
its /64 first, used only to rate-limit crawlers that can't be checked. Neither is written to
|
|
123
|
+
Statlark's database. The hash is a pseudonym, not anonymous: anyone holding your key could work
|
|
124
|
+
out an IPv4 address from it. Statlark keeps the crawler's name, the path and a count.
|
|
125
|
+
|
|
126
|
+
Framework paths such as `/_next/` and `/api/` aren't sent, and neither is a user agent the
|
|
127
|
+
server has just said it doesn't recognise, for an hour.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type TrackOptions } from "./index.js";
|
|
2
|
+
/** Call this at the top of your Worker's `fetch`, or in a Pages Function. */
|
|
3
|
+
export declare function trackBot(request: Request, ctx: {
|
|
4
|
+
waitUntil(p: Promise<unknown>): void;
|
|
5
|
+
}, env: {
|
|
6
|
+
STATLARK_BOT_KEY?: string;
|
|
7
|
+
}, options?: TrackOptions): void;
|
|
8
|
+
//# sourceMappingURL=cloudflare.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare.d.ts","sourceRoot":"","sources":["../src/cloudflare.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAE7D,6EAA6E;AAC7E,wBAAgB,QAAQ,CACtB,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE;IAAE,SAAS,CAAC,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;CAAE,EAC7C,GAAG,EAAE;IAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,YAAY,GACrB,IAAI,CAcN"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { reportBotHit } from "./index.js";
|
|
2
|
+
/** Call this at the top of your Worker's `fetch`, or in a Pages Function. */
|
|
3
|
+
export function trackBot(request, ctx, env, options) {
|
|
4
|
+
try {
|
|
5
|
+
const promise = reportBotHit({
|
|
6
|
+
url: request.url,
|
|
7
|
+
userAgent: request.headers.get("user-agent"),
|
|
8
|
+
ip: request.headers.get("cf-connecting-ip"),
|
|
9
|
+
}, { ...options, key: options?.key ?? env?.STATLARK_BOT_KEY });
|
|
10
|
+
ctx?.waitUntil?.(promise);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// Never fail the request.
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=cloudflare.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare.js","sourceRoot":"","sources":["../src/cloudflare.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAqB,MAAM,YAAY,CAAC;AAE7D,6EAA6E;AAC7E,MAAM,UAAU,QAAQ,CACtB,OAAgB,EAChB,GAA6C,EAC7C,GAAkC,EAClC,OAAsB;IAEtB,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,YAAY,CAC1B;YACE,GAAG,EAAE,OAAO,CAAC,GAAG;YAChB,SAAS,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;YAC5C,EAAE,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC;SAC5C,EACD,EAAE,GAAG,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,IAAI,GAAG,EAAE,gBAAgB,EAAE,CAC3D,CAAC;QACF,GAAG,EAAE,SAAS,EAAE,CAAC,OAAO,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,0BAA0B;IAC5B,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type TrackOptions } from "./index.js";
|
|
2
|
+
/**
|
|
3
|
+
* `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
|
|
4
|
+
*
|
|
5
|
+
* The IP comes from `req.ip`, so `trust proxy` decides it. With nothing in front of Node, don't
|
|
6
|
+
* set it. Behind proxies you control, set the exact number of hops. Never set it to `true`, or
|
|
7
|
+
* to more hops than there are: `req.ip` then comes from a header the client can write.
|
|
8
|
+
*/
|
|
9
|
+
export declare function statlarkBots(options?: TrackOptions): (req: any, res: any, next: () => void) => void;
|
|
10
|
+
//# sourceMappingURL=express.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../src/express.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAE7D;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,OAAO,CAAC,EAAE,YAAY,GACrB,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI,CAiBhD"}
|
package/dist/express.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { reportBotHit } from "./index.js";
|
|
2
|
+
/**
|
|
3
|
+
* `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
|
|
4
|
+
*
|
|
5
|
+
* The IP comes from `req.ip`, so `trust proxy` decides it. With nothing in front of Node, don't
|
|
6
|
+
* set it. Behind proxies you control, set the exact number of hops. Never set it to `true`, or
|
|
7
|
+
* to more hops than there are: `req.ip` then comes from a header the client can write.
|
|
8
|
+
*/
|
|
9
|
+
export function statlarkBots(options) {
|
|
10
|
+
return (req, _res, next) => {
|
|
11
|
+
try {
|
|
12
|
+
void reportBotHit({
|
|
13
|
+
url: `${req.protocol}://${req.get("host")}${req.originalUrl}`,
|
|
14
|
+
userAgent: req.get("user-agent"),
|
|
15
|
+
ip: req.ip,
|
|
16
|
+
}, options);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Never fail the request.
|
|
20
|
+
}
|
|
21
|
+
// Outside the try: an error from the next handler is the app's, not ours to swallow.
|
|
22
|
+
next();
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=express.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.js","sourceRoot":"","sources":["../src/express.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAqB,MAAM,YAAY,CAAC;AAE7D;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAC1B,OAAsB;IAEtB,OAAO,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE;QACzB,IAAI,CAAC;YACH,KAAK,YAAY,CACf;gBACE,GAAG,EAAE,GAAG,GAAG,CAAC,QAAQ,MAAM,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,WAAW,EAAE;gBAC7D,SAAS,EAAE,GAAG,CAAC,GAAG,CAAC,YAAY,CAAC;gBAChC,EAAE,EAAE,GAAG,CAAC,EAAE;aACX,EACD,OAAO,CACR,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,0BAA0B;QAC5B,CAAC;QACD,qFAAqF;QACrF,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statlark/bots: tells Statlark when a crawler requests a page.
|
|
3
|
+
*
|
|
4
|
+
* Most AI crawlers never run JavaScript, so this has to happen on the server. It's a thin
|
|
5
|
+
* reporter: this file makes a cheap first guess at what's a crawler, and Statlark's server
|
|
6
|
+
* decides what really is one (docs/21 D12).
|
|
7
|
+
*
|
|
8
|
+
* THE RULE: this runs in the middle of someone else's site. It never throws, never rejects,
|
|
9
|
+
* never retries, and gives up after 2 seconds. A failure here must never reach a request.
|
|
10
|
+
*/
|
|
11
|
+
export type BotHit = {
|
|
12
|
+
/** An absolute URL or a path. The query string and fragment are never sent. */
|
|
13
|
+
url: string;
|
|
14
|
+
userAgent: string | null | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The crawler's address, if you have a reliable one. Sent for crawlers with a published list,
|
|
17
|
+
* to check them. For other crawlers only a hash of it, keyed with your bot key, is sent, for a
|
|
18
|
+
* rate limit. Neither is written to Statlark's database.
|
|
19
|
+
*/
|
|
20
|
+
ip?: string | null;
|
|
21
|
+
};
|
|
22
|
+
export type TrackOptions = {
|
|
23
|
+
/** Defaults to the STATLARK_BOT_KEY environment variable. */
|
|
24
|
+
key?: string;
|
|
25
|
+
endpoint?: string;
|
|
26
|
+
onError?: (e: unknown) => void;
|
|
27
|
+
};
|
|
28
|
+
/** Reports a crawler's request. Never throws, never rejects, and always returns a promise,
|
|
29
|
+
* so it's safe to hand straight to any waitUntil. */
|
|
30
|
+
export declare function reportBotHit(hit: BotHit, options?: TrackOptions): Promise<void>;
|
|
31
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AA4CH,MAAM,MAAM,MAAM,GAAG;IACnB,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACrC;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,6DAA6D;IAC7D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CAChC,CAAC;AAoEF;sDACsD;AACtD,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6FzF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statlark/bots: tells Statlark when a crawler requests a page.
|
|
3
|
+
*
|
|
4
|
+
* Most AI crawlers never run JavaScript, so this has to happen on the server. It's a thin
|
|
5
|
+
* reporter: this file makes a cheap first guess at what's a crawler, and Statlark's server
|
|
6
|
+
* decides what really is one (docs/21 D12).
|
|
7
|
+
*
|
|
8
|
+
* THE RULE: this runs in the middle of someone else's site. It never throws, never rejects,
|
|
9
|
+
* never retries, and gives up after 2 seconds. A failure here must never reach a request.
|
|
10
|
+
*/
|
|
11
|
+
const ENDPOINT = "https://collect.statlark.com/bots";
|
|
12
|
+
const TIMEOUT_MS = 2000;
|
|
13
|
+
const AUTH_BACKOFF_MS = 600000;
|
|
14
|
+
const MAX_STOP_MS = 3600000;
|
|
15
|
+
const FAILURE_BACKOFF_MS = 60000;
|
|
16
|
+
const MAX_FAILURES = 3;
|
|
17
|
+
const MAX_IN_FLIGHT = 20;
|
|
18
|
+
// The server refuses a longer user agent, so don't send one.
|
|
19
|
+
const MAX_USER_AGENT = 512;
|
|
20
|
+
// After the server says a user agent isn't a crawler it knows, skip it for an hour. The hour
|
|
21
|
+
// keeps a crawler newly added to the server's list from waiting on an upgrade (docs/21 D12).
|
|
22
|
+
const UNKNOWN_TTL_MS = 3600000;
|
|
23
|
+
const MAX_UNKNOWN = 500;
|
|
24
|
+
// Cheap first filter. Statlark's server decides what's really a crawler (docs/21 D12), so
|
|
25
|
+
// this only has to skip ordinary browsers without a network call. Keep it broad: a crawler
|
|
26
|
+
// this misses never reaches the server, so adding it to the list wouldn't be enough.
|
|
27
|
+
const CANDIDATE = /bot\b|bot\/|crawl|spider|slurp|-user\b|-index\b|-training\b|-agent\b|amzn-|google-|googleother|meta-|bytespider|cohere|facebookexternalhit/i;
|
|
28
|
+
const SKIP = /\.(?:js|mjs|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|wasm)$/i;
|
|
29
|
+
// The server drops these paths, so don't spend a request on them.
|
|
30
|
+
const FRAMEWORK = /^\/(?:_next|_vercel|api|\.well-known)(?:\/|$)/;
|
|
31
|
+
// A key is printable ASCII. Anything else, such as a pasted line break, can't go in a header.
|
|
32
|
+
const BAD_KEY = /[^\x21-\x7e]/;
|
|
33
|
+
// Crawlers with a published list: the only ones whose IP is worth sending (D19).
|
|
34
|
+
const HAS_LIST = /chatgpt-user|oai-searchbot|oai-adsbot|gptbot|claude|perplexity|google-agent|google-cloudvertexbot|googleother|googlebot|applebot|duckduckbot|duckassistbot|mistralai-(?:user|index)|ccbot|ahrefsbot/i;
|
|
35
|
+
// Stands in for the origin of a bare path, so `new URL` can parse one.
|
|
36
|
+
const NO_ORIGIN = "https://statlark.invalid";
|
|
37
|
+
// Per key, so one bad key on a multi-tenant host can't stop another tenant's.
|
|
38
|
+
const state = new Map();
|
|
39
|
+
// User agents the server answered unknown_crawler for, with when to try again.
|
|
40
|
+
const unknown = new Map();
|
|
41
|
+
let inFlight = 0;
|
|
42
|
+
let warnedNoKey = false;
|
|
43
|
+
function stateFor(key) {
|
|
44
|
+
let entry = state.get(key);
|
|
45
|
+
if (!entry) {
|
|
46
|
+
entry = { stoppedUntil: 0, failures: 0, warned: false };
|
|
47
|
+
state.set(key, entry);
|
|
48
|
+
}
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
/** The URL to send and its pathname, or null if it isn't something we'd report. */
|
|
52
|
+
function pageUrl(url) {
|
|
53
|
+
const parsed = new URL(url, NO_ORIGIN);
|
|
54
|
+
if (parsed.origin === "null")
|
|
55
|
+
return null; // mailto:, javascript: and the like
|
|
56
|
+
const send = parsed.origin === NO_ORIGIN ? parsed.pathname : parsed.origin + parsed.pathname;
|
|
57
|
+
return { send, pathname: parsed.pathname };
|
|
58
|
+
}
|
|
59
|
+
async function sha256Hex(text) {
|
|
60
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
61
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What a rate limit should treat as one source: an IPv4 address, or an IPv6 /64, since one host
|
|
65
|
+
* can hold a whole /64. A mapped IPv4 address stays whole, so dual-stack servers don't put every
|
|
66
|
+
* IPv4 client in one bucket.
|
|
67
|
+
*/
|
|
68
|
+
function sourceAddress(ip) {
|
|
69
|
+
const address = ip.split("%")[0].toLowerCase();
|
|
70
|
+
if (!address.includes(":") || address.includes("."))
|
|
71
|
+
return address;
|
|
72
|
+
const [head, tail] = address.split("::");
|
|
73
|
+
if (address.split("::").length > 2)
|
|
74
|
+
return address;
|
|
75
|
+
const front = head ? head.split(":") : [];
|
|
76
|
+
const back = tail === undefined ? [] : tail ? tail.split(":") : [];
|
|
77
|
+
const groups = tail === undefined ? front : [...front, ...Array(8 - front.length - back.length).fill("0"), ...back];
|
|
78
|
+
if (groups.length !== 8 || !groups.every((g) => /^[0-9a-f]{1,4}$/.test(g)))
|
|
79
|
+
return address;
|
|
80
|
+
return `${groups.slice(0, 4).map((g) => g.replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
81
|
+
}
|
|
82
|
+
/** Statlark's own errors are JSON with a string `error`. A 401, 402 or 403 without that came
|
|
83
|
+
* from something on the way, so it says nothing about the key or the plan. */
|
|
84
|
+
async function isStatlarkError(res) {
|
|
85
|
+
if (!res.headers.get("content-type")?.includes("application/json"))
|
|
86
|
+
return false;
|
|
87
|
+
try {
|
|
88
|
+
const reply = (await res.json());
|
|
89
|
+
return typeof reply?.error === "string";
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function warnOnce(entry, message) {
|
|
96
|
+
if (entry.warned)
|
|
97
|
+
return;
|
|
98
|
+
entry.warned = true;
|
|
99
|
+
console.warn(message);
|
|
100
|
+
}
|
|
101
|
+
function countFailure(entry) {
|
|
102
|
+
entry.failures += 1;
|
|
103
|
+
if (entry.failures >= MAX_FAILURES) {
|
|
104
|
+
entry.failures = 0;
|
|
105
|
+
entry.stoppedUntil = Date.now() + FAILURE_BACKOFF_MS;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Reports a crawler's request. Never throws, never rejects, and always returns a promise,
|
|
109
|
+
* so it's safe to hand straight to any waitUntil. */
|
|
110
|
+
export async function reportBotHit(hit, options = {}) {
|
|
111
|
+
let entry;
|
|
112
|
+
let counted = false;
|
|
113
|
+
try {
|
|
114
|
+
if (typeof window !== "undefined")
|
|
115
|
+
return; // never run in a browser
|
|
116
|
+
const userAgent = hit.userAgent;
|
|
117
|
+
if (!userAgent || userAgent.length > MAX_USER_AGENT || !CANDIDATE.test(userAgent))
|
|
118
|
+
return;
|
|
119
|
+
const retryAt = unknown.get(userAgent);
|
|
120
|
+
if (retryAt !== undefined) {
|
|
121
|
+
if (Date.now() < retryAt)
|
|
122
|
+
return;
|
|
123
|
+
unknown.delete(userAgent);
|
|
124
|
+
}
|
|
125
|
+
const page = pageUrl(hit.url);
|
|
126
|
+
if (!page || SKIP.test(page.pathname) || FRAMEWORK.test(page.pathname))
|
|
127
|
+
return;
|
|
128
|
+
// Trimmed, so a key pasted with a trailing line break still works.
|
|
129
|
+
const key = (options.key ?? (typeof process !== "undefined" ? process.env.STATLARK_BOT_KEY : undefined))?.trim();
|
|
130
|
+
if (!key) {
|
|
131
|
+
if (!warnedNoKey) {
|
|
132
|
+
warnedNoKey = true;
|
|
133
|
+
console.warn("[statlark] STATLARK_BOT_KEY isn't set, so bot tracking is off.");
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
entry = stateFor(key);
|
|
138
|
+
if (BAD_KEY.test(key)) {
|
|
139
|
+
warnOnce(entry, "[statlark] Bot tracking is off: the bot key contains a character a key can't have, such as a line break. Copy it again.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (Date.now() < entry.stoppedUntil || inFlight >= MAX_IN_FLIGHT)
|
|
143
|
+
return;
|
|
144
|
+
inFlight += 1;
|
|
145
|
+
counted = true;
|
|
146
|
+
const body = { url: page.send, user_agent: userAgent };
|
|
147
|
+
if (hit.ip) {
|
|
148
|
+
if (HAS_LIST.test(userAgent))
|
|
149
|
+
body.ip = hit.ip;
|
|
150
|
+
else
|
|
151
|
+
body.source = await sha256Hex(`${key}:${sourceAddress(hit.ip)}`);
|
|
152
|
+
}
|
|
153
|
+
const res = await fetch(options.endpoint ?? ENDPOINT, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
156
|
+
body: JSON.stringify(body),
|
|
157
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
158
|
+
});
|
|
159
|
+
if (res.status === 401 || res.status === 402 || res.status === 403) {
|
|
160
|
+
if (await isStatlarkError(res)) {
|
|
161
|
+
entry.stoppedUntil = Date.now() + AUTH_BACKOFF_MS;
|
|
162
|
+
warnOnce(entry, `[statlark] Bot tracking stopped: the server answered ${res.status}. Check your bot key and plan.`);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// A layer on the way, such as a firewall, answered. Not a verdict on the key.
|
|
166
|
+
countFailure(entry);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (res.status === 429) {
|
|
170
|
+
const seconds = Number(res.headers.get("retry-after"));
|
|
171
|
+
const wait = seconds > 0 ? seconds * 1000 : FAILURE_BACKOFF_MS;
|
|
172
|
+
entry.stoppedUntil = Date.now() + Math.min(wait, MAX_STOP_MS);
|
|
173
|
+
}
|
|
174
|
+
else if (res.status >= 500) {
|
|
175
|
+
countFailure(entry);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
entry.failures = 0;
|
|
179
|
+
}
|
|
180
|
+
if (res.status === 200) {
|
|
181
|
+
const reply = (await res.json());
|
|
182
|
+
if (reply.reason === "unknown_crawler") {
|
|
183
|
+
if (unknown.size >= MAX_UNKNOWN)
|
|
184
|
+
unknown.delete(unknown.keys().next().value);
|
|
185
|
+
unknown.set(userAgent, Date.now() + UNKNOWN_TTL_MS);
|
|
186
|
+
}
|
|
187
|
+
if (reply.paused_until) {
|
|
188
|
+
// Capped, so an upgrade takes effect within the hour.
|
|
189
|
+
const until = Date.parse(reply.paused_until);
|
|
190
|
+
const cap = Date.now() + MAX_STOP_MS;
|
|
191
|
+
entry.stoppedUntil = Number.isNaN(until) ? cap : Math.min(until, cap);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (!res.bodyUsed) {
|
|
195
|
+
await res.body?.cancel();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
if (entry)
|
|
200
|
+
countFailure(entry);
|
|
201
|
+
try {
|
|
202
|
+
options.onError?.(e);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// A throwing onError stays here too.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
if (counted)
|
|
210
|
+
inFlight -= 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/next.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type TrackOptions } from "./index.js";
|
|
2
|
+
type Req = {
|
|
3
|
+
url: string;
|
|
4
|
+
headers: Headers;
|
|
5
|
+
};
|
|
6
|
+
type Evt = {
|
|
7
|
+
waitUntil(p: Promise<unknown>): void;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Call this first in `proxy.ts`, before any login check. Before Next 16 the file is
|
|
11
|
+
* `middleware.ts` and the function is `middleware`.
|
|
12
|
+
*
|
|
13
|
+
* Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
|
|
14
|
+
* leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
|
|
15
|
+
* anything else, pass `options.ip` yourself.
|
|
16
|
+
*/
|
|
17
|
+
export declare function trackBot(request: Req, event: Evt, options?: TrackOptions & {
|
|
18
|
+
ip?: string | null;
|
|
19
|
+
}): void;
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=next.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next.d.ts","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAE7D,KAAK,GAAG,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;AAC7C,KAAK,GAAG,GAAG;IAAE,SAAS,CAAC,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;CAAE,CAAC;AAEpD;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CACtB,OAAO,EAAE,GAAG,EACZ,KAAK,EAAE,GAAG,EACV,OAAO,CAAC,EAAE,YAAY,GAAG;IAAE,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAC9C,IAAI,CAcN"}
|
package/dist/next.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { reportBotHit } from "./index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Call this first in `proxy.ts`, before any login check. Before Next 16 the file is
|
|
4
|
+
* `middleware.ts` and the function is `middleware`.
|
|
5
|
+
*
|
|
6
|
+
* Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
|
|
7
|
+
* leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
|
|
8
|
+
* anything else, pass `options.ip` yourself.
|
|
9
|
+
*/
|
|
10
|
+
export function trackBot(request, event, options) {
|
|
11
|
+
// The whole body is guarded, and so is a missing or wrapped `event`. Only crawlers reach the
|
|
12
|
+
// work below, so a manual test wouldn't show a failure here.
|
|
13
|
+
try {
|
|
14
|
+
const onVercel = typeof process !== "undefined" && Boolean(process.env.VERCEL);
|
|
15
|
+
const ip = options?.ip ?? (onVercel ? request.headers.get("x-real-ip") : null);
|
|
16
|
+
const promise = reportBotHit({ url: request.url, userAgent: request.headers.get("user-agent"), ip }, options);
|
|
17
|
+
event?.waitUntil?.(promise);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Never fail the request.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=next.js.map
|
package/dist/next.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next.js","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAqB,MAAM,YAAY,CAAC;AAK7D;;;;;;;GAOG;AACH,MAAM,UAAU,QAAQ,CACtB,OAAY,EACZ,KAAU,EACV,OAA+C;IAE/C,6FAA6F;IAC7F,6DAA6D;IAC7D,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,OAAO,OAAO,KAAK,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC/E,MAAM,EAAE,GAAG,OAAO,EAAE,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QAC/E,MAAM,OAAO,GAAG,YAAY,CAC1B,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,EAAE,EAAE,EACtE,OAAO,CACR,CAAC;QACF,KAAK,EAAE,SAAS,EAAE,CAAC,OAAO,CAAC,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,0BAA0B;IAC5B,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statlark/bots",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Statlark bot tracking: reports which AI assistants, search engines and training crawlers request your pages, from your server. Adapters for Next.js, Cloudflare and Express. No runtime dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"typesVersions": {
|
|
10
|
+
"*": {
|
|
11
|
+
"next": ["dist/next.d.ts"],
|
|
12
|
+
"cloudflare": ["dist/cloudflare.d.ts"],
|
|
13
|
+
"express": ["dist/express.d.ts"]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./next": {
|
|
22
|
+
"types": "./dist/next.d.ts",
|
|
23
|
+
"default": "./dist/next.js"
|
|
24
|
+
},
|
|
25
|
+
"./cloudflare": {
|
|
26
|
+
"types": "./dist/cloudflare.d.ts",
|
|
27
|
+
"default": "./dist/cloudflare.js"
|
|
28
|
+
},
|
|
29
|
+
"./express": {
|
|
30
|
+
"types": "./dist/express.d.ts",
|
|
31
|
+
"default": "./dist/express.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": ["dist", "src", "README.md"],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"prepublishOnly": "tsc"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"statlark",
|
|
50
|
+
"analytics",
|
|
51
|
+
"bots",
|
|
52
|
+
"crawlers",
|
|
53
|
+
"ai-crawlers",
|
|
54
|
+
"gptbot",
|
|
55
|
+
"nextjs",
|
|
56
|
+
"cloudflare-workers",
|
|
57
|
+
"express"
|
|
58
|
+
],
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"typescript": "^5.5.4"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { reportBotHit, type TrackOptions } from "./index.js";
|
|
2
|
+
|
|
3
|
+
/** Call this at the top of your Worker's `fetch`, or in a Pages Function. */
|
|
4
|
+
export function trackBot(
|
|
5
|
+
request: Request,
|
|
6
|
+
ctx: { waitUntil(p: Promise<unknown>): void },
|
|
7
|
+
env: { STATLARK_BOT_KEY?: string },
|
|
8
|
+
options?: TrackOptions,
|
|
9
|
+
): void {
|
|
10
|
+
try {
|
|
11
|
+
const promise = reportBotHit(
|
|
12
|
+
{
|
|
13
|
+
url: request.url,
|
|
14
|
+
userAgent: request.headers.get("user-agent"),
|
|
15
|
+
ip: request.headers.get("cf-connecting-ip"),
|
|
16
|
+
},
|
|
17
|
+
{ ...options, key: options?.key ?? env?.STATLARK_BOT_KEY },
|
|
18
|
+
);
|
|
19
|
+
ctx?.waitUntil?.(promise);
|
|
20
|
+
} catch {
|
|
21
|
+
// Never fail the request.
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The package has `"types": []` and no dependency on @types/node, and it also runs on Workers,
|
|
2
|
+
// where `process` may not exist. So `process` is declared possibly undefined: every read has to
|
|
3
|
+
// sit behind a `typeof process !== "undefined"` guard, and the compiler holds that line.
|
|
4
|
+
declare const process: { env: Record<string, string | undefined> } | undefined;
|
package/src/express.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { reportBotHit, type TrackOptions } from "./index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
|
|
5
|
+
*
|
|
6
|
+
* The IP comes from `req.ip`, so `trust proxy` decides it. With nothing in front of Node, don't
|
|
7
|
+
* set it. Behind proxies you control, set the exact number of hops. Never set it to `true`, or
|
|
8
|
+
* to more hops than there are: `req.ip` then comes from a header the client can write.
|
|
9
|
+
*/
|
|
10
|
+
export function statlarkBots(
|
|
11
|
+
options?: TrackOptions,
|
|
12
|
+
): (req: any, res: any, next: () => void) => void {
|
|
13
|
+
return (req, _res, next) => {
|
|
14
|
+
try {
|
|
15
|
+
void reportBotHit(
|
|
16
|
+
{
|
|
17
|
+
url: `${req.protocol}://${req.get("host")}${req.originalUrl}`,
|
|
18
|
+
userAgent: req.get("user-agent"),
|
|
19
|
+
ip: req.ip,
|
|
20
|
+
},
|
|
21
|
+
options,
|
|
22
|
+
);
|
|
23
|
+
} catch {
|
|
24
|
+
// Never fail the request.
|
|
25
|
+
}
|
|
26
|
+
// Outside the try: an error from the next handler is the app's, not ours to swallow.
|
|
27
|
+
next();
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @statlark/bots: tells Statlark when a crawler requests a page.
|
|
3
|
+
*
|
|
4
|
+
* Most AI crawlers never run JavaScript, so this has to happen on the server. It's a thin
|
|
5
|
+
* reporter: this file makes a cheap first guess at what's a crawler, and Statlark's server
|
|
6
|
+
* decides what really is one (docs/21 D12).
|
|
7
|
+
*
|
|
8
|
+
* THE RULE: this runs in the middle of someone else's site. It never throws, never rejects,
|
|
9
|
+
* never retries, and gives up after 2 seconds. A failure here must never reach a request.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ENDPOINT = "https://collect.statlark.com/bots";
|
|
13
|
+
const TIMEOUT_MS = 2_000;
|
|
14
|
+
const AUTH_BACKOFF_MS = 600_000;
|
|
15
|
+
const MAX_STOP_MS = 3_600_000;
|
|
16
|
+
const FAILURE_BACKOFF_MS = 60_000;
|
|
17
|
+
const MAX_FAILURES = 3;
|
|
18
|
+
const MAX_IN_FLIGHT = 20;
|
|
19
|
+
// The server refuses a longer user agent, so don't send one.
|
|
20
|
+
const MAX_USER_AGENT = 512;
|
|
21
|
+
// After the server says a user agent isn't a crawler it knows, skip it for an hour. The hour
|
|
22
|
+
// keeps a crawler newly added to the server's list from waiting on an upgrade (docs/21 D12).
|
|
23
|
+
const UNKNOWN_TTL_MS = 3_600_000;
|
|
24
|
+
const MAX_UNKNOWN = 500;
|
|
25
|
+
|
|
26
|
+
// Cheap first filter. Statlark's server decides what's really a crawler (docs/21 D12), so
|
|
27
|
+
// this only has to skip ordinary browsers without a network call. Keep it broad: a crawler
|
|
28
|
+
// this misses never reaches the server, so adding it to the list wouldn't be enough.
|
|
29
|
+
const CANDIDATE =
|
|
30
|
+
/bot\b|bot\/|crawl|spider|slurp|-user\b|-index\b|-training\b|-agent\b|amzn-|google-|googleother|meta-|bytespider|cohere|facebookexternalhit/i;
|
|
31
|
+
const SKIP =
|
|
32
|
+
/\.(?:js|mjs|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|wasm)$/i;
|
|
33
|
+
// The server drops these paths, so don't spend a request on them.
|
|
34
|
+
const FRAMEWORK = /^\/(?:_next|_vercel|api|\.well-known)(?:\/|$)/;
|
|
35
|
+
// A key is printable ASCII. Anything else, such as a pasted line break, can't go in a header.
|
|
36
|
+
const BAD_KEY = /[^\x21-\x7e]/;
|
|
37
|
+
|
|
38
|
+
// Crawlers with a published list: the only ones whose IP is worth sending (D19).
|
|
39
|
+
const HAS_LIST =
|
|
40
|
+
/chatgpt-user|oai-searchbot|oai-adsbot|gptbot|claude|perplexity|google-agent|google-cloudvertexbot|googleother|googlebot|applebot|duckduckbot|duckassistbot|mistralai-(?:user|index)|ccbot|ahrefsbot/i;
|
|
41
|
+
|
|
42
|
+
// Stands in for the origin of a bare path, so `new URL` can parse one.
|
|
43
|
+
const NO_ORIGIN = "https://statlark.invalid";
|
|
44
|
+
|
|
45
|
+
type KeyState = { stoppedUntil: number; failures: number; warned: boolean };
|
|
46
|
+
|
|
47
|
+
// Per key, so one bad key on a multi-tenant host can't stop another tenant's.
|
|
48
|
+
const state = new Map<string, KeyState>();
|
|
49
|
+
// User agents the server answered unknown_crawler for, with when to try again.
|
|
50
|
+
const unknown = new Map<string, number>();
|
|
51
|
+
let inFlight = 0;
|
|
52
|
+
let warnedNoKey = false;
|
|
53
|
+
|
|
54
|
+
export type BotHit = {
|
|
55
|
+
/** An absolute URL or a path. The query string and fragment are never sent. */
|
|
56
|
+
url: string;
|
|
57
|
+
userAgent: string | null | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The crawler's address, if you have a reliable one. Sent for crawlers with a published list,
|
|
60
|
+
* to check them. For other crawlers only a hash of it, keyed with your bot key, is sent, for a
|
|
61
|
+
* rate limit. Neither is written to Statlark's database.
|
|
62
|
+
*/
|
|
63
|
+
ip?: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type TrackOptions = {
|
|
67
|
+
/** Defaults to the STATLARK_BOT_KEY environment variable. */
|
|
68
|
+
key?: string;
|
|
69
|
+
endpoint?: string;
|
|
70
|
+
onError?: (e: unknown) => void;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function stateFor(key: string): KeyState {
|
|
74
|
+
let entry = state.get(key);
|
|
75
|
+
if (!entry) {
|
|
76
|
+
entry = { stoppedUntil: 0, failures: 0, warned: false };
|
|
77
|
+
state.set(key, entry);
|
|
78
|
+
}
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The URL to send and its pathname, or null if it isn't something we'd report. */
|
|
83
|
+
function pageUrl(url: string): { send: string; pathname: string } | null {
|
|
84
|
+
const parsed = new URL(url, NO_ORIGIN);
|
|
85
|
+
if (parsed.origin === "null") return null; // mailto:, javascript: and the like
|
|
86
|
+
const send = parsed.origin === NO_ORIGIN ? parsed.pathname : parsed.origin + parsed.pathname;
|
|
87
|
+
return { send, pathname: parsed.pathname };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function sha256Hex(text: string): Promise<string> {
|
|
91
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
92
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What a rate limit should treat as one source: an IPv4 address, or an IPv6 /64, since one host
|
|
97
|
+
* can hold a whole /64. A mapped IPv4 address stays whole, so dual-stack servers don't put every
|
|
98
|
+
* IPv4 client in one bucket.
|
|
99
|
+
*/
|
|
100
|
+
function sourceAddress(ip: string): string {
|
|
101
|
+
const address = ip.split("%")[0].toLowerCase();
|
|
102
|
+
if (!address.includes(":") || address.includes(".")) return address;
|
|
103
|
+
const [head, tail] = address.split("::");
|
|
104
|
+
if (address.split("::").length > 2) return address;
|
|
105
|
+
const front = head ? head.split(":") : [];
|
|
106
|
+
const back = tail === undefined ? [] : tail ? tail.split(":") : [];
|
|
107
|
+
const groups =
|
|
108
|
+
tail === undefined ? front : [...front, ...Array(8 - front.length - back.length).fill("0"), ...back];
|
|
109
|
+
if (groups.length !== 8 || !groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return address;
|
|
110
|
+
return `${groups.slice(0, 4).map((g) => g.replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Statlark's own errors are JSON with a string `error`. A 401, 402 or 403 without that came
|
|
114
|
+
* from something on the way, so it says nothing about the key or the plan. */
|
|
115
|
+
async function isStatlarkError(res: Response): Promise<boolean> {
|
|
116
|
+
if (!res.headers.get("content-type")?.includes("application/json")) return false;
|
|
117
|
+
try {
|
|
118
|
+
const reply = (await res.json()) as { error?: unknown } | null;
|
|
119
|
+
return typeof reply?.error === "string";
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function warnOnce(entry: KeyState, message: string): void {
|
|
126
|
+
if (entry.warned) return;
|
|
127
|
+
entry.warned = true;
|
|
128
|
+
console.warn(message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function countFailure(entry: KeyState): void {
|
|
132
|
+
entry.failures += 1;
|
|
133
|
+
if (entry.failures >= MAX_FAILURES) {
|
|
134
|
+
entry.failures = 0;
|
|
135
|
+
entry.stoppedUntil = Date.now() + FAILURE_BACKOFF_MS;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Reports a crawler's request. Never throws, never rejects, and always returns a promise,
|
|
140
|
+
* so it's safe to hand straight to any waitUntil. */
|
|
141
|
+
export async function reportBotHit(hit: BotHit, options: TrackOptions = {}): Promise<void> {
|
|
142
|
+
let entry: KeyState | undefined;
|
|
143
|
+
let counted = false;
|
|
144
|
+
try {
|
|
145
|
+
if (typeof window !== "undefined") return; // never run in a browser
|
|
146
|
+
const userAgent = hit.userAgent;
|
|
147
|
+
if (!userAgent || userAgent.length > MAX_USER_AGENT || !CANDIDATE.test(userAgent)) return;
|
|
148
|
+
const retryAt = unknown.get(userAgent);
|
|
149
|
+
if (retryAt !== undefined) {
|
|
150
|
+
if (Date.now() < retryAt) return;
|
|
151
|
+
unknown.delete(userAgent);
|
|
152
|
+
}
|
|
153
|
+
const page = pageUrl(hit.url);
|
|
154
|
+
if (!page || SKIP.test(page.pathname) || FRAMEWORK.test(page.pathname)) return;
|
|
155
|
+
|
|
156
|
+
// Trimmed, so a key pasted with a trailing line break still works.
|
|
157
|
+
const key = (
|
|
158
|
+
options.key ?? (typeof process !== "undefined" ? process.env.STATLARK_BOT_KEY : undefined)
|
|
159
|
+
)?.trim();
|
|
160
|
+
if (!key) {
|
|
161
|
+
if (!warnedNoKey) {
|
|
162
|
+
warnedNoKey = true;
|
|
163
|
+
console.warn("[statlark] STATLARK_BOT_KEY isn't set, so bot tracking is off.");
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
entry = stateFor(key);
|
|
169
|
+
if (BAD_KEY.test(key)) {
|
|
170
|
+
warnOnce(entry, "[statlark] Bot tracking is off: the bot key contains a character a key can't have, such as a line break. Copy it again.");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (Date.now() < entry.stoppedUntil || inFlight >= MAX_IN_FLIGHT) return;
|
|
174
|
+
|
|
175
|
+
inFlight += 1;
|
|
176
|
+
counted = true;
|
|
177
|
+
|
|
178
|
+
const body: Record<string, string> = { url: page.send, user_agent: userAgent };
|
|
179
|
+
if (hit.ip) {
|
|
180
|
+
if (HAS_LIST.test(userAgent)) body.ip = hit.ip;
|
|
181
|
+
else body.source = await sha256Hex(`${key}:${sourceAddress(hit.ip)}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const res = await fetch(options.endpoint ?? ENDPOINT, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
187
|
+
body: JSON.stringify(body),
|
|
188
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (res.status === 401 || res.status === 402 || res.status === 403) {
|
|
192
|
+
if (await isStatlarkError(res)) {
|
|
193
|
+
entry.stoppedUntil = Date.now() + AUTH_BACKOFF_MS;
|
|
194
|
+
warnOnce(entry, `[statlark] Bot tracking stopped: the server answered ${res.status}. Check your bot key and plan.`);
|
|
195
|
+
} else {
|
|
196
|
+
// A layer on the way, such as a firewall, answered. Not a verdict on the key.
|
|
197
|
+
countFailure(entry);
|
|
198
|
+
}
|
|
199
|
+
} else if (res.status === 429) {
|
|
200
|
+
const seconds = Number(res.headers.get("retry-after"));
|
|
201
|
+
const wait = seconds > 0 ? seconds * 1000 : FAILURE_BACKOFF_MS;
|
|
202
|
+
entry.stoppedUntil = Date.now() + Math.min(wait, MAX_STOP_MS);
|
|
203
|
+
} else if (res.status >= 500) {
|
|
204
|
+
countFailure(entry);
|
|
205
|
+
} else {
|
|
206
|
+
entry.failures = 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (res.status === 200) {
|
|
210
|
+
const reply = (await res.json()) as { paused_until?: string; reason?: string };
|
|
211
|
+
if (reply.reason === "unknown_crawler") {
|
|
212
|
+
if (unknown.size >= MAX_UNKNOWN) unknown.delete(unknown.keys().next().value as string);
|
|
213
|
+
unknown.set(userAgent, Date.now() + UNKNOWN_TTL_MS);
|
|
214
|
+
}
|
|
215
|
+
if (reply.paused_until) {
|
|
216
|
+
// Capped, so an upgrade takes effect within the hour.
|
|
217
|
+
const until = Date.parse(reply.paused_until);
|
|
218
|
+
const cap = Date.now() + MAX_STOP_MS;
|
|
219
|
+
entry.stoppedUntil = Number.isNaN(until) ? cap : Math.min(until, cap);
|
|
220
|
+
}
|
|
221
|
+
} else if (!res.bodyUsed) {
|
|
222
|
+
await res.body?.cancel();
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {
|
|
225
|
+
if (entry) countFailure(entry);
|
|
226
|
+
try {
|
|
227
|
+
options.onError?.(e);
|
|
228
|
+
} catch {
|
|
229
|
+
// A throwing onError stays here too.
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
if (counted) inFlight -= 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/next.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { reportBotHit, type TrackOptions } from "./index.js";
|
|
2
|
+
|
|
3
|
+
type Req = { url: string; headers: Headers };
|
|
4
|
+
type Evt = { waitUntil(p: Promise<unknown>): void };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Call this first in `proxy.ts`, before any login check. Before Next 16 the file is
|
|
8
|
+
* `middleware.ts` and the function is `middleware`.
|
|
9
|
+
*
|
|
10
|
+
* Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
|
|
11
|
+
* leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
|
|
12
|
+
* anything else, pass `options.ip` yourself.
|
|
13
|
+
*/
|
|
14
|
+
export function trackBot(
|
|
15
|
+
request: Req,
|
|
16
|
+
event: Evt,
|
|
17
|
+
options?: TrackOptions & { ip?: string | null },
|
|
18
|
+
): void {
|
|
19
|
+
// The whole body is guarded, and so is a missing or wrapped `event`. Only crawlers reach the
|
|
20
|
+
// work below, so a manual test wouldn't show a failure here.
|
|
21
|
+
try {
|
|
22
|
+
const onVercel = typeof process !== "undefined" && Boolean(process.env.VERCEL);
|
|
23
|
+
const ip = options?.ip ?? (onVercel ? request.headers.get("x-real-ip") : null);
|
|
24
|
+
const promise = reportBotHit(
|
|
25
|
+
{ url: request.url, userAgent: request.headers.get("user-agent"), ip },
|
|
26
|
+
options,
|
|
27
|
+
);
|
|
28
|
+
event?.waitUntil?.(promise);
|
|
29
|
+
} catch {
|
|
30
|
+
// Never fail the request.
|
|
31
|
+
}
|
|
32
|
+
}
|