@statlark/bots 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # @statlark/bots
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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. It adds no delay to your pages: reports are sent
7
+ in the background, 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` (`middleware.ts` before Next 16), first, before any login check:
24
+
25
+ ```ts
26
+ import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";
27
+ import { trackBot } from "@statlark/bots/next";
28
+
29
+ export function proxy(request: NextRequest, event: NextFetchEvent) {
30
+ trackBot(request, event);
31
+ return NextResponse.next();
32
+ }
33
+ ```
34
+
35
+ On Vercel it reads the address from `x-real-ip`. Anywhere else, pass `{ ip }` yourself, or the
36
+ check can't run. `x-forwarded-for` is never used: outside Vercel its leftmost entry is
37
+ client-supplied, so a scanner could forge a verified request.
38
+
39
+ ## Cloudflare Workers and Pages
40
+
41
+ ```ts
42
+ import { trackBot } from "@statlark/bots/cloudflare";
43
+
44
+ export default {
45
+ async fetch(request, env, ctx) {
46
+ trackBot(request, ctx, env);
47
+ return handle(request, env);
48
+ },
49
+ };
50
+ ```
51
+
52
+ Static files never reach a Worker by default, so set `assets.run_worker_first` in your Wrangler
53
+ config, or existing pages and `robots.txt` go uncounted. On Pages, `_routes.json` must not exclude
54
+ them.
55
+
56
+ ## Express
57
+
58
+ ```ts
59
+ import express from "express";
60
+ import { statlarkBots } from "@statlark/bots/express";
61
+
62
+ const app = express();
63
+ app.set("trust proxy", 1); // your exact number of proxies
64
+ app.use(statlarkBots());
65
+ ```
66
+
67
+ Behind a proxy, `req.ip` is only right if `trust proxy` is an **exact hop count**. Never set it to
68
+ `true`: that makes `req.ip` the client-supplied leftmost entry.
69
+
70
+ ## Any other server
71
+
72
+ `reportBotHit` works anywhere with `fetch`:
73
+
74
+ ```ts
75
+ import { reportBotHit } from "@statlark/bots";
76
+
77
+ void reportBotHit({ url: request.url, userAgent: request.headers.get("user-agent"), ip });
78
+ ```
79
+
80
+ Or call the endpoint yourself:
81
+
82
+ ```sh
83
+ curl -X POST https://collect.statlark.com/bots \
84
+ -H "Authorization: Bearer $STATLARK_BOT_KEY" \
85
+ -H "Content-Type: application/json" \
86
+ -d '{"url":"https://example.com/pricing","user_agent":"GPTBot/1.1","ip":"132.196.86.10"}'
87
+ ```
88
+
89
+ Filter by user agent first, so you don't send every visitor to us. The guide has the filter.
90
+
91
+ ## What's sent
92
+
93
+ The page's origin and path (the query string and fragment are stripped first), the user agent,
94
+ and, for crawlers with a published address list, the IP. The IP is used to check the crawler is
95
+ genuine, then discarded. Statlark keeps the crawler's name, the path and a count.
96
+
97
+ ## License
98
+
99
+ 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,9 @@
1
+ import { type TrackOptions } from "./index.js";
2
+ /**
3
+ * `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
4
+ *
5
+ * `req.ip` is only right behind a proxy if `trust proxy` is set to an exact hop count. Never
6
+ * set it to `true`, which makes `req.ip` the client-supplied leftmost entry.
7
+ */
8
+ export declare function statlarkBots(options?: TrackOptions): (req: any, res: any, next: () => void) => void;
9
+ //# 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;;;;;GAKG;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"}
@@ -0,0 +1,24 @@
1
+ import { reportBotHit } from "./index.js";
2
+ /**
3
+ * `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
4
+ *
5
+ * `req.ip` is only right behind a proxy if `trust proxy` is set to an exact hop count. Never
6
+ * set it to `true`, which makes `req.ip` the client-supplied leftmost entry.
7
+ */
8
+ export function statlarkBots(options) {
9
+ return (req, _res, next) => {
10
+ try {
11
+ void reportBotHit({
12
+ url: `${req.protocol}://${req.get("host")}${req.originalUrl}`,
13
+ userAgent: req.get("user-agent"),
14
+ ip: req.ip,
15
+ }, options);
16
+ }
17
+ catch {
18
+ // Never fail the request.
19
+ }
20
+ // Outside the try: an error from the next handler is the app's, not ours to swallow.
21
+ next();
22
+ };
23
+ }
24
+ //# 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;;;;;GAKG;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"}
@@ -0,0 +1,27 @@
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
+ /** The crawler's address, if you have a reliable one. Used for the check, then discarded. */
16
+ ip?: string | null;
17
+ };
18
+ export type TrackOptions = {
19
+ /** Defaults to the STATLARK_BOT_KEY environment variable. */
20
+ key?: string;
21
+ endpoint?: string;
22
+ onError?: (e: unknown) => void;
23
+ };
24
+ /** Reports a crawler's request. Never throws, never rejects, and always returns a promise,
25
+ * so it's safe to hand straight to any waitUntil. */
26
+ export declare function reportBotHit(hit: BotHit, options?: TrackOptions): Promise<void>;
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAgCH,MAAM,MAAM,MAAM,GAAG;IACnB,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACrC,6FAA6F;IAC7F,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;AAsCF;sDACsD;AACtD,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyEzF"}
package/dist/index.js ADDED
@@ -0,0 +1,147 @@
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
+ // Cheap first filter. Statlark's server decides what's really a crawler (docs/21 D12), so
19
+ // this only has to skip ordinary browsers without a network call. Keep it broad: a crawler
20
+ // this misses never reaches the server, so adding it to the list wouldn't be enough.
21
+ 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
+ const SKIP = /\.(?:js|mjs|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|wasm)$/i;
23
+ // Crawlers with a published list: the only ones whose IP is worth sending (D19).
24
+ 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
+ // Stands in for the origin of a bare path, so `new URL` can parse one.
26
+ const NO_ORIGIN = "https://statlark.invalid";
27
+ // Per key, so one bad key on a multi-tenant host can't stop another tenant's.
28
+ const state = new Map();
29
+ let inFlight = 0;
30
+ let warnedNoKey = false;
31
+ function stateFor(key) {
32
+ let entry = state.get(key);
33
+ if (!entry) {
34
+ entry = { stoppedUntil: 0, failures: 0, warned: false };
35
+ state.set(key, entry);
36
+ }
37
+ return entry;
38
+ }
39
+ /** The URL to send and its pathname, or null if it isn't something we'd report. */
40
+ function pageUrl(url) {
41
+ const parsed = new URL(url, NO_ORIGIN);
42
+ if (parsed.origin === "null")
43
+ return null; // mailto:, javascript: and the like
44
+ const send = parsed.origin === NO_ORIGIN ? parsed.pathname : parsed.origin + parsed.pathname;
45
+ return { send, pathname: parsed.pathname };
46
+ }
47
+ async function sha256Hex(text) {
48
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
49
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
50
+ }
51
+ function warnOnce(entry, message) {
52
+ if (entry.warned)
53
+ return;
54
+ entry.warned = true;
55
+ console.warn(message);
56
+ }
57
+ function countFailure(entry) {
58
+ entry.failures += 1;
59
+ if (entry.failures >= MAX_FAILURES) {
60
+ entry.failures = 0;
61
+ entry.stoppedUntil = Date.now() + FAILURE_BACKOFF_MS;
62
+ }
63
+ }
64
+ /** Reports a crawler's request. Never throws, never rejects, and always returns a promise,
65
+ * so it's safe to hand straight to any waitUntil. */
66
+ export async function reportBotHit(hit, options = {}) {
67
+ let entry;
68
+ let counted = false;
69
+ try {
70
+ if (typeof window !== "undefined")
71
+ return; // never run in a browser
72
+ const userAgent = hit.userAgent;
73
+ if (!userAgent || !CANDIDATE.test(userAgent))
74
+ return;
75
+ const page = pageUrl(hit.url);
76
+ if (!page || SKIP.test(page.pathname))
77
+ return;
78
+ const key = options.key ?? (typeof process !== "undefined" ? process.env.STATLARK_BOT_KEY : undefined);
79
+ if (!key) {
80
+ if (!warnedNoKey) {
81
+ warnedNoKey = true;
82
+ console.warn("[statlark] STATLARK_BOT_KEY isn't set, so bot tracking is off.");
83
+ }
84
+ return;
85
+ }
86
+ entry = stateFor(key);
87
+ if (Date.now() < entry.stoppedUntil || inFlight >= MAX_IN_FLIGHT)
88
+ return;
89
+ inFlight += 1;
90
+ counted = true;
91
+ const body = { url: page.send, user_agent: userAgent };
92
+ if (hit.ip) {
93
+ if (HAS_LIST.test(userAgent))
94
+ body.ip = hit.ip;
95
+ else
96
+ body.source = await sha256Hex(`${key}:${hit.ip}`);
97
+ }
98
+ const res = await fetch(options.endpoint ?? ENDPOINT, {
99
+ method: "POST",
100
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
101
+ body: JSON.stringify(body),
102
+ signal: AbortSignal.timeout(TIMEOUT_MS),
103
+ });
104
+ if (res.status === 401 || res.status === 402 || res.status === 403) {
105
+ entry.stoppedUntil = Date.now() + AUTH_BACKOFF_MS;
106
+ warnOnce(entry, `[statlark] Bot tracking stopped: the server answered ${res.status}. Check your bot key and plan.`);
107
+ }
108
+ else if (res.status === 429) {
109
+ const seconds = Number(res.headers.get("retry-after"));
110
+ const wait = seconds > 0 ? seconds * 1000 : FAILURE_BACKOFF_MS;
111
+ entry.stoppedUntil = Date.now() + Math.min(wait, MAX_STOP_MS);
112
+ }
113
+ else if (res.status >= 500) {
114
+ countFailure(entry);
115
+ }
116
+ else {
117
+ entry.failures = 0;
118
+ }
119
+ if (res.status === 200) {
120
+ const reply = (await res.json());
121
+ if (reply.paused_until) {
122
+ // Capped, so an upgrade takes effect within the hour.
123
+ const until = Date.parse(reply.paused_until);
124
+ const cap = Date.now() + MAX_STOP_MS;
125
+ entry.stoppedUntil = Number.isNaN(until) ? cap : Math.min(until, cap);
126
+ }
127
+ }
128
+ else {
129
+ await res.body?.cancel();
130
+ }
131
+ }
132
+ catch (e) {
133
+ if (entry)
134
+ countFailure(entry);
135
+ try {
136
+ options.onError?.(e);
137
+ }
138
+ catch {
139
+ // A throwing onError stays here too.
140
+ }
141
+ }
142
+ finally {
143
+ if (counted)
144
+ inFlight -= 1;
145
+ }
146
+ }
147
+ //# 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,20 @@
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` (`middleware.ts` before Next 16), before any login check.
11
+ *
12
+ * Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
13
+ * leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
14
+ * anything else, pass `options.ip` yourself.
15
+ */
16
+ export declare function trackBot(request: Req, event: Evt, options?: TrackOptions & {
17
+ ip?: string | null;
18
+ }): void;
19
+ export {};
20
+ //# 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;;;;;;GAMG;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,22 @@
1
+ import { reportBotHit } from "./index.js";
2
+ /**
3
+ * Call this first in `proxy.ts` (`middleware.ts` before Next 16), before any login check.
4
+ *
5
+ * Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
6
+ * leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
7
+ * anything else, pass `options.ip` yourself.
8
+ */
9
+ export function trackBot(request, event, options) {
10
+ // The whole body is guarded, and so is a missing or wrapped `event`. Only crawlers reach the
11
+ // work below, so a manual test wouldn't show a failure here.
12
+ try {
13
+ const onVercel = typeof process !== "undefined" && Boolean(process.env.VERCEL);
14
+ const ip = options?.ip ?? (onVercel ? request.headers.get("x-real-ip") : null);
15
+ const promise = reportBotHit({ url: request.url, userAgent: request.headers.get("user-agent"), ip }, options);
16
+ event?.waitUntil?.(promise);
17
+ }
18
+ catch {
19
+ // Never fail the request.
20
+ }
21
+ }
22
+ //# sourceMappingURL=next.js.map
@@ -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;;;;;;GAMG;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,55 @@
1
1
  {
2
2
  "name": "@statlark/bots",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./next": {
15
+ "types": "./dist/next.d.ts",
16
+ "default": "./dist/next.js"
17
+ },
18
+ "./cloudflare": {
19
+ "types": "./dist/cloudflare.d.ts",
20
+ "default": "./dist/cloudflare.js"
21
+ },
22
+ "./express": {
23
+ "types": "./dist/express.d.ts",
24
+ "default": "./dist/express.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": ["dist", "src", "README.md"],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "sideEffects": false,
33
+ "engines": {
34
+ "node": ">=20.19"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "typecheck": "tsc --noEmit",
39
+ "prepublishOnly": "tsc"
40
+ },
41
+ "keywords": [
42
+ "statlark",
43
+ "analytics",
44
+ "bots",
45
+ "crawlers",
46
+ "ai-crawlers",
47
+ "gptbot",
48
+ "nextjs",
49
+ "cloudflare-workers",
50
+ "express"
51
+ ],
52
+ "devDependencies": {
53
+ "typescript": "^5.5.4"
54
+ }
55
+ }
@@ -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,28 @@
1
+ import { reportBotHit, type TrackOptions } from "./index.js";
2
+
3
+ /**
4
+ * `app.use(statlarkBots())`. Fire and forget: the request carries on immediately.
5
+ *
6
+ * `req.ip` is only right behind a proxy if `trust proxy` is set to an exact hop count. Never
7
+ * set it to `true`, which makes `req.ip` the client-supplied leftmost entry.
8
+ */
9
+ export function statlarkBots(
10
+ options?: TrackOptions,
11
+ ): (req: any, res: any, next: () => void) => void {
12
+ return (req, _res, next) => {
13
+ try {
14
+ void reportBotHit(
15
+ {
16
+ url: `${req.protocol}://${req.get("host")}${req.originalUrl}`,
17
+ userAgent: req.get("user-agent"),
18
+ ip: req.ip,
19
+ },
20
+ options,
21
+ );
22
+ } catch {
23
+ // Never fail the request.
24
+ }
25
+ // Outside the try: an error from the next handler is the app's, not ours to swallow.
26
+ next();
27
+ };
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,168 @@
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
+
20
+ // Cheap first filter. Statlark's server decides what's really a crawler (docs/21 D12), so
21
+ // this only has to skip ordinary browsers without a network call. Keep it broad: a crawler
22
+ // this misses never reaches the server, so adding it to the list wouldn't be enough.
23
+ const CANDIDATE =
24
+ /bot\b|bot\/|crawl|spider|slurp|-user\b|-index\b|-training\b|-agent\b|amzn-|google-|googleother|meta-|bytespider|cohere|facebookexternalhit/i;
25
+ const SKIP =
26
+ /\.(?:js|mjs|css|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot|mp4|webm|mp3|wasm)$/i;
27
+
28
+ // Crawlers with a published list: the only ones whose IP is worth sending (D19).
29
+ const HAS_LIST =
30
+ /chatgpt-user|oai-searchbot|oai-adsbot|gptbot|claude|perplexity|google-agent|google-cloudvertexbot|googleother|googlebot|applebot|duckduckbot|duckassistbot|mistralai-(?:user|index)|ccbot|ahrefsbot/i;
31
+
32
+ // Stands in for the origin of a bare path, so `new URL` can parse one.
33
+ const NO_ORIGIN = "https://statlark.invalid";
34
+
35
+ type KeyState = { stoppedUntil: number; failures: number; warned: boolean };
36
+
37
+ // Per key, so one bad key on a multi-tenant host can't stop another tenant's.
38
+ const state = new Map<string, KeyState>();
39
+ let inFlight = 0;
40
+ let warnedNoKey = false;
41
+
42
+ export type BotHit = {
43
+ /** An absolute URL or a path. The query string and fragment are never sent. */
44
+ url: string;
45
+ userAgent: string | null | undefined;
46
+ /** The crawler's address, if you have a reliable one. Used for the check, then discarded. */
47
+ ip?: string | null;
48
+ };
49
+
50
+ export type TrackOptions = {
51
+ /** Defaults to the STATLARK_BOT_KEY environment variable. */
52
+ key?: string;
53
+ endpoint?: string;
54
+ onError?: (e: unknown) => void;
55
+ };
56
+
57
+ function stateFor(key: string): KeyState {
58
+ let entry = state.get(key);
59
+ if (!entry) {
60
+ entry = { stoppedUntil: 0, failures: 0, warned: false };
61
+ state.set(key, entry);
62
+ }
63
+ return entry;
64
+ }
65
+
66
+ /** The URL to send and its pathname, or null if it isn't something we'd report. */
67
+ function pageUrl(url: string): { send: string; pathname: string } | null {
68
+ const parsed = new URL(url, NO_ORIGIN);
69
+ if (parsed.origin === "null") return null; // mailto:, javascript: and the like
70
+ const send = parsed.origin === NO_ORIGIN ? parsed.pathname : parsed.origin + parsed.pathname;
71
+ return { send, pathname: parsed.pathname };
72
+ }
73
+
74
+ async function sha256Hex(text: string): Promise<string> {
75
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
76
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
77
+ }
78
+
79
+ function warnOnce(entry: KeyState, message: string): void {
80
+ if (entry.warned) return;
81
+ entry.warned = true;
82
+ console.warn(message);
83
+ }
84
+
85
+ function countFailure(entry: KeyState): void {
86
+ entry.failures += 1;
87
+ if (entry.failures >= MAX_FAILURES) {
88
+ entry.failures = 0;
89
+ entry.stoppedUntil = Date.now() + FAILURE_BACKOFF_MS;
90
+ }
91
+ }
92
+
93
+ /** Reports a crawler's request. Never throws, never rejects, and always returns a promise,
94
+ * so it's safe to hand straight to any waitUntil. */
95
+ export async function reportBotHit(hit: BotHit, options: TrackOptions = {}): Promise<void> {
96
+ let entry: KeyState | undefined;
97
+ let counted = false;
98
+ try {
99
+ if (typeof window !== "undefined") return; // never run in a browser
100
+ const userAgent = hit.userAgent;
101
+ if (!userAgent || !CANDIDATE.test(userAgent)) return;
102
+ const page = pageUrl(hit.url);
103
+ if (!page || SKIP.test(page.pathname)) return;
104
+
105
+ const key =
106
+ options.key ?? (typeof process !== "undefined" ? process.env.STATLARK_BOT_KEY : undefined);
107
+ if (!key) {
108
+ if (!warnedNoKey) {
109
+ warnedNoKey = true;
110
+ console.warn("[statlark] STATLARK_BOT_KEY isn't set, so bot tracking is off.");
111
+ }
112
+ return;
113
+ }
114
+
115
+ entry = stateFor(key);
116
+ if (Date.now() < entry.stoppedUntil || inFlight >= MAX_IN_FLIGHT) return;
117
+
118
+ inFlight += 1;
119
+ counted = true;
120
+
121
+ const body: Record<string, string> = { url: page.send, user_agent: userAgent };
122
+ if (hit.ip) {
123
+ if (HAS_LIST.test(userAgent)) body.ip = hit.ip;
124
+ else body.source = await sha256Hex(`${key}:${hit.ip}`);
125
+ }
126
+
127
+ const res = await fetch(options.endpoint ?? ENDPOINT, {
128
+ method: "POST",
129
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
130
+ body: JSON.stringify(body),
131
+ signal: AbortSignal.timeout(TIMEOUT_MS),
132
+ });
133
+
134
+ if (res.status === 401 || res.status === 402 || res.status === 403) {
135
+ entry.stoppedUntil = Date.now() + AUTH_BACKOFF_MS;
136
+ warnOnce(entry, `[statlark] Bot tracking stopped: the server answered ${res.status}. Check your bot key and plan.`);
137
+ } else if (res.status === 429) {
138
+ const seconds = Number(res.headers.get("retry-after"));
139
+ const wait = seconds > 0 ? seconds * 1000 : FAILURE_BACKOFF_MS;
140
+ entry.stoppedUntil = Date.now() + Math.min(wait, MAX_STOP_MS);
141
+ } else if (res.status >= 500) {
142
+ countFailure(entry);
143
+ } else {
144
+ entry.failures = 0;
145
+ }
146
+
147
+ if (res.status === 200) {
148
+ const reply = (await res.json()) as { paused_until?: string };
149
+ if (reply.paused_until) {
150
+ // Capped, so an upgrade takes effect within the hour.
151
+ const until = Date.parse(reply.paused_until);
152
+ const cap = Date.now() + MAX_STOP_MS;
153
+ entry.stoppedUntil = Number.isNaN(until) ? cap : Math.min(until, cap);
154
+ }
155
+ } else {
156
+ await res.body?.cancel();
157
+ }
158
+ } catch (e) {
159
+ if (entry) countFailure(entry);
160
+ try {
161
+ options.onError?.(e);
162
+ } catch {
163
+ // A throwing onError stays here too.
164
+ }
165
+ } finally {
166
+ if (counted) inFlight -= 1;
167
+ }
168
+ }
package/src/next.ts ADDED
@@ -0,0 +1,31 @@
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` (`middleware.ts` before Next 16), before any login check.
8
+ *
9
+ * Only `x-real-ip` on Vercel is trusted. `x-forwarded-for` is never used: outside Vercel its
10
+ * leftmost entry is client-supplied, so a scanner could forge a verified request. Behind
11
+ * anything else, pass `options.ip` yourself.
12
+ */
13
+ export function trackBot(
14
+ request: Req,
15
+ event: Evt,
16
+ options?: TrackOptions & { ip?: string | null },
17
+ ): void {
18
+ // The whole body is guarded, and so is a missing or wrapped `event`. Only crawlers reach the
19
+ // work below, so a manual test wouldn't show a failure here.
20
+ try {
21
+ const onVercel = typeof process !== "undefined" && Boolean(process.env.VERCEL);
22
+ const ip = options?.ip ?? (onVercel ? request.headers.get("x-real-ip") : null);
23
+ const promise = reportBotHit(
24
+ { url: request.url, userAgent: request.headers.get("user-agent"), ip },
25
+ options,
26
+ );
27
+ event?.waitUntil?.(promise);
28
+ } catch {
29
+ // Never fail the request.
30
+ }
31
+ }