Rate-limited AniList client for discovery seeds (BL-0051)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aad2761dc9
commit
461a37e924
8 files changed
+316
No files matched your search
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createLimiter, createClient, normalizeMedia, AniListStop, AniListSkip } = require('../discovery/anilist');
|
||||
|
||||
const fixture = name => JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'anilist', `${name}.json`), 'utf8'));
|
||||
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
const noWait = { wait: async () => {} };
|
||||
|
||||
function stub(respond) {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, init) => { calls.push({ url, init, body: JSON.parse(init.body) }); return respond(); };
|
||||
return { calls, client: createClient({ endpoint: 'http://localhost/graphql', limiter: noWait, fetchImpl, today: () => new Date('2026-10-25') }) };
|
||||
}
|
||||
const clientFor = respond => stub(respond).client;
|
||||
|
||||
test('normalizeMedia maps fields and drops novels', () => {
|
||||
const media = fixture('title').data.Media;
|
||||
assert.deepEqual(normalizeMedia(media), {
|
||||
anilistId: 101, title: 'Moon Traveller', altTitles: ['Tsuki no Tabibito', '月の旅人', 'Lunar Wanderer'],
|
||||
format: 'MANGA', isAdult: false, status: 'ongoing', year: 2019, averageScore: 81, popularity: 5400, trending: 37,
|
||||
tags: [
|
||||
{ name: 'Adventure', rank: 100, kind: 'genre', isAdult: false },
|
||||
{ name: 'Fantasy', rank: 100, kind: 'genre', isAdult: false },
|
||||
{ name: 'Travel', rank: 88, kind: 'tag', isAdult: false },
|
||||
{ name: 'Nudity', rank: 40, kind: 'tag', isAdult: true }],
|
||||
authors: [{ staffId: 900, name: 'Aoi Example', role: 'Story & Art' }],
|
||||
coverUrl: 'https://example.invalid/cover/101.jpg', siteUrl: 'https://anilist.co/manga/101' });
|
||||
assert.equal(normalizeMedia({ ...media, format: 'NOVEL' }), null);
|
||||
assert.equal(normalizeMedia(fixture('novel').data.Media), null);
|
||||
assert.equal(normalizeMedia({ ...media, status: 'HIATUS' }).status, 'unknown');
|
||||
});
|
||||
|
||||
test('title returns a record and sends the fixed headers', async () => {
|
||||
const { client, calls } = stub(() => json(fixture('title')));
|
||||
const record = await client.title(101);
|
||||
assert.equal(record.anilistId, 101);
|
||||
assert.equal(calls[0].body.variables.id, 101);
|
||||
assert.equal(calls[0].init.headers['user-agent'], 'MangaCircus-Discovery/1.0');
|
||||
assert.equal(calls[0].init.headers['content-type'], 'application/json');
|
||||
});
|
||||
|
||||
test('recs keeps positive ratings and manga formats', async () => {
|
||||
const out = await clientFor(() => json(fixture('recs'))).recs(101);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].anilistId, 201);
|
||||
assert.equal(out[0].rating, 12);
|
||||
assert.equal(out[0].record.title, 'Sky Town');
|
||||
});
|
||||
|
||||
test('creator returns roles and drops novels', async () => {
|
||||
const out = await clientFor(() => json(fixture('creator'))).creator(900);
|
||||
assert.deepEqual(out.map(e => [e.anilistId, e.role]), [[301, 'Story & Art']]);
|
||||
});
|
||||
|
||||
test('fresh query sends startDate two years back', async () => {
|
||||
const { client, calls } = stub(() => json(fixture('fresh')));
|
||||
const out = await client.fresh();
|
||||
assert.equal(calls[0].body.variables.startDate_greater, 20241025);
|
||||
assert.equal(calls[0].body.variables.perPage, 50);
|
||||
assert.match(calls[0].body.query, /TRENDING_DESC/);
|
||||
assert.deepEqual(out.map(e => e.anilistId), [501]);
|
||||
});
|
||||
|
||||
test('tagTop uses genre_in for genres and tag_in for tags', async () => {
|
||||
const g = stub(() => json(fixture('tagtop')));
|
||||
const out = await g.client.tagTop({ name: 'Mystery', kind: 'genre' });
|
||||
assert.deepEqual(g.calls[0].body.variables.genre_in, ['Mystery']);
|
||||
assert.equal(g.calls[0].body.variables.tag_in, undefined);
|
||||
assert.match(g.calls[0].body.query, /popularity_greater: 1000/);
|
||||
assert.deepEqual(out.map(e => e.anilistId), [401]);
|
||||
const t = stub(() => json(fixture('tagtop')));
|
||||
await t.client.tagTop({ name: 'Detective', kind: 'tag' });
|
||||
assert.deepEqual(t.calls[0].body.variables.tag_in, ['Detective']);
|
||||
assert.equal(t.calls[0].body.variables.genre_in, undefined);
|
||||
});
|
||||
|
||||
test('429 stops, 403 stops, html body is a challenge', async () => {
|
||||
await assert.rejects(clientFor(() => json({}, 429)).title(1), e => e instanceof AniListStop && e.reason === 'rate-limited');
|
||||
await assert.rejects(clientFor(() => json({}, 403)).title(1), e => e instanceof AniListStop && e.reason === 'forbidden');
|
||||
await assert.rejects(clientFor(() => json({}, 401)).title(1), e => e instanceof AniListStop && e.reason === 'forbidden');
|
||||
const html = () => new Response('<html>checking your browser</html>', { status: 200, headers: { 'content-type': 'text/html' } });
|
||||
await assert.rejects(clientFor(html).title(1), e => e instanceof AniListStop && e.reason === 'challenge');
|
||||
const garbage = () => new Response('not json', { status: 200 });
|
||||
await assert.rejects(clientFor(garbage).title(1), e => e instanceof AniListStop && e.reason === 'challenge');
|
||||
});
|
||||
|
||||
test('5xx and timeout skip', async () => {
|
||||
await assert.rejects(clientFor(() => json({}, 503)).title(1), e => e instanceof AniListSkip && e.reason === 'upstream');
|
||||
const timeout = () => { throw Object.assign(new Error('timed out'), { name: 'TimeoutError' }); };
|
||||
await assert.rejects(clientFor(timeout).title(1), e => e instanceof AniListSkip && e.reason === 'timeout');
|
||||
const abort = () => { throw Object.assign(new Error('aborted'), { name: 'AbortError' }); };
|
||||
await assert.rejects(clientFor(abort).title(1), e => e instanceof AniListSkip && e.reason === 'timeout');
|
||||
});
|
||||
|
||||
test('missing data is invalid', async () => {
|
||||
const invalid = e => e instanceof AniListSkip && e.reason === 'invalid';
|
||||
await assert.rejects(clientFor(() => json({ data: {} })).title(1), invalid);
|
||||
await assert.rejects(clientFor(() => json({ data: { Page: {} } })).fresh(), invalid);
|
||||
await assert.rejects(clientFor(() => json({ data: {} })).creator(1), invalid);
|
||||
await assert.rejects(clientFor(() => json({ data: { Media: { ...fixture('title').data.Media, id: 'x' } } })).title(1), invalid);
|
||||
});
|
||||
|
||||
test('limiter spaces calls 2400 ms apart', async () => {
|
||||
let clock = 1000;
|
||||
const sleeps = [];
|
||||
const limiter = createLimiter({ perMinute: 25, now: () => clock, sleep: async ms => { sleeps.push(ms); clock += ms; } });
|
||||
await Promise.all([limiter.wait(), limiter.wait(), limiter.wait()]);
|
||||
assert.deepEqual(sleeps, [0, 2400, 2400]);
|
||||
});
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{"data":{"Staff":{"staffMedia":{"edges":[
|
||||
{"staffRole":"Story & Art","node":{"id":301,"format":"MANGA","isAdult":false,
|
||||
"title":{"romaji":"Hoshi no Uta","english":"Star Song","native":null},"synonyms":[],
|
||||
"status":"FINISHED","startDate":{"year":2018},"averageScore":79,"popularity":2500,"trending":3,
|
||||
"genres":["Fantasy"],"tags":[],"staff":{"edges":[]},"coverImage":{"extraLarge":null},"siteUrl":null}},
|
||||
{"staffRole":"Original Story","node":{"id":777,"format":"NOVEL","isAdult":false,
|
||||
"title":{"romaji":"Hon no Tabi","english":null,"native":null},"synonyms":[],
|
||||
"status":"FINISHED","startDate":{"year":2010},"averageScore":70,"popularity":1200,"trending":1,
|
||||
"genres":[],"tags":[],"staff":{"edges":[]},"coverImage":{"extraLarge":null},"siteUrl":null}}
|
||||
]}}}}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{"data":{"Page":{"media":[
|
||||
{"id":501,"format":"MANGA","isAdult":false,
|
||||
"title":{"romaji":"Atarashii Kaze","english":"New Wind","native":null},"synonyms":[],
|
||||
"status":"RELEASING","startDate":{"year":2025},"averageScore":72,"popularity":2100,"trending":150,
|
||||
"genres":["Sports"],"tags":[],"staff":{"edges":[]},"coverImage":{"extraLarge":null},"siteUrl":null}
|
||||
]}}}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{"data":{"Media":{"id":777,"format":"NOVEL","isAdult":false,
|
||||
"title":{"romaji":"Hon no Tabi","english":null,"native":null},
|
||||
"synonyms":[],"status":"FINISHED","startDate":{"year":2010},
|
||||
"averageScore":70,"popularity":1200,"trending":1,"genres":[],"tags":[],
|
||||
"staff":{"edges":[]},"coverImage":{"extraLarge":null},"siteUrl":"https://anilist.co/manga/777"}}}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{"data":{"Media":{"recommendations":{"nodes":[
|
||||
{"rating":12,"mediaRecommendation":{"id":201,"format":"MANGA","isAdult":false,
|
||||
"title":{"romaji":"Sora no Machi","english":"Sky Town","native":null},"synonyms":[],
|
||||
"status":"FINISHED","startDate":{"year":2015},"averageScore":77,"popularity":3000,"trending":5,
|
||||
"genres":["Drama"],"tags":[],"staff":{"edges":[]},"coverImage":{"extraLarge":null},"siteUrl":"https://anilist.co/manga/201"}},
|
||||
{"rating":-2,"mediaRecommendation":{"id":202,"format":"MANGA","isAdult":false,
|
||||
"title":{"romaji":"Kuroi Mori","english":null,"native":null},"synonyms":[],
|
||||
"status":"HIATUS","startDate":{"year":2012},"averageScore":60,"popularity":900,"trending":0,
|
||||
"genres":[],"tags":[],"staff":{"edges":[]},"coverImage":{"extraLarge":null},"siteUrl":null}},
|
||||
{"rating":9,"mediaRecommendation":{"id":777,"format":"NOVEL","isAdult":false,
|
||||
"title":{"romaji":"Hon no Tabi","english":null,"native":null},"synonyms":[],
|
||||
"status":"FINISHED","startDate":{"year":2010},"averageScore":70,"popularity":1200,"trending":1,
|
||||
"genres":[],"tags":[],"staff":{"edges":[]},"coverImage":{"extraLarge":null},"siteUrl":null}}
|
||||
]}}}}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{"data":{"Page":{"media":[
|
||||
{"id":401,"format":"ONE_SHOT","isAdult":false,
|
||||
"title":{"romaji":"Ichi Wa","english":"One Tale","native":null},"synonyms":[],
|
||||
"status":"FINISHED","startDate":{"year":2020},"averageScore":74,"popularity":1800,"trending":2,
|
||||
"genres":["Mystery"],"tags":[{"name":"Detective","rank":90,"isAdult":false}],"staff":{"edges":[]},
|
||||
"coverImage":{"extraLarge":null},"siteUrl":null},
|
||||
{"id":777,"format":"NOVEL","isAdult":false,
|
||||
"title":{"romaji":"Hon no Tabi","english":null,"native":null},"synonyms":[],
|
||||
"status":"FINISHED","startDate":{"year":2010},"averageScore":70,"popularity":1200,"trending":1,
|
||||
"genres":[],"tags":[],"staff":{"edges":[]},"coverImage":{"extraLarge":null},"siteUrl":null}
|
||||
]}}}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{"data":{"Media":{"id":101,"format":"MANGA","isAdult":false,
|
||||
"title":{"romaji":"Tsuki no Tabibito","english":"Moon Traveller","native":"月の旅人"},
|
||||
"synonyms":["Lunar Wanderer"],"status":"RELEASING","startDate":{"year":2019},
|
||||
"averageScore":81,"popularity":5400,"trending":37,
|
||||
"genres":["Adventure","Fantasy"],
|
||||
"tags":[{"name":"Travel","rank":88,"isAdult":false},{"name":"Nudity","rank":40,"isAdult":true}],
|
||||
"staff":{"edges":[
|
||||
{"role":"Story & Art","node":{"id":900,"name":{"full":"Aoi Example"}}},
|
||||
{"role":"Translator","node":{"id":901,"name":{"full":"Not An Author"}}}]},
|
||||
"coverImage":{"extraLarge":"https://example.invalid/cover/101.jpg"},
|
||||
"siteUrl":"https://anilist.co/manga/101"}}}
|
||||
Reference in new issue
Block a user