Skip to content

Commit d6076bf

Browse files
ralyodioclaude
andcommitted
Write down what the crawler paywall does, and put it on a public board
The gateway has been answering 402 and selling day passes for weeks and none of it was recorded: a sale existed only for as long as the response took to send. So 'what did the paywall earn' and 'which agent is the customer' had no answer. Two tables now hold it. A sale is money (crawl_sales, unique on the payment ref so a settlement delivered twice books once). A refusal is demand (crawl_demand, counted per agent per day rather than stored per request, so thousands a day stay small enough to keep forever). Both writes are fire and forget. Bookkeeping must not fail a payment the buyer already made, or turn a 402 into a 500. The board shows the two sides apart: agents paying ranked by money, agents not paying ranked by volume. The same agent can sit on both, and the numbers are never pooled, because 'paid us $1' and 'asked 5,000 times for free' are not the same fact. No commission rate anywhere, since nobody earns here. /leaderboard is in openPaths with and without the trailing slash: the gateway prefix-matches only entries ending in a slash, so the bare path would open the index and still charge for every board on it. Verified against a throwaway Postgres: all 34 migrations apply, a duplicate ref books once, five refusals make one row of five, a live 402 from ClaudeBot increments crawl_demand, and GPTBot reads the board at 200 while / stays 402. A DATE column bound as a JS Date 500s the whole board, so the day is sent as a day. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0144uEVbZK3jdaQkwcLYTXPE
1 parent 959cc80 commit d6076bf

5 files changed

Lines changed: 286 additions & 4 deletions

File tree

‎apps/web/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@tipoff/radio": "workspace:*",
2323
"@tipoff/sports": "workspace:*",
2424
"hono": "^4.10.3",
25-
"mpegts.js": "1.8.2"
25+
"mpegts.js": "1.8.2",
26+
"@profullstack/leaderboard": "^0.3.0"
2627
}
2728
}

‎apps/web/src/app.js‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createGateway, isTrainingAgent } from '@profullstack/x402-gateway';
2-
import { x402Gateway } from '@profullstack/x402-gateway/hono';
32
import * as auth from '@tipoff/auth';
43
import * as invites from '@tipoff/auth/invites';
54
import { brand, config, href } from '@tipoff/config';
@@ -33,6 +32,7 @@ import { getCookie, setCookie } from 'hono/cookie';
3332
import { assetUrl, isCurrentVersion, loadAssetVersions } from './lib/asset-version.js';
3433
import { attempt, callerAddress, forgive, MISS, VIEW } from './lib/auth-throttle.js';
3534
import { buildCalendar } from './lib/ics.js';
35+
import { agentName, leaderboard } from './lib/leaderboard.js';
3636
import { MAX_TILES, parseChannelIds } from './lib/multiview.js';
3737
import { buildFeed } from './lib/rss.js';
3838
import { SECURITY_HEADERS } from './lib/security-headers.js';
@@ -185,7 +185,11 @@ const crawlGateway = createGateway({
185185
passMinutes: config.crawl.passMinutes,
186186
// The maps stay readable: a crawler that reads them may decide the API is
187187
// the cheaper way in, which is the point of publishing them.
188-
openPaths: ['/llms.txt', '/skill.md'],
188+
// Both spellings: the gateway prefix-matches only entries ending in a slash,
189+
// so '/leaderboard' alone would open the index and still charge for every
190+
// board on it. An agent that hits a 402 on the page ranking its own spend
191+
// cannot read the case for buying a pass.
192+
openPaths: ['/llms.txt', '/skill.md', '/leaderboard', '/leaderboard/'],
189193
/*
190194
* Lightpanda is a headless browser sold to scrapers, and on 2026-09-02 a
191195
* fleet of it fetched 8,500 pages here in a day from 104 countries. It is not
@@ -208,8 +212,49 @@ const crawlGateway = createGateway({
208212
chargeSpoofedBrowsers: true,
209213
exempt: (request) => (request.headers.get('cookie') ?? '').includes(`${config.session.cookie}=`),
210214
contact: config.contactEmail ? `mailto:${config.contactEmail}` : `${config.siteUrl}/contact`,
215+
/*
216+
* Write the sale down. Until now a pass existed only for as long as the
217+
* response took to send, so neither "what did this earn" nor "who is the
218+
* customer" could be answered afterwards. Never awaited into the answer: a
219+
* bookkeeping failure must not fail a payment the buyer already made.
220+
*/
221+
onSale: (sale) => {
222+
q.recordCrawlSale({
223+
payer: sale.payer,
224+
ref: sale.ref,
225+
days: sale.days,
226+
priceCents: sale.priceCents,
227+
totalCents: sale.totalCents,
228+
currency: sale.currency,
229+
userAgent: sale.userAgent,
230+
expiresAt: sale.expiresAt,
231+
}).catch((err) => console.error('[x402] could not record the sale', err));
232+
},
233+
});
234+
/*
235+
* Count what the wall turns away, before it answers.
236+
*
237+
* A 402 is the pipeline: it is an agent that wants this data and has not paid
238+
* yet, and that is the only measure of demand this site has. Counted per agent
239+
* per day, so the cost is one upsert on a request that was going to be refused
240+
* anyway, and never awaited.
241+
*/
242+
app.use('*', async (c, next) => {
243+
const answer = await crawlGateway.handle(c.req.raw);
244+
if (!answer) return next();
245+
if (answer.status === 402)
246+
q.recordCrawlDemand(agentName(c.req.header('user-agent'))).catch(() => {});
247+
return answer;
248+
});
249+
250+
/**
251+
* The public board: who pays for this data, and who keeps asking without
252+
* paying. Open to everyone, including the agents on it.
253+
*/
254+
app.use('*', async (c, next) => {
255+
const answer = await leaderboard.handle(c.req.raw);
256+
return answer ?? next();
211257
});
212-
app.use('*', x402Gateway(crawlGateway));
213258

214259
/*
215260
* Security headers, on everything.

‎apps/web/src/lib/leaderboard.js‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { createLeaderboard, projectionStore } from '@profullstack/leaderboard';
2+
import { brand, config } from '@tipoff/config';
3+
import { sql } from '@tipoff/db';
4+
5+
/**
6+
* The public board over the crawler paywall.
7+
*
8+
* Two sides, never in one list. An agent that paid is a customer and is ranked
9+
* by money; an agent that keeps hitting the wall without paying is demand, and
10+
* is ranked by volume. Putting them in one table would say those are the same
11+
* kind of fact, and the difference between them is the entire business.
12+
*
13+
* Both tables are written by the gateway hooks in app.js, so the board keeps no
14+
* copy of its own and cannot drift from them. Badges are the exception: they
15+
* are awarded rather than derived.
16+
*/
17+
18+
const ms = (v) => (v instanceof Date ? v.getTime() : new Date(v).getTime());
19+
20+
const badges = {
21+
async awardBadge(player, badge) {
22+
const rows = await sql`
23+
insert into leaderboard_badges (player, badge) values (${player}, ${badge})
24+
on conflict (player, badge) do nothing
25+
returning player`;
26+
return rows.length > 0;
27+
},
28+
async badges() {
29+
const rows = await sql`select player, badge, awarded_at from leaderboard_badges`;
30+
const out = {};
31+
for (const r of rows) {
32+
out[r.player] ??= {};
33+
out[r.player][r.badge] = ms(r.awarded_at);
34+
}
35+
return out;
36+
},
37+
};
38+
39+
const KNOWN_AGENTS = [
40+
'meta-externalagent',
41+
'GPTBot',
42+
'ClaudeBot',
43+
'anthropic-ai',
44+
'CCBot',
45+
'Bytespider',
46+
'Applebot',
47+
'FacebookBot',
48+
'Lightpanda',
49+
'PerplexityBot',
50+
'Google-Extended',
51+
];
52+
53+
/**
54+
* The name a reader recognises. An agent that says nothing useful is grouped
55+
* under one honest label rather than given a row per random string, which
56+
* would fill the board with noise and bury the agents that matter.
57+
*/
58+
export function agentName(userAgent) {
59+
const ua = String(userAgent ?? '');
60+
const known = KNOWN_AGENTS.find((k) => ua.toLowerCase().includes(k.toLowerCase()));
61+
if (known) return known;
62+
const token = ua.match(/([A-Za-z][\w.-]{2,30})\/[\d.]+/)?.[1];
63+
return token ?? 'unidentified';
64+
}
65+
66+
const shortWallet = (p) => (p.length > 14 ? `${p.slice(0, 6)}…${p.slice(-4)}` : p);
67+
68+
async function events({ since }) {
69+
const from = new Date(since || 0);
70+
const [sales, demand] = await Promise.all([
71+
sql`select payer, user_agent, total_cents, days, created_at
72+
from crawl_sales where created_at >= ${from}`,
73+
// crawl_demand.day is a DATE. Binding a JS Date here sends Postgres the
74+
// full "Thu Jan 01 1970 00:00:00 GMT+0000 (…)" string, which it refuses to
75+
// parse as a date, and the whole board 500s. Send the day itself.
76+
sql`select agent, day, hits from crawl_demand where day >= ${from.toISOString().slice(0, 10)}`,
77+
]);
78+
79+
const out = [];
80+
for (const s of sales) {
81+
const player = s.payer || agentName(s.user_agent);
82+
if (!player) continue;
83+
const at = ms(s.created_at);
84+
const name = agentName(s.user_agent) ?? shortWallet(String(player));
85+
const each = (metric, delta) => out.push({ player: String(player), name, metric, delta, at });
86+
each('spent', Number(s.total_cents) || 0);
87+
each('passes', 1);
88+
each('days', Number(s.days) || 1);
89+
}
90+
for (const d of demand) {
91+
// A day bucket lands at noon UTC, so it falls inside the day it describes
92+
// no matter which way a period boundary rounds.
93+
const at = ms(d.day) + 12 * 3600 * 1000;
94+
out.push({
95+
player: `ua:${d.agent}`,
96+
name: d.agent,
97+
metric: 'blocked',
98+
delta: Number(d.hits) || 0,
99+
at,
100+
});
101+
}
102+
return out;
103+
}
104+
105+
export const leaderboard = createLeaderboard({
106+
siteName: brand.name,
107+
siteUrl: config.siteUrl,
108+
basePath: '/leaderboard',
109+
store: projectionStore({ events, badges }),
110+
sides: { buy: 'Agents paying', use: 'Agents not paying' },
111+
boards: {
112+
spenders: {
113+
label: 'Biggest spenders',
114+
metric: 'spent',
115+
format: 'usd',
116+
unit: 'Spent',
117+
side: 'buy',
118+
actor: 'Agent',
119+
},
120+
passes: {
121+
label: 'Most passes bought',
122+
metric: 'passes',
123+
format: 'integer',
124+
unit: 'Passes',
125+
side: 'buy',
126+
actor: 'Agent',
127+
},
128+
days: {
129+
label: 'Most days of access',
130+
metric: 'days',
131+
format: 'integer',
132+
unit: 'Days',
133+
side: 'buy',
134+
actor: 'Agent',
135+
},
136+
blocked: {
137+
label: 'Most requests refused',
138+
metric: 'blocked',
139+
format: 'integer',
140+
unit: 'Refused',
141+
side: 'use',
142+
actor: 'Agent',
143+
},
144+
},
145+
// Nobody earns here: this site sells its own data rather than anyone else's.
146+
ladder: null,
147+
cacheMs: 60_000,
148+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
-- What the crawler paywall is actually doing, and who is on the other side.
2+
--
3+
-- The gateway has been answering 402 and selling day passes for a while, and
4+
-- none of it was written down: a sale existed only for as long as the response
5+
-- took to send. So there is no way to answer "what did the paywall earn this
6+
-- month" or "which agent is the customer", which are the only two questions
7+
-- that matter about it.
8+
--
9+
-- Two tables, because there are two facts. A sale is money. A demand is an
10+
-- agent hitting the wall and not paying, which is the pipeline, and there are
11+
-- thousands of those a day: it is counted per agent per day rather than stored
12+
-- per request, so the table stays small enough to leave on forever.
13+
create table if not exists crawl_sales (
14+
id bigserial primary key,
15+
payer text,
16+
ref text unique,
17+
days int not null default 1,
18+
price_cents int not null,
19+
total_cents int not null,
20+
currency text not null default 'USD',
21+
user_agent text,
22+
expires_at timestamptz,
23+
created_at timestamptz not null default now()
24+
);
25+
create index if not exists crawl_sales_payer_idx on crawl_sales (lower(payer), created_at desc);
26+
27+
create table if not exists crawl_demand (
28+
agent text not null,
29+
day date not null,
30+
hits bigint not null default 0,
31+
primary key (agent, day)
32+
);
33+
34+
-- Badges for the public board. Everything else it shows is projected out of
35+
-- the two tables above; a badge is awarded at a moment and then kept, and that
36+
-- fact lives nowhere else.
37+
create table if not exists leaderboard_badges (
38+
player text not null,
39+
badge text not null,
40+
awarded_at timestamptz not null default now(),
41+
primary key (player, badge)
42+
);

‎packages/db/src/queries.js‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3429,3 +3429,49 @@ export async function sharedSiriusXmOwner(ownerId, { viewerId = null } = {}) {
34293429
`;
34303430
return row ?? null;
34313431
}
3432+
3433+
/*
3434+
* The crawler paywall's own books.
3435+
*
3436+
* The gateway has been answering 402 and selling day passes without writing any
3437+
* of it down, so neither the revenue nor the customer was knowable after the
3438+
* response was sent. These two record it.
3439+
*/
3440+
3441+
/**
3442+
* One sale. `ref` is the payment reference and is unique, so a settlement
3443+
* delivered twice books once rather than doubling the day's takings.
3444+
*/
3445+
export async function recordCrawlSale({
3446+
payer = null,
3447+
ref = null,
3448+
days = 1,
3449+
priceCents = 0,
3450+
totalCents = 0,
3451+
currency = 'USD',
3452+
userAgent = null,
3453+
expiresAt = null,
3454+
}) {
3455+
const [row] = await sql`
3456+
insert into crawl_sales (payer, ref, days, price_cents, total_cents, currency, user_agent, expires_at)
3457+
values (${payer}, ${ref}, ${days}, ${priceCents}, ${totalCents}, ${currency}, ${userAgent},
3458+
${expiresAt ? new Date(expiresAt) : null})
3459+
on conflict (ref) do nothing
3460+
returning id`;
3461+
return row ?? null;
3462+
}
3463+
3464+
/**
3465+
* One refused request, counted per agent per day.
3466+
*
3467+
* There are thousands of these a day and none of them is interesting on its
3468+
* own, so the row is a counter rather than an event. Cheap enough to call on
3469+
* every 402 and small enough to keep forever.
3470+
*/
3471+
export async function recordCrawlDemand(agent, at = new Date()) {
3472+
if (!agent) return;
3473+
await sql`
3474+
insert into crawl_demand (agent, day, hits)
3475+
values (${agent}, ${new Date(at).toISOString().slice(0, 10)}, 1)
3476+
on conflict (agent, day) do update set hits = crawl_demand.hits + 1`;
3477+
}

0 commit comments

Comments
 (0)