@pickrate/collector 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 ADDED
@@ -0,0 +1,65 @@
1
+ # @pickrate/collector
2
+
3
+ See which AI agents visit your site. One file, no data pipeline.
4
+
5
+ The analytics you already run (Google Analytics, Amplitude) can't see AI agents, because agents don't run JavaScript. This collector sits in your server middleware, where it *can* see them, classifies each request (which agent, what it read), and sends it to [Pickrate](https://pickrate.io).
6
+
7
+ ## Install (Next.js)
8
+
9
+ ```bash
10
+ npm install @pickrate/collector
11
+ ```
12
+
13
+ ```ts
14
+ // middleware.ts
15
+ export { middleware } from "@pickrate/collector/next";
16
+ export const config = { matcher: ["/((?!_next|favicon).*)"] };
17
+ ```
18
+
19
+ ```bash
20
+ # .env
21
+ PICKRATE_KEY=sk_live_xxx # your Pickrate secret key
22
+ ```
23
+
24
+ That's it. Agent hits start showing up in your Pickrate dashboard. Human traffic classifies to nothing and pays zero cost.
25
+
26
+ ## What it captures
27
+
28
+ Every request from a known agent (by user-agent) or to a machine-readable surface (`/llms.txt`, `/openapi.json`, `/.well-known/*`, `*.md`, `sitemap.xml`, `robots.txt`). For each it sends an `agent_interaction`:
29
+
30
+ - **actor** — OpenAI GPTBot, Anthropic ClaudeBot, Perplexity, ...
31
+ - **actorType** — assistant (a person is waiting) vs crawler (background)
32
+ - **surface** — llms_txt, openapi_spec, well_known_endpoint, content, ...
33
+ - **interaction** — llms_txt_requested, page_requested, ...
34
+ - **object / path** — what was read
35
+
36
+ It never reports app routes, assets, or `/api`. Human page loads are ignored.
37
+
38
+ ## Custom instrumentation
39
+
40
+ Compose your own middleware, or report events the request path can't see (e.g. an MCP `tool_invoked`):
41
+
42
+ ```ts
43
+ import { createCollector } from "@pickrate/collector";
44
+ const pickrate = createCollector({ key: process.env.PICKRATE_KEY! });
45
+
46
+ // From an MCP tool handler:
47
+ await pickrate.send({
48
+ type: "agent_interaction",
49
+ actor: "chatgpt",
50
+ actorType: "assistant",
51
+ protocol: "http",
52
+ surface: "remote_mcp_server",
53
+ interaction: "tool_invoked",
54
+ object: "create_automation",
55
+ path: "/mcp",
56
+ });
57
+ ```
58
+
59
+ ## Notes
60
+
61
+ - **Secret key.** `agent_interaction` is server-detected, so the ingest requires a secret (`sk_`) key. The collector runs server-side, so the key never reaches a browser.
62
+ - **Fail-safe.** Every send is fire-and-forget and timeout-bounded. A logging failure never surfaces to a visitor.
63
+ - **Runtime.** The core (`@pickrate/collector`) is framework-agnostic (Edge, Node, Cloudflare). The Next adapter (`@pickrate/collector/next`) uses `waitUntil` when available so nothing blocks the response.
64
+
65
+ MIT © Pickrate
package/index.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ // Type declarations for @pickrate/collector (framework-agnostic core).
2
+
3
+ export interface RequestInfo {
4
+ method: string;
5
+ /** pathname only, no query string */
6
+ path: string;
7
+ userAgent: string;
8
+ }
9
+
10
+ export interface InteractionPayload {
11
+ type: "agent_interaction";
12
+ /** clean display name, or "unknown" */
13
+ actor: string;
14
+ /** assistant | crawler | unknown_machine */
15
+ actorType: string;
16
+ protocol: "http";
17
+ /** llms_txt | well_known_endpoint | content | ... */
18
+ surface: string;
19
+ /** llms_txt_requested | page_requested | ... */
20
+ interaction: string;
21
+ /** the path acted on */
22
+ object: string;
23
+ path: string;
24
+ format?: string;
25
+ }
26
+
27
+ export interface CollectorConfig {
28
+ /** A Pickrate SECRET key (sk_). agent_interaction is server-detected, so the ingest requires it. */
29
+ key: string;
30
+ /** Ingest endpoint. Default https://pickrate.io/api/collect */
31
+ endpoint?: string;
32
+ /** Per-request send timeout, ms. Default 1500. */
33
+ timeoutMs?: number;
34
+ }
35
+
36
+ export interface Collector {
37
+ /** Classify a request and report it if it's an agent hit. Resolves true when something was sent. */
38
+ report(req: RequestInfo): Promise<boolean>;
39
+ /** Send a pre-built payload (e.g. an MCP tool_invoked the request path can't see). */
40
+ send(payload: InteractionPayload): Promise<void>;
41
+ }
42
+
43
+ export interface SurfaceInfo {
44
+ surface: string;
45
+ interaction: string;
46
+ format?: string;
47
+ dedicated: boolean;
48
+ }
49
+
50
+ export function surfaceFor(path: string): SurfaceInfo;
51
+ export function classifyRequest(req: RequestInfo): InteractionPayload | null;
52
+ export function createCollector(config: CollectorConfig): Collector;
package/index.js ADDED
@@ -0,0 +1,132 @@
1
+ // @pickrate/collector — see which AI agents visit your site. Framework-agnostic core.
2
+ // Hand-authored ESM to match packages/attribution (no build step). Logic mirrors the tested source
3
+ // at app/src/collector; keep them in sync.
4
+
5
+ // UA substring -> clean name. Order matters where one signature contains another.
6
+ const BOTS = [
7
+ ["gptbot", "OpenAI GPTBot"],
8
+ ["oai-searchbot", "OpenAI SearchBot"],
9
+ ["chatgpt-user", "OpenAI ChatGPT-User"],
10
+ ["claudebot", "Anthropic ClaudeBot"],
11
+ ["claude-user", "Anthropic Claude-User"],
12
+ ["claude-web", "Anthropic Claude-Web"],
13
+ ["anthropic-ai", "Anthropic"],
14
+ ["perplexitybot", "Perplexity"],
15
+ ["perplexity-user", "Perplexity-User"],
16
+ ["google-extended", "Google-Extended"],
17
+ ["googlebot", "Googlebot"],
18
+ ["bingbot", "Bingbot"],
19
+ ["applebot-extended", "Applebot-Extended"],
20
+ ["applebot", "Applebot"],
21
+ ["ccbot", "Common Crawl"],
22
+ ["bytespider", "ByteDance"],
23
+ ["amazonbot", "Amazon"],
24
+ ["meta-externalagent", "Meta"],
25
+ ["cohere-ai", "Cohere"],
26
+ ["perplexity", "Perplexity"],
27
+ ];
28
+
29
+ const RETRIEVAL = new Set([
30
+ "OpenAI SearchBot",
31
+ "OpenAI ChatGPT-User",
32
+ "Anthropic Claude-Web",
33
+ "Anthropic Claude-User",
34
+ "Perplexity",
35
+ "Perplexity-User",
36
+ ]);
37
+
38
+ const SEARCH = new Set(["Googlebot", "Bingbot", "Applebot"]);
39
+
40
+ function botFor(ua) {
41
+ const l = String(ua || "").toLowerCase();
42
+ for (const [needle, name] of BOTS) if (l.includes(needle)) return name;
43
+ return null;
44
+ }
45
+
46
+ function actorTypeFor(name) {
47
+ if (RETRIEVAL.has(name)) return "assistant";
48
+ if (SEARCH.has(name)) return "crawler";
49
+ return "crawler";
50
+ }
51
+
52
+ function isExcluded(path) {
53
+ return (
54
+ path.startsWith("/api") ||
55
+ path.startsWith("/_next") ||
56
+ path.startsWith("/dashboard") ||
57
+ path.startsWith("/admin") ||
58
+ path.startsWith("/auth") ||
59
+ path.startsWith("/login") ||
60
+ path === "/favicon.ico" ||
61
+ /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|woff2?|ttf)$/i.test(path)
62
+ );
63
+ }
64
+
65
+ export function surfaceFor(path) {
66
+ if (path === "/llms.txt") return { surface: "llms_txt", interaction: "llms_txt_requested", format: "plain_text", dedicated: true };
67
+ if (path === "/robots.txt") return { surface: "robots_txt", interaction: "robots_txt_requested", format: "plain_text", dedicated: true };
68
+ if (path === "/sitemap.xml") return { surface: "sitemap_xml", interaction: "sitemap_requested", format: "xml", dedicated: true };
69
+ if (path === "/openapi.json") return { surface: "openapi_spec", interaction: "openapi_spec_requested", format: "json", dedicated: true };
70
+ if (path.endsWith(".md")) return { surface: "markdown_document", interaction: "markdown_retrieved", format: "markdown", dedicated: true };
71
+ if (path.startsWith("/.well-known/")) return { surface: "well_known_endpoint", interaction: "manifest_requested", format: "json", dedicated: true };
72
+ return { surface: "content", interaction: "page_requested", dedicated: false };
73
+ }
74
+
75
+ // Classify a request. Reports when it's a KNOWN agent (by UA) OR targets an agent surface. Returns
76
+ // null for human, asset, and excluded traffic.
77
+ export function classifyRequest(req) {
78
+ if (req.method !== "GET" && req.method !== "HEAD") return null;
79
+ const path = String(req.path || "/").split(/[?#]/)[0];
80
+ if (isExcluded(path)) return null;
81
+
82
+ const agent = botFor(req.userAgent);
83
+ const sf = surfaceFor(path);
84
+ if (!agent && !sf.dedicated) return null;
85
+
86
+ const payload = {
87
+ type: "agent_interaction",
88
+ actor: agent || "unknown",
89
+ actorType: agent ? actorTypeFor(agent) : "unknown_machine",
90
+ protocol: "http",
91
+ surface: sf.surface,
92
+ interaction: sf.interaction,
93
+ object: path,
94
+ path,
95
+ };
96
+ if (sf.format) payload.format = sf.format;
97
+ return payload;
98
+ }
99
+
100
+ const DEFAULT_ENDPOINT = "https://pickrate.io/api/collect";
101
+
102
+ // Create a collector bound to a secret key. report(req) classifies + POSTs an agent hit; send()
103
+ // posts a pre-built payload. Fire-and-forget, timeout-bounded: a logging failure never surfaces.
104
+ export function createCollector(config) {
105
+ const endpoint = (config && config.endpoint) || DEFAULT_ENDPOINT;
106
+ const timeoutMs = config && typeof config.timeoutMs === "number" ? config.timeoutMs : 1500;
107
+ const key = (config && config.key) || "";
108
+
109
+ async function send(payload) {
110
+ if (!key) return;
111
+ try {
112
+ await fetch(endpoint, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
115
+ body: JSON.stringify({ events: [payload] }),
116
+ cache: "no-store",
117
+ signal: AbortSignal.timeout(timeoutMs),
118
+ });
119
+ } catch {
120
+ // swallow: a visitor (or bot) must never see a logging error
121
+ }
122
+ }
123
+
124
+ async function report(req) {
125
+ const payload = classifyRequest(req);
126
+ if (!payload) return false;
127
+ await send(payload);
128
+ return true;
129
+ }
130
+
131
+ return { report, send };
132
+ }
package/next.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Type declarations for @pickrate/collector/next.
2
+ import type { NextRequest, NextFetchEvent, NextResponse } from "next/server";
3
+ import type { CollectorConfig } from "./index.js";
4
+
5
+ export function createMiddleware(
6
+ config: CollectorConfig,
7
+ ): (req: NextRequest, ev?: NextFetchEvent) => Promise<NextResponse>;
8
+
9
+ export const middleware: (req: NextRequest, ev?: NextFetchEvent) => Promise<NextResponse>;
package/next.js ADDED
@@ -0,0 +1,33 @@
1
+ // @pickrate/collector/next — the Next.js middleware adapter. The one-file install:
2
+ //
3
+ // // middleware.ts
4
+ // export { middleware } from "@pickrate/collector/next";
5
+ // export const config = { matcher: ["/((?!_next|favicon).*)"] };
6
+ //
7
+ // Set PICKRATE_KEY (a secret sk_ key) in the environment.
8
+
9
+ import { NextResponse } from "next/server";
10
+ import { createCollector } from "./index.js";
11
+
12
+ // Build a middleware bound to a specific config (for composing your own middleware).
13
+ export function createMiddleware(config) {
14
+ const collector = createCollector(config);
15
+ return async function middleware(req, ev) {
16
+ const info = {
17
+ method: req.method,
18
+ path: req.nextUrl.pathname,
19
+ userAgent: req.headers.get("user-agent") || "",
20
+ };
21
+ // Prefer waitUntil (Vercel/Edge) so nothing blocks the response. Where it's unavailable, await:
22
+ // only agent hits actually send, so a human page load still returns instantly.
23
+ if (ev && typeof ev.waitUntil === "function") ev.waitUntil(collector.report(info));
24
+ else await collector.report(info);
25
+ return NextResponse.next();
26
+ };
27
+ }
28
+
29
+ // Zero-config default for the one-file install. Reads PICKRATE_KEY (and optional PICKRATE_ENDPOINT).
30
+ export const middleware = createMiddleware({
31
+ key: process.env.PICKRATE_KEY || "",
32
+ endpoint: process.env.PICKRATE_ENDPOINT,
33
+ });
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@pickrate/collector",
3
+ "version": "0.1.0",
4
+ "description": "See which AI agents visit your site. One-file server-side collector for Pickrate Agent Analytics.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "module": "index.js",
8
+ "types": "index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "default": "./index.js"
13
+ },
14
+ "./next": {
15
+ "types": "./next.d.ts",
16
+ "default": "./next.js"
17
+ }
18
+ },
19
+ "files": ["index.js", "index.d.ts", "next.js", "next.d.ts", "README.md"],
20
+ "engines": { "node": ">=18" },
21
+ "license": "MIT",
22
+ "homepage": "https://pickrate.io",
23
+ "repository": { "type": "git", "url": "git+https://github.com/TimHey/pickrate.git", "directory": "packages/collector" },
24
+ "keywords": ["pickrate", "agent", "analytics", "ai", "crawler", "mcp", "llms.txt", "attribution", "middleware", "next"],
25
+ "peerDependencies": { "next": ">=13" },
26
+ "peerDependenciesMeta": { "next": { "optional": true } },
27
+ "sideEffects": false
28
+ }