@statlark/bots 0.1.0 → 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 +44 -12
- package/dist/express.d.ts +3 -2
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +3 -2
- package/dist/express.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +73 -7
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +2 -1
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +2 -1
- package/dist/next.js.map +1 -1
- package/package.json +9 -2
- package/src/express.ts +3 -2
- package/src/index.ts +76 -10
- package/src/next.ts +2 -1
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
See which AI assistants, search engines and training crawlers read your site.
|
|
4
4
|
|
|
5
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.
|
|
7
|
-
|
|
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
8
|
|
|
9
9
|
Full guide: <https://statlark.com/docs/bot-traffic>
|
|
10
10
|
|
|
@@ -20,7 +20,8 @@ put it in browser code. With no key, the package warns once and does nothing.
|
|
|
20
20
|
|
|
21
21
|
## Next.js
|
|
22
22
|
|
|
23
|
-
In `proxy.ts
|
|
23
|
+
In `proxy.ts`, first, before any login check. Before Next 16 the file is `middleware.ts` and
|
|
24
|
+
the function is `middleware`:
|
|
24
25
|
|
|
25
26
|
```ts
|
|
26
27
|
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";
|
|
@@ -49,9 +50,23 @@ export default {
|
|
|
49
50
|
};
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
Static files never reach a Worker by default, so
|
|
53
|
-
|
|
54
|
-
|
|
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`.
|
|
55
70
|
|
|
56
71
|
## Express
|
|
57
72
|
|
|
@@ -60,12 +75,22 @@ import express from "express";
|
|
|
60
75
|
import { statlarkBots } from "@statlark/bots/express";
|
|
61
76
|
|
|
62
77
|
const app = express();
|
|
63
|
-
app.set("trust proxy", 1); // your exact number of proxies
|
|
64
78
|
app.use(statlarkBots());
|
|
65
79
|
```
|
|
66
80
|
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
69
94
|
|
|
70
95
|
## Any other server
|
|
71
96
|
|
|
@@ -90,9 +115,16 @@ Filter by user agent first, so you don't send every visitor to us. The guide has
|
|
|
90
115
|
|
|
91
116
|
## What's sent
|
|
92
117
|
|
|
93
|
-
The page's origin and path (the query string and fragment are stripped first)
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
96
128
|
|
|
97
129
|
## License
|
|
98
130
|
|
package/dist/express.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { type TrackOptions } from "./index.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
|
|
4
4
|
*
|
|
5
|
-
* `req.ip
|
|
6
|
-
* set it
|
|
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.
|
|
7
8
|
*/
|
|
8
9
|
export declare function statlarkBots(options?: TrackOptions): (req: any, res: any, next: () => void) => void;
|
|
9
10
|
//# sourceMappingURL=express.d.ts.map
|
package/dist/express.d.ts.map
CHANGED
|
@@ -1 +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
|
|
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
CHANGED
|
@@ -2,8 +2,9 @@ import { reportBotHit } from "./index.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
|
|
4
4
|
*
|
|
5
|
-
* `req.ip
|
|
6
|
-
* set it
|
|
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.
|
|
7
8
|
*/
|
|
8
9
|
export function statlarkBots(options) {
|
|
9
10
|
return (req, _res, next) => {
|
package/dist/express.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"express.js","sourceRoot":"","sources":["../src/express.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAqB,MAAM,YAAY,CAAC;AAE7D
|
|
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
CHANGED
|
@@ -12,7 +12,11 @@ export type BotHit = {
|
|
|
12
12
|
/** An absolute URL or a path. The query string and fragment are never sent. */
|
|
13
13
|
url: string;
|
|
14
14
|
userAgent: string | null | undefined;
|
|
15
|
-
/**
|
|
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
|
+
*/
|
|
16
20
|
ip?: string | null;
|
|
17
21
|
};
|
|
18
22
|
export type TrackOptions = {
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;
|
|
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
CHANGED
|
@@ -15,17 +15,29 @@ const MAX_STOP_MS = 3600000;
|
|
|
15
15
|
const FAILURE_BACKOFF_MS = 60000;
|
|
16
16
|
const MAX_FAILURES = 3;
|
|
17
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;
|
|
18
24
|
// Cheap first filter. Statlark's server decides what's really a crawler (docs/21 D12), so
|
|
19
25
|
// this only has to skip ordinary browsers without a network call. Keep it broad: a crawler
|
|
20
26
|
// this misses never reaches the server, so adding it to the list wouldn't be enough.
|
|
21
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;
|
|
22
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]/;
|
|
23
33
|
// Crawlers with a published list: the only ones whose IP is worth sending (D19).
|
|
24
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;
|
|
25
35
|
// Stands in for the origin of a bare path, so `new URL` can parse one.
|
|
26
36
|
const NO_ORIGIN = "https://statlark.invalid";
|
|
27
37
|
// Per key, so one bad key on a multi-tenant host can't stop another tenant's.
|
|
28
38
|
const state = new Map();
|
|
39
|
+
// User agents the server answered unknown_crawler for, with when to try again.
|
|
40
|
+
const unknown = new Map();
|
|
29
41
|
let inFlight = 0;
|
|
30
42
|
let warnedNoKey = false;
|
|
31
43
|
function stateFor(key) {
|
|
@@ -48,6 +60,38 @@ async function sha256Hex(text) {
|
|
|
48
60
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
49
61
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
50
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
|
+
}
|
|
51
95
|
function warnOnce(entry, message) {
|
|
52
96
|
if (entry.warned)
|
|
53
97
|
return;
|
|
@@ -70,12 +114,19 @@ export async function reportBotHit(hit, options = {}) {
|
|
|
70
114
|
if (typeof window !== "undefined")
|
|
71
115
|
return; // never run in a browser
|
|
72
116
|
const userAgent = hit.userAgent;
|
|
73
|
-
if (!userAgent || !CANDIDATE.test(userAgent))
|
|
117
|
+
if (!userAgent || userAgent.length > MAX_USER_AGENT || !CANDIDATE.test(userAgent))
|
|
74
118
|
return;
|
|
119
|
+
const retryAt = unknown.get(userAgent);
|
|
120
|
+
if (retryAt !== undefined) {
|
|
121
|
+
if (Date.now() < retryAt)
|
|
122
|
+
return;
|
|
123
|
+
unknown.delete(userAgent);
|
|
124
|
+
}
|
|
75
125
|
const page = pageUrl(hit.url);
|
|
76
|
-
if (!page || SKIP.test(page.pathname))
|
|
126
|
+
if (!page || SKIP.test(page.pathname) || FRAMEWORK.test(page.pathname))
|
|
77
127
|
return;
|
|
78
|
-
|
|
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();
|
|
79
130
|
if (!key) {
|
|
80
131
|
if (!warnedNoKey) {
|
|
81
132
|
warnedNoKey = true;
|
|
@@ -84,6 +135,10 @@ export async function reportBotHit(hit, options = {}) {
|
|
|
84
135
|
return;
|
|
85
136
|
}
|
|
86
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
|
+
}
|
|
87
142
|
if (Date.now() < entry.stoppedUntil || inFlight >= MAX_IN_FLIGHT)
|
|
88
143
|
return;
|
|
89
144
|
inFlight += 1;
|
|
@@ -93,7 +148,7 @@ export async function reportBotHit(hit, options = {}) {
|
|
|
93
148
|
if (HAS_LIST.test(userAgent))
|
|
94
149
|
body.ip = hit.ip;
|
|
95
150
|
else
|
|
96
|
-
body.source = await sha256Hex(`${key}:${hit.ip}`);
|
|
151
|
+
body.source = await sha256Hex(`${key}:${sourceAddress(hit.ip)}`);
|
|
97
152
|
}
|
|
98
153
|
const res = await fetch(options.endpoint ?? ENDPOINT, {
|
|
99
154
|
method: "POST",
|
|
@@ -102,8 +157,14 @@ export async function reportBotHit(hit, options = {}) {
|
|
|
102
157
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
103
158
|
});
|
|
104
159
|
if (res.status === 401 || res.status === 402 || res.status === 403) {
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
}
|
|
107
168
|
}
|
|
108
169
|
else if (res.status === 429) {
|
|
109
170
|
const seconds = Number(res.headers.get("retry-after"));
|
|
@@ -118,6 +179,11 @@ export async function reportBotHit(hit, options = {}) {
|
|
|
118
179
|
}
|
|
119
180
|
if (res.status === 200) {
|
|
120
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
|
+
}
|
|
121
187
|
if (reply.paused_until) {
|
|
122
188
|
// Capped, so an upgrade takes effect within the hour.
|
|
123
189
|
const until = Date.parse(reply.paused_until);
|
|
@@ -125,7 +191,7 @@ export async function reportBotHit(hit, options = {}) {
|
|
|
125
191
|
entry.stoppedUntil = Number.isNaN(until) ? cap : Math.min(until, cap);
|
|
126
192
|
}
|
|
127
193
|
}
|
|
128
|
-
else {
|
|
194
|
+
else if (!res.bodyUsed) {
|
|
129
195
|
await res.body?.cancel();
|
|
130
196
|
}
|
|
131
197
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,QAAQ,GAAG,mCAAmC,CAAC;AACrD,MAAM,UAAU,GAAG,IAAK,CAAC;AACzB,MAAM,eAAe,GAAG,MAAO,CAAC;AAChC,MAAM,WAAW,GAAG,OAAS,CAAC;AAC9B,MAAM,kBAAkB,GAAG,KAAM,CAAC;AAClC,MAAM,YAAY,GAAG,CAAC,CAAC;AACvB,MAAM,aAAa,GAAG,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/next.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ type Evt = {
|
|
|
7
7
|
waitUntil(p: Promise<unknown>): void;
|
|
8
8
|
};
|
|
9
9
|
/**
|
|
10
|
-
* Call this first in `proxy.ts
|
|
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`.
|
|
11
12
|
*
|
|
12
13
|
* Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
|
|
13
14
|
* leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
|
package/dist/next.d.ts.map
CHANGED
|
@@ -1 +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
|
|
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
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { reportBotHit } from "./index.js";
|
|
2
2
|
/**
|
|
3
|
-
* Call this first in `proxy.ts
|
|
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`.
|
|
4
5
|
*
|
|
5
6
|
* Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
|
|
6
7
|
* leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
|
package/dist/next.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"next.js","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAqB,MAAM,YAAY,CAAC;AAK7D
|
|
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,11 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statlark/bots",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
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
|
+
},
|
|
9
16
|
"exports": {
|
|
10
17
|
".": {
|
|
11
18
|
"types": "./dist/index.d.ts",
|
|
@@ -31,7 +38,7 @@
|
|
|
31
38
|
},
|
|
32
39
|
"sideEffects": false,
|
|
33
40
|
"engines": {
|
|
34
|
-
"node": "
|
|
41
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
35
42
|
},
|
|
36
43
|
"scripts": {
|
|
37
44
|
"build": "tsc",
|
package/src/express.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { reportBotHit, type TrackOptions } from "./index.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
|
|
5
5
|
*
|
|
6
|
-
* `req.ip
|
|
7
|
-
* set it
|
|
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.
|
|
8
9
|
*/
|
|
9
10
|
export function statlarkBots(
|
|
10
11
|
options?: TrackOptions,
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,12 @@ const MAX_STOP_MS = 3_600_000;
|
|
|
16
16
|
const FAILURE_BACKOFF_MS = 60_000;
|
|
17
17
|
const MAX_FAILURES = 3;
|
|
18
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;
|
|
19
25
|
|
|
20
26
|
// Cheap first filter. Statlark's server decides what's really a crawler (docs/21 D12), so
|
|
21
27
|
// this only has to skip ordinary browsers without a network call. Keep it broad: a crawler
|
|
@@ -24,6 +30,10 @@ const CANDIDATE =
|
|
|
24
30
|
/bot\b|bot\/|crawl|spider|slurp|-user\b|-index\b|-training\b|-agent\b|amzn-|google-|googleother|meta-|bytespider|cohere|facebookexternalhit/i;
|
|
25
31
|
const SKIP =
|
|
26
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]/;
|
|
27
37
|
|
|
28
38
|
// Crawlers with a published list: the only ones whose IP is worth sending (D19).
|
|
29
39
|
const HAS_LIST =
|
|
@@ -36,6 +46,8 @@ type KeyState = { stoppedUntil: number; failures: number; warned: boolean };
|
|
|
36
46
|
|
|
37
47
|
// Per key, so one bad key on a multi-tenant host can't stop another tenant's.
|
|
38
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>();
|
|
39
51
|
let inFlight = 0;
|
|
40
52
|
let warnedNoKey = false;
|
|
41
53
|
|
|
@@ -43,7 +55,11 @@ export type BotHit = {
|
|
|
43
55
|
/** An absolute URL or a path. The query string and fragment are never sent. */
|
|
44
56
|
url: string;
|
|
45
57
|
userAgent: string | null | undefined;
|
|
46
|
-
/**
|
|
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
|
+
*/
|
|
47
63
|
ip?: string | null;
|
|
48
64
|
};
|
|
49
65
|
|
|
@@ -76,6 +92,36 @@ async function sha256Hex(text: string): Promise<string> {
|
|
|
76
92
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
77
93
|
}
|
|
78
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
|
+
|
|
79
125
|
function warnOnce(entry: KeyState, message: string): void {
|
|
80
126
|
if (entry.warned) return;
|
|
81
127
|
entry.warned = true;
|
|
@@ -98,12 +144,19 @@ export async function reportBotHit(hit: BotHit, options: TrackOptions = {}): Pro
|
|
|
98
144
|
try {
|
|
99
145
|
if (typeof window !== "undefined") return; // never run in a browser
|
|
100
146
|
const userAgent = hit.userAgent;
|
|
101
|
-
if (!userAgent || !CANDIDATE.test(userAgent)) return;
|
|
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
|
+
}
|
|
102
153
|
const page = pageUrl(hit.url);
|
|
103
|
-
if (!page || SKIP.test(page.pathname)) return;
|
|
154
|
+
if (!page || SKIP.test(page.pathname) || FRAMEWORK.test(page.pathname)) return;
|
|
104
155
|
|
|
105
|
-
|
|
106
|
-
|
|
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();
|
|
107
160
|
if (!key) {
|
|
108
161
|
if (!warnedNoKey) {
|
|
109
162
|
warnedNoKey = true;
|
|
@@ -113,6 +166,10 @@ export async function reportBotHit(hit: BotHit, options: TrackOptions = {}): Pro
|
|
|
113
166
|
}
|
|
114
167
|
|
|
115
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
|
+
}
|
|
116
173
|
if (Date.now() < entry.stoppedUntil || inFlight >= MAX_IN_FLIGHT) return;
|
|
117
174
|
|
|
118
175
|
inFlight += 1;
|
|
@@ -121,7 +178,7 @@ export async function reportBotHit(hit: BotHit, options: TrackOptions = {}): Pro
|
|
|
121
178
|
const body: Record<string, string> = { url: page.send, user_agent: userAgent };
|
|
122
179
|
if (hit.ip) {
|
|
123
180
|
if (HAS_LIST.test(userAgent)) body.ip = hit.ip;
|
|
124
|
-
else body.source = await sha256Hex(`${key}:${hit.ip}`);
|
|
181
|
+
else body.source = await sha256Hex(`${key}:${sourceAddress(hit.ip)}`);
|
|
125
182
|
}
|
|
126
183
|
|
|
127
184
|
const res = await fetch(options.endpoint ?? ENDPOINT, {
|
|
@@ -132,8 +189,13 @@ export async function reportBotHit(hit: BotHit, options: TrackOptions = {}): Pro
|
|
|
132
189
|
});
|
|
133
190
|
|
|
134
191
|
if (res.status === 401 || res.status === 402 || res.status === 403) {
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|
|
137
199
|
} else if (res.status === 429) {
|
|
138
200
|
const seconds = Number(res.headers.get("retry-after"));
|
|
139
201
|
const wait = seconds > 0 ? seconds * 1000 : FAILURE_BACKOFF_MS;
|
|
@@ -145,14 +207,18 @@ export async function reportBotHit(hit: BotHit, options: TrackOptions = {}): Pro
|
|
|
145
207
|
}
|
|
146
208
|
|
|
147
209
|
if (res.status === 200) {
|
|
148
|
-
const reply = (await res.json()) as { paused_until?: string };
|
|
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
|
+
}
|
|
149
215
|
if (reply.paused_until) {
|
|
150
216
|
// Capped, so an upgrade takes effect within the hour.
|
|
151
217
|
const until = Date.parse(reply.paused_until);
|
|
152
218
|
const cap = Date.now() + MAX_STOP_MS;
|
|
153
219
|
entry.stoppedUntil = Number.isNaN(until) ? cap : Math.min(until, cap);
|
|
154
220
|
}
|
|
155
|
-
} else {
|
|
221
|
+
} else if (!res.bodyUsed) {
|
|
156
222
|
await res.body?.cancel();
|
|
157
223
|
}
|
|
158
224
|
} catch (e) {
|
package/src/next.ts
CHANGED
|
@@ -4,7 +4,8 @@ type Req = { url: string; headers: Headers };
|
|
|
4
4
|
type Evt = { waitUntil(p: Promise<unknown>): void };
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Call this first in `proxy.ts
|
|
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`.
|
|
8
9
|
*
|
|
9
10
|
* Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
|
|
10
11
|
* leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
|