Consolidate MangaCircus app, scraper, and Compose baseline
This commit is contained in:
1 parent
cfb2d79103
commit
eef4ffa07c
103 files changed
+9316
-172
No files matched your search
@@ -0,0 +1,87 @@
|
||||
'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 };
|
||||
Reference in new issue
Block a user