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,146 @@
|
||||
'use strict';
|
||||
|
||||
// Polite AniList GraphQL client for discovery seeds. Sequential, rate limited, stops on access controls.
|
||||
|
||||
const STAFF_ROLE = /^(?:original story|story(?: & art)?|art|illustration)$/i;
|
||||
const USER_AGENT = 'MangaCircus-Discovery/1.0';
|
||||
|
||||
// Stop: the whole run should end. Skip: this one request is lost, carry on.
|
||||
class AniListStop extends Error {
|
||||
constructor(reason, message) { super(message || `AniList stop: ${reason}`); this.name = 'AniListStop'; this.reason = reason; }
|
||||
}
|
||||
class AniListSkip extends Error {
|
||||
constructor(reason, message) { super(message || `AniList skip: ${reason}`); this.name = 'AniListSkip'; this.reason = reason; }
|
||||
}
|
||||
|
||||
function createLimiter({ perMinute = 25, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
||||
const interval = 60000 / perMinute;
|
||||
let nextAllowed = 0;
|
||||
let tail = Promise.resolve();
|
||||
// Chained so concurrent callers queue up instead of all reading the same clock.
|
||||
function wait() {
|
||||
const turn = tail.then(async () => {
|
||||
await sleep(Math.max(0, nextAllowed - now()));
|
||||
nextAllowed = now() + interval;
|
||||
});
|
||||
tail = turn.catch(() => {});
|
||||
return turn;
|
||||
}
|
||||
return { wait };
|
||||
}
|
||||
|
||||
function normalizeMedia(media) {
|
||||
if (!media || (media.format !== 'MANGA' && media.format !== 'ONE_SHOT')) return null;
|
||||
const title = media.title || {};
|
||||
const names = [title.english || title.romaji || title.native, title.romaji, title.native, ...(media.synonyms || [])].filter(Boolean);
|
||||
return {
|
||||
anilistId: media.id,
|
||||
title: names[0] || '',
|
||||
altTitles: [...new Set(names.slice(1))].filter(name => name !== names[0]),
|
||||
format: media.format,
|
||||
isAdult: !!media.isAdult,
|
||||
status: media.status === 'RELEASING' ? 'ongoing' : media.status === 'FINISHED' ? 'completed' : 'unknown',
|
||||
year: media.startDate?.year ?? null,
|
||||
averageScore: media.averageScore ?? null,
|
||||
popularity: media.popularity ?? null,
|
||||
trending: media.trending ?? null,
|
||||
tags: [
|
||||
...(media.genres || []).map(name => ({ name, rank: 100, kind: 'genre', isAdult: false })),
|
||||
...(media.tags || []).map(tag => ({ name: tag.name, rank: tag.rank, kind: 'tag', isAdult: !!tag.isAdult }))],
|
||||
authors: (media.staff?.edges || [])
|
||||
.filter(edge => STAFF_ROLE.test(edge.role || '') && edge.node?.name?.full)
|
||||
.map(edge => ({ staffId: edge.node.id, name: edge.node.name.full, role: edge.role })),
|
||||
coverUrl: media.coverImage?.extraLarge || null,
|
||||
siteUrl: media.siteUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
const MEDIA_FIELDS = `fragment MediaFields on Media {
|
||||
id format isAdult title { romaji english native } synonyms status startDate { year }
|
||||
averageScore popularity trending genres tags { name rank isAdult }
|
||||
staff(perPage: 20) { edges { role node { id name { full } } } }
|
||||
coverImage { extraLarge } siteUrl
|
||||
}`;
|
||||
|
||||
const QUERIES = {
|
||||
title: `query ($id: Int) { Media(id: $id, type: MANGA) { ...MediaFields } } ${MEDIA_FIELDS}`,
|
||||
recs: `query ($id: Int) { Media(id: $id, type: MANGA) {
|
||||
recommendations(perPage: 25, sort: RATING_DESC) { nodes { rating mediaRecommendation { ...MediaFields } } } } } ${MEDIA_FIELDS}`,
|
||||
creator: `query ($id: Int) { Staff(id: $id) {
|
||||
staffMedia(type: MANGA, perPage: 25, sort: POPULARITY_DESC) { edges { staffRole node { ...MediaFields } } } } } ${MEDIA_FIELDS}`,
|
||||
tagTopGenre: `query ($genre_in: [String]) { Page(perPage: 25) {
|
||||
media(type: MANGA, genre_in: $genre_in, popularity_greater: 1000, sort: POPULARITY_DESC) { ...MediaFields } } } ${MEDIA_FIELDS}`,
|
||||
tagTopTag: `query ($tag_in: [String]) { Page(perPage: 25) {
|
||||
media(type: MANGA, tag_in: $tag_in, popularity_greater: 1000, sort: POPULARITY_DESC) { ...MediaFields } } } ${MEDIA_FIELDS}`,
|
||||
fresh: `query ($startDate_greater: FuzzyDateInt, $perPage: Int) { Page(perPage: $perPage) {
|
||||
media(type: MANGA, status: RELEASING, startDate_greater: $startDate_greater, sort: TRENDING_DESC) { ...MediaFields } } } ${MEDIA_FIELDS}`,
|
||||
};
|
||||
|
||||
const isTimeout = error => error && (error.name === 'TimeoutError' || error.name === 'AbortError');
|
||||
const hasId = media => media && Number.isInteger(media.id);
|
||||
const need = (value, check = Array.isArray) => { if (!check(value)) throw new AniListSkip('invalid', 'AniList response is missing data'); return value; };
|
||||
|
||||
function createClient({ endpoint = process.env.DISCOVERY_ANILIST_URL || 'https://graphql.anilist.co', limiter = createLimiter(),
|
||||
fetchImpl = fetch, timeoutMs = 30000, today = () => new Date() } = {}) {
|
||||
async function post(query, variables) {
|
||||
await limiter.wait();
|
||||
let response, text;
|
||||
try {
|
||||
response = await fetchImpl(endpoint, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': USER_AGENT },
|
||||
body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (response.status === 429) throw new AniListStop('rate-limited');
|
||||
if (response.status === 401 || response.status === 403) throw new AniListStop('forbidden');
|
||||
if (response.status >= 500) throw new AniListSkip('upstream', `AniList HTTP ${response.status}`);
|
||||
text = await response.text();
|
||||
} catch (error) {
|
||||
if (error instanceof AniListStop || error instanceof AniListSkip) throw error;
|
||||
if (isTimeout(error)) throw new AniListSkip('timeout');
|
||||
throw new AniListSkip('upstream', `AniList request failed: ${error.message}`);
|
||||
}
|
||||
// An HTML or non-JSON page means a bot-check in front of the API; never work around it.
|
||||
if (/text\/html/i.test(response.headers.get('content-type') || '')) throw new AniListStop('challenge');
|
||||
let body;
|
||||
try { body = JSON.parse(text); } catch { throw new AniListStop('challenge'); }
|
||||
if (!response.ok || !body || typeof body !== 'object') throw new AniListSkip('invalid', `AniList HTTP ${response.status}`);
|
||||
return body.data || {};
|
||||
}
|
||||
|
||||
const records = (list, pick) => need(list).filter(hasId).map(item => ({ item, record: normalizeMedia(pick(item)) })).filter(entry => entry.record);
|
||||
const mediaOf = data => { need(data.Media, value => value && typeof value === 'object'); return data.Media; };
|
||||
|
||||
return {
|
||||
async title(id) {
|
||||
const media = mediaOf(await post(QUERIES.title, { id }));
|
||||
if (!hasId(media)) throw new AniListSkip('invalid');
|
||||
return normalizeMedia(media);
|
||||
},
|
||||
async recs(id) {
|
||||
const nodes = need(mediaOf(await post(QUERIES.recs, { id })).recommendations?.nodes);
|
||||
return nodes.filter(node => node.rating > 0 && hasId(node.mediaRecommendation))
|
||||
.map(node => ({ anilistId: node.mediaRecommendation.id, rating: node.rating, record: normalizeMedia(node.mediaRecommendation) }))
|
||||
.filter(entry => entry.record);
|
||||
},
|
||||
async creator(staffId) {
|
||||
const data = await post(QUERIES.creator, { id: staffId });
|
||||
const edges = need(need(data.Staff, value => value && typeof value === 'object').staffMedia?.edges);
|
||||
return edges.filter(edge => hasId(edge.node))
|
||||
.map(edge => ({ anilistId: edge.node.id, role: edge.staffRole, record: normalizeMedia(edge.node) }))
|
||||
.filter(entry => entry.record);
|
||||
},
|
||||
async tagTop({ name, kind }) {
|
||||
const genre = kind === 'genre';
|
||||
const data = await post(genre ? QUERIES.tagTopGenre : QUERIES.tagTopTag, genre ? { genre_in: [name] } : { tag_in: [name] });
|
||||
return records(data.Page?.media, media => media).map(({ item, record }) => ({ anilistId: item.id, record }));
|
||||
},
|
||||
async fresh() {
|
||||
const date = today();
|
||||
// AniList FuzzyDateInt: YYYYMMDD, two years back.
|
||||
const startDate = (date.getUTCFullYear() - 2) * 10000 + (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
|
||||
const data = await post(QUERIES.fresh, { startDate_greater: startDate, perPage: 50 });
|
||||
return records(data.Page?.media, media => media).map(({ item, record }) => ({ anilistId: item.id, record }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLimiter, createClient, normalizeMedia, AniListStop, AniListSkip };
|
||||
Reference in new issue
Block a user