88 lines
4.1 KiB
JavaScript
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 };
|