Files
MangaCircus/discovery/anilist.js
MaashkinoozheandClaude Opus 5.5 cd6a30a76b Discovery final review fixes: lean publish, robust scoring, cheap prune (BL-0051)
- Cache prune counts directory entries first and only reads files when over
  the cap; tests for cross-kind prune, schema-mismatch and corrupt entries.
- Scoring tolerates malformed manga.json and cached records (non-list tags,
  object or null relations, non-object items); a tag without a rank no longer
  passes the top-tag filter; authors without an integer AniList id are dropped.
- discovery.json publishes only candidates some feed shows; examined/clipped
  still describe the pool. Tests that asserted the published count equals the
  pool size now check the pool via candidatePool() and the published set
  against the feeds.
- The post-harvest rescore failure test now breaks discovery.json right before
  the publish (via a scraper stand-in) instead of relying on tags: 5, and a
  following successful harvest clears the stale lastError.
- Consent text names everything sent to AniList; paused label mentions admin
  harvests; the UI test asserts the XSS seed instead of skipping it.
- Slot-mix test proves the fresh/outside reservations matter.
- Docs: privacy note, fresh fallback reason, per-period cap, dry-run wording,
  published-candidate rule; plan/spec hygiene and spec status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:43:31 +01:00

166 lines
9.0 KiB
JavaScript

'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 };
}
const invalid = message => new AniListSkip('invalid', `AniList response is malformed: ${message}`);
const isObject = value => !!value && typeof value === 'object' && !Array.isArray(value);
const optionalArray = (value, what) => { if (value != null && !Array.isArray(value)) throw invalid(what); };
// Malformed input is a Skip('invalid'), never a plain TypeError from deep inside the mapping.
function assertShape(media) {
if (media.title != null && !isObject(media.title)) throw invalid('title');
for (const key of ['synonyms', 'genres', 'tags']) optionalArray(media[key], key);
if (media.staff != null && !isObject(media.staff)) throw invalid('staff');
optionalArray(media.staff?.edges, 'staff edges');
if ((media.tags || []).some(tag => !isObject(tag))) throw invalid('tags');
if ((media.staff?.edges || []).some(edge => !isObject(edge) || (edge.node != null && !isObject(edge.node)))) throw invalid('staff edges');
}
function normalizeMedia(media) {
if (!isObject(media) || !Number.isInteger(media.id)) throw invalid('media without integer id');
if (media.format !== 'MANGA' && media.format !== 'ONE_SHOT') return null;
assertShape(media);
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 || [])
// An author without an integer id could never match a creator seed.
.filter(edge => STAFF_ROLE.test(edge?.role || '') && edge.node?.name?.full && Number.isInteger(edge.node.id))
.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: SCORE_DESC) { ...MediaFields } } } ${MEDIA_FIELDS}`,
tagTopTag: `query ($tag_in: [String]) { Page(perPage: 25) {
media(type: MANGA, tag_in: $tag_in, popularity_greater: 1000, sort: SCORE_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 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),
redirect: 'error' }); // never follow a redirect to another host
if (response.status === 429) throw new AniListStop('rate-limited');
if (response.status === 401 || response.status === 403) throw new AniListStop('forbidden');
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, even on a 5xx; 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.status >= 500) throw new AniListSkip('upstream', `AniList HTTP ${response.status}`);
if (!response.ok || !body || typeof body !== 'object') throw new AniListSkip('invalid', `AniList HTTP ${response.status}`);
return body.data || {};
}
// Every item is normalized (and so validated) before any is dropped: one malformed item invalidates the response.
const mediaOf = data => need(data.Media, isObject);
const entries = (list, pick) => need(list).map(item => {
if (!isObject(item)) throw invalid('list item');
const media = pick(item);
return { item, media, record: normalizeMedia(media) };
}).filter(entry => entry.record);
return {
async title(id) {
return normalizeMedia(mediaOf(await post(QUERIES.title, { id })));
},
async recs(id) {
const nodes = mediaOf(await post(QUERIES.recs, { id })).recommendations?.nodes;
// AniList returns a null mediaRecommendation when the recommended media was removed: valid, just drop it.
const present = need(nodes).filter(node => !isObject(node) || node.mediaRecommendation !== null);
return entries(present, node => node.mediaRecommendation).filter(({ item }) => item.rating > 0)
.map(({ item, record }) => ({ anilistId: record.anilistId, rating: item.rating, record }));
},
async creator(staffId) {
const data = await post(QUERIES.creator, { id: staffId });
return entries(need(data.Staff, isObject).staffMedia?.edges, edge => edge.node)
.map(({ item, record }) => ({ anilistId: record.anilistId, role: item.staffRole, 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 entries(data.Page?.media, media => media).map(({ record }) => ({ anilistId: record.anilistId, 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 entries(data.Page?.media, media => media).map(({ record }) => ({ anilistId: record.anilistId, record }));
},
};
}
module.exports = { createLimiter, createClient, normalizeMedia, AniListStop, AniListSkip };