Skip to content

Commit 8f9a95e

Browse files
ralyodioclaude
andauthored
A house crawler gets its own rung on the two profile routes (#195)
nichedb.dev pulls every author's OpenProfile.md into its people collection and names itself in its user agent. On the free rung (120 a minute, 600 an hour) a backfill of two hundred thousand authors takes days. A request that names a house crawler AND asks for /api/openprofiles or an author's openprofile.md is placed on a `house` rung: 600 a minute, 36,000 an hour. Every other route and every other caller is unchanged, and a session or a key still places the caller by what it carries. Claude-Session: https://claude.ai/code/session_01S7yeJUHGxA4P5N74xnsRPQ Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent fd7743c commit 8f9a95e

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,45 @@ export const TIERS = {
117117
* unchanged here.
118118
*/
119119
pass: { name: 'pass', burst: envInt('TIER_SPONSOR_BURST', 2_000), hourly: SPONSOR_HOURLY },
120+
/**
121+
* A house crawler on the profile routes.
122+
*
123+
* nichedb.dev pulls every author's OpenProfile.md into its people collection
124+
* (nichedb.dev/c/profiles). It names itself in its user agent and wants two
125+
* things only: the listing at /api/openprofiles and the files the listing
126+
* names. Under the free rung (120 a minute, 600 an hour) a backfill of two
127+
* hundred thousand authors takes days; at 600 a minute it takes hours. The
128+
* rung exists for those two routes and no other, so a stranger wearing the
129+
* string gains a signed-in reader's pace on the two cheapest routes on the
130+
* site and nothing anywhere else.
131+
*/
132+
house: { name: 'house', burst: envInt('TIER_HOUSE_BURST', 600), hourly: envInt('TIER_HOUSE_HOURLY', 36_000) },
120133
};
121134

135+
/** User agent prefixes of the house crawlers, as they identify themselves. */
136+
export const HOUSE_CRAWLERS = ['niche-db/'];
137+
138+
/** The routes the house rung applies to, and nothing else. */
139+
const HOUSE_ROUTES = [/^\/api\/openprofiles$/, /^\/authors\/[^/]+\/openprofile\.md$/];
140+
141+
/**
142+
* Whether this request is a house crawler asking for a profile route.
143+
*
144+
* @param {Request} request
145+
* @returns {boolean}
146+
*/
147+
export function isHouseCrawl(request) {
148+
const ua = (request.headers.get('user-agent') ?? '').trim();
149+
if (!HOUSE_CRAWLERS.some((prefix) => ua.startsWith(prefix))) return false;
150+
let pathname;
151+
try {
152+
pathname = /** @type {any} */ (request).nextUrl?.pathname ?? new URL(request.url).pathname;
153+
} catch {
154+
return false;
155+
}
156+
return HOUSE_ROUTES.some((re) => re.test(pathname));
157+
}
158+
122159
/**
123160
* Validated sponsor keys, by hash, with the time they were checked.
124161
*
@@ -204,6 +241,11 @@ export function tierFor(request) {
204241

205242
if (/(^|;\s*)rsa_session=[^;]/.test(request.headers.get('cookie') ?? '')) return TIERS.session;
206243

244+
// Below the cookie and the key on purpose: a house crawler carrying either
245+
// is placed by what it carries, and the string only matters when it is all
246+
// the request has to say for itself.
247+
if (isHouseCrawl(request)) return TIERS.house;
248+
207249
return TIERS.anon;
208250
}
209251

‎apps/web/test/tiers.test.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,25 @@ test('the tiers are separately metered, so one does not spend another', () => {
219219
function hashOf(token) {
220220
return hashToken(token);
221221
}
222+
223+
test('a house crawler gets the house rung on the two profile routes and nowhere else', () => {
224+
resetTierCache();
225+
const ua = 'niche-db/0.1 (+https://nichedb.dev)';
226+
const at = (path, agent = ua) =>
227+
tierFor(new Request(`https://rssamplifier.com${path}`, { headers: agent ? { 'user-agent': agent } : {} })).name;
228+
assert.equal(at('/api/openprofiles?limit=500'), 'house');
229+
assert.equal(at('/authors/ada-lovelace/openprofile.md'), 'house');
230+
// Same crawler, any other route: free, as before.
231+
assert.equal(at('/authors/ada-lovelace'), 'anon');
232+
assert.equal(at('/api/authors'), 'anon');
233+
assert.equal(at('/topics'), 'anon');
234+
// Same routes, any other caller: free, as before.
235+
assert.equal(at('/api/openprofiles', 'curl/8.0'), 'anon');
236+
assert.equal(at('/api/openprofiles', 'Mozilla/5.0 niche-db/0.1'), 'anon');
237+
// A session or a key still places the caller by what it carries.
238+
const r = new Request('https://rssamplifier.com/api/openprofiles', {
239+
headers: { 'user-agent': ua, cookie: 'rsa_session=abc' },
240+
});
241+
assert.equal(tierFor(r).name, 'session');
242+
assert.ok(TIERS.house.burst >= 600, 'the house rung is at least 600 a minute');
243+
});

0 commit comments

Comments
 (0)