Files

88 lines
4.1 KiB
JavaScript

'use strict';
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const { SlowHttp } = require('../lib/http');
const QUERY = `query ($search: String) {
Media(search: $search, type: MANGA, format: MANGA) {
id idMal format title { romaji english native } synonyms
staff(perPage: 20) { edges { role node { name { full } } } }
status startDate { year } genres description(asHtml: false)
countryOfOrigin siteUrl coverImage { extraLarge }
relations { edges { relationType node { id type format title { romaji english native } siteUrl } } }
}
}`;
const QUERY_BY_ID = `query ($id: Int) {
Media(id: $id, type: MANGA, format: MANGA) {
id idMal format title { romaji english native } synonyms
staff(perPage: 20) { edges { role node { name { full } } } }
status startDate { year } genres description(asHtml: false)
countryOfOrigin siteUrl coverImage { extraLarge }
relations { edges { relationType node { id type format title { romaji english native } siteUrl } } }
}
}`;
function stripHtml(value) {
return String(value || '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '').trim();
}
function metadataFromMedia(media) {
return {
title: media.title.english || media.title.romaji,
altTitles: [...new Set([media.title.romaji, media.title.native, ...(media.synonyms || [])].filter(Boolean))],
authors: [...new Set((media.staff?.edges || [])
.filter(edge => /^(?:original story|story(?: & art)?|art|illustration)$/i.test(edge.role || ''))
.map(edge => edge.node?.name?.full).filter(Boolean))],
status: media.status === 'FINISHED' ? 'completed' : media.status === 'RELEASING' ? 'ongoing' : 'unknown',
year: media.startDate?.year || undefined,
tags: media.genres || [], description: stripHtml(media.description),
'x-anilist': { id: media.id, idMal: media.idMal, format: media.format, url: media.siteUrl, coverUrl: media.coverImage?.extraLarge,
relations: (media.relations?.edges || []).filter(edge => edge.node?.type === 'MANGA').map(edge => ({ id: edge.node.id, type: edge.relationType,
format: edge.node.format, title: edge.node.title?.english || edge.node.title?.romaji || edge.node.title?.native, url: edge.node.siteUrl })) },
};
}
async function lookup(query, variables, cacheKey, options = {}) {
const cacheDir = path.resolve(options.cacheDir || process.env.METADATA_CACHE || path.join(__dirname, '..', '.cache', 'anilist'));
await fs.mkdir(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, `${cacheKey}.json`);
try {
const stat = await fs.stat(cacheFile);
const cached = JSON.parse(await fs.readFile(cacheFile, 'utf8'));
if (cached._cacheVersion === 4 && Date.now() - stat.mtimeMs < (options.maxAgeMs || 30 * 86400000)) return cached.metadata;
} catch {}
const http = new SlowHttp({ minMs: 0, maxMs: 0, maxRetries: 1 });
const response = await fetch('https://graphql.anilist.co', {
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': http.userAgent },
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(30000),
});
if (!response.ok) throw Object.assign(new Error(`AniList HTTP ${response.status}`), { status: response.status });
const body = await response.json();
if (body.errors?.length) throw new Error(body.errors[0].message);
const metadata = metadataFromMedia(body.data.Media);
const temporary = `${cacheFile}.${crypto.randomUUID()}.tmp`;
try {
await fs.writeFile(temporary, JSON.stringify({ _cacheVersion: 4, metadata }, null, 2) + '\n');
await fs.rename(temporary, cacheFile);
} finally { await fs.rm(temporary, { force: true }); }
return metadata;
}
async function lookupAniList(search, options = {}) {
const key = Buffer.from(search).toString('base64url');
return lookup(QUERY, { search }, key, options);
}
async function lookupAniListById(id, options = {}) {
const value = Number(id);
if (!Number.isInteger(value) || value < 1) throw new Error('AniList id must be a positive integer');
return lookup(QUERY_BY_ID, { id: value }, `id-${value}`, options);
}
module.exports = { lookupAniList, lookupAniListById, metadataFromMedia };