- Adaptateurs comic-vine et mangadex avec helper de fetch partagé, timeouts et fallback durcis sur les providers existants - Scoring des correspondances amélioré, normalisation de la date de publication, chaîne de résolution des providers étendue - Scanner : statuts par livre (scan/enrichissement) et page admin d'automatisation alignée Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
7.6 KiB
TypeScript
180 lines
7.6 KiB
TypeScript
import { Injectable } from "@nestjs/common";
|
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
|
import { extractSeriesVolume } from "../use-cases/extract-series-volume.js";
|
|
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
|
|
import { providerFetch } from "./provider-fetch.js";
|
|
|
|
@Injectable()
|
|
export class MangaDexProvider implements MetadataProvider {
|
|
readonly id = "mangadex" as const;
|
|
|
|
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
|
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
|
|
return matches[0] ?? null;
|
|
}
|
|
|
|
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
|
const titles = mangaDexTitleQueries(query.title);
|
|
const matches: Array<MetadataMatch & { mangaDexRank?: number }> = [];
|
|
const seen = new Map<string, number>();
|
|
for (const title of titles) {
|
|
for (const match of await searchManga(title, config)) {
|
|
const key = match.sourceId ?? `${match.title}|${match.author}`;
|
|
const ranked = { ...match, scoreTitle: title, mangaDexRank: mangaRank(query.title, title, match) };
|
|
const existingIndex = seen.get(key);
|
|
if (existingIndex === undefined) {
|
|
seen.set(key, matches.length);
|
|
matches.push(ranked);
|
|
continue;
|
|
}
|
|
if ((ranked.mangaDexRank ?? 0) > (matches[existingIndex]?.mangaDexRank ?? 0)) {
|
|
matches[existingIndex] = ranked;
|
|
}
|
|
}
|
|
}
|
|
return rankMatches(matches);
|
|
}
|
|
}
|
|
|
|
export class MangaDexProviderError extends Error {
|
|
constructor(
|
|
readonly code: "rate-limit" | "http",
|
|
readonly status: number,
|
|
message: string
|
|
) {
|
|
super(message);
|
|
this.name = "MangaDexProviderError";
|
|
}
|
|
}
|
|
|
|
async function searchManga(title: string, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
|
const url = new URL("https://api.mangadex.org/manga");
|
|
url.searchParams.set("title", title);
|
|
url.searchParams.set("limit", "10");
|
|
url.searchParams.set("includes[]", "cover_art");
|
|
url.searchParams.append("includes[]", "author");
|
|
url.searchParams.append("includes[]", "artist");
|
|
url.searchParams.set("contentRating[]", "safe");
|
|
url.searchParams.append("contentRating[]", "suggestive");
|
|
let response = await providerFetch("mangadex", url, {
|
|
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (MangaDex)" },
|
|
timeoutMs: 5000
|
|
});
|
|
if (response.status === 429) {
|
|
await sleep(retryDelayMs(response));
|
|
response = await providerFetch("mangadex", url, {
|
|
headers: { "User-Agent": "ReadaBook/0.1 self-hosted metadata provider (MangaDex)" },
|
|
timeoutMs: 5000
|
|
});
|
|
}
|
|
const data = (await parseMangaDexResponse(response)) as { data?: Array<Record<string, unknown>> };
|
|
return (data.data ?? []).map(mangaToMatch);
|
|
}
|
|
|
|
function retryDelayMs(response: Response): number {
|
|
const retryAfter = Number(response.headers.get("Retry-After"));
|
|
return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1000, 2000) : 250;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function parseMangaDexResponse(response: Response): Promise<unknown> {
|
|
const data = (await response.json().catch(() => ({}))) as { errors?: Array<{ detail?: string; title?: string }> };
|
|
if (response.ok) return data;
|
|
const message = data.errors?.map((error) => error.detail ?? error.title).filter(Boolean).join("; ") || `MangaDex HTTP ${response.status}`;
|
|
if (response.status === 429) throw new MangaDexProviderError("rate-limit", response.status, message);
|
|
throw new MangaDexProviderError("http", response.status, message);
|
|
}
|
|
|
|
function mangaToMatch(manga: Record<string, unknown>): MetadataMatch & { mangaDexRank?: number } {
|
|
const id = stringValue(manga.id);
|
|
const attributes = objectValue(manga.attributes);
|
|
const relationships = Array.isArray(manga.relationships) ? (manga.relationships as Array<Record<string, unknown>>) : [];
|
|
const cover = relationships.find((entry) => entry.type === "cover_art");
|
|
const coverFile = stringValue(objectValue(cover?.attributes).fileName);
|
|
return {
|
|
title: localizedText(attributes.title) ?? undefined,
|
|
author: relationshipNames(relationships),
|
|
description: localizedText(attributes.description),
|
|
publishedDate: normalizePublishedDate(stringValue(attributes.year)),
|
|
language: stringValue(attributes.originalLanguage),
|
|
coverUrl: id && coverFile ? `https://uploads.mangadex.org/covers/${id}/${coverFile}.512.jpg` : null,
|
|
sourceId: id
|
|
};
|
|
}
|
|
|
|
function mangaDexTitleQueries(title: string): string[] {
|
|
const cleaned = extractSeriesVolume(title).seriesTitle;
|
|
return [cleaned, ...mangaDexTitleAliases(cleaned)].filter(
|
|
(value, index, values): value is string => Boolean(value) && values.indexOf(value) === index
|
|
);
|
|
}
|
|
|
|
function mangaDexTitleAliases(title: string): string[] {
|
|
const normalized = normalizeTitle(title);
|
|
if (normalized === "demon slayer school days") return ["Demon Slayer Kimetsu Academy", "Kimetsu Academy"];
|
|
return [];
|
|
}
|
|
|
|
function mangaRank(originalTitle: string, searchedTitle: string, match: MetadataMatch): number {
|
|
const expectedVolume = volumeNumber(originalTitle);
|
|
let rank = tokenOverlap(searchedTitle, match.title ?? "") * 10;
|
|
if (expectedVolume) {
|
|
const candidateVolume = volumeNumber(match.title ?? "");
|
|
rank += candidateVolume === expectedVolume ? 4 : candidateVolume ? -2 : 0;
|
|
}
|
|
if (match.coverUrl) rank += 1;
|
|
if (match.description) rank += 1;
|
|
return rank;
|
|
}
|
|
|
|
function rankMatches(matches: Array<MetadataMatch & { mangaDexRank?: number }>): MetadataMatch[] {
|
|
return [...matches].sort((left, right) => (right.mangaDexRank ?? 0) - (left.mangaDexRank ?? 0)).map(({ mangaDexRank: _rank, ...match }) => match);
|
|
}
|
|
|
|
function volumeNumber(value: string): string | null {
|
|
return value.match(/\b(?:T|tome|vol(?:ume)?\.?)\s*0?(\d{1,3})\b/i)?.[1] ?? value.match(/\b0?(\d{1,3})\b/)?.[1] ?? null;
|
|
}
|
|
|
|
function tokenOverlap(left: string, right: string): number {
|
|
const leftTokens = new Set(normalizeTitle(left).split(" ").filter(Boolean));
|
|
const rightTokens = new Set(normalizeTitle(right).split(" ").filter(Boolean));
|
|
if (!leftTokens.size || !rightTokens.size) return 0;
|
|
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
|
|
}
|
|
|
|
function normalizeTitle(value: string): string {
|
|
return value
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function localizedText(value: unknown): string | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const entries = value as Record<string, unknown>;
|
|
return stringValue(entries.en) ?? stringValue(entries.fr) ?? Object.values(entries).map(stringValue).find(Boolean) ?? null;
|
|
}
|
|
|
|
function relationshipNames(relationships: Array<Record<string, unknown>>): string | null {
|
|
const names = relationships
|
|
.filter((entry) => entry.type === "author" || entry.type === "artist")
|
|
.map((entry) => stringValue(objectValue(entry.attributes).name))
|
|
.filter((entry): entry is string => Boolean(entry));
|
|
return names.length ? [...new Set(names)].join(", ") : null;
|
|
}
|
|
|
|
function objectValue(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function stringValue(value: unknown): string | null {
|
|
if (typeof value === "number") return String(value);
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|