feat(api,web): providers ComicVine + MangaDex et pilotage de l'enrichissement
- 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>
This commit is contained in:
@ -1,6 +1,8 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||
import { normalizePublishedDate } from "../use-cases/normalize-published-date.js";
|
||||
import { providerFetch } from "./provider-fetch.js";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleBooksProvider implements MetadataProvider {
|
||||
@ -12,54 +14,157 @@ export class GoogleBooksProvider implements MetadataProvider {
|
||||
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
const query = `isbn:${isbn}`;
|
||||
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("maxResults", "1");
|
||||
url.searchParams.set("printType", "books");
|
||||
if (config.apiKey) url.searchParams.set("key", config.apiKey);
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record<string, unknown> }> };
|
||||
const info = data.items?.[0]?.volumeInfo;
|
||||
if (!info) return null;
|
||||
return {
|
||||
title: stringValue(info.title) ?? undefined,
|
||||
author: arrayJoin(info.authors),
|
||||
description: stringValue(info.description),
|
||||
language: stringValue(info.language),
|
||||
publisher: stringValue(info.publisher),
|
||||
publishedDate: stringValue(info.publishedDate),
|
||||
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, lookup.identifiers.isbn13)
|
||||
};
|
||||
const matches = await this.searchVolumes(`isbn:${isbn}`, config, lookup.title, lookup.author, lookup.identifiers.isbn13);
|
||||
return rankMatches(lookup.title, matches)[0] ?? null;
|
||||
}
|
||||
|
||||
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||
const attempts = googleBookQueries(query);
|
||||
const matches: MetadataMatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const attempt of attempts) {
|
||||
for (const match of await this.searchVolumes(attempt, config, query.title, query.author, query.isbn ? toIsbn13(query.isbn) : null)) {
|
||||
const key = [match.sourceId, match.isbn, match.title, match.author].filter(Boolean).join("|");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
matches.push(match);
|
||||
}
|
||||
}
|
||||
return rankMatches(query.title, matches);
|
||||
}
|
||||
|
||||
private async searchVolumes(
|
||||
googleQuery: string,
|
||||
config: MetadataProviderConfig,
|
||||
originalTitle: string,
|
||||
originalAuthor: string | null,
|
||||
expectedIsbn13: string | null
|
||||
): Promise<MetadataMatch[]> {
|
||||
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
||||
url.searchParams.set("q", `intitle:${query.title}${query.author ? `+inauthor:${query.author}` : ""}`);
|
||||
url.searchParams.set("maxResults", "5");
|
||||
url.searchParams.set("q", googleQuery);
|
||||
url.searchParams.set("maxResults", "10");
|
||||
url.searchParams.set("printType", "books");
|
||||
if (config.apiKey) url.searchParams.set("key", config.apiKey);
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||
if (!response.ok) return [];
|
||||
const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record<string, unknown> }> };
|
||||
const response = await providerFetch(this.id, url, { timeoutMs: 4000 });
|
||||
const data = (await parseGoogleResponse(response)) as { items?: Array<{ id?: string; volumeInfo?: Record<string, unknown> }> };
|
||||
return (data.items ?? [])
|
||||
.map((item) => item.volumeInfo)
|
||||
.filter((info): info is Record<string, unknown> => Boolean(info))
|
||||
.map((info) => ({
|
||||
.map((item) => ({ sourceId: item.id, info: item.volumeInfo }))
|
||||
.filter((item): item is { sourceId: string | undefined; info: Record<string, unknown> } => Boolean(item.info))
|
||||
.map(({ sourceId, info }) => ({
|
||||
title: stringValue(info.title) ?? undefined,
|
||||
author: arrayJoin(info.authors),
|
||||
description: stringValue(info.description),
|
||||
language: stringValue(info.language),
|
||||
publisher: stringValue(info.publisher),
|
||||
publishedDate: stringValue(info.publishedDate),
|
||||
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, query.isbn ? toIsbn13(query.isbn) : null)
|
||||
publishedDate: normalizePublishedDate(stringValue(info.publishedDate)),
|
||||
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, expectedIsbn13),
|
||||
coverUrl: coverUrl(info.imageLinks),
|
||||
sourceId,
|
||||
identifiers: { candidates: isbnCandidates(info.industryIdentifiers) },
|
||||
googleRank: googleRank(originalTitle, originalAuthor, info)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export class GoogleBooksProviderError extends Error {
|
||||
constructor(
|
||||
readonly code: "quota" | "auth" | "http",
|
||||
readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GoogleBooksProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
async function parseGoogleResponse(response: Response): Promise<unknown> {
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: { message?: string; status?: string } };
|
||||
if (response.ok) return data;
|
||||
const message = data.error?.message ?? `Google Books HTTP ${response.status}`;
|
||||
if (response.status === 429) throw new GoogleBooksProviderError("quota", response.status, message);
|
||||
if (response.status === 401 || response.status === 403) throw new GoogleBooksProviderError("auth", response.status, message);
|
||||
throw new GoogleBooksProviderError("http", response.status, message);
|
||||
}
|
||||
|
||||
function googleBookQueries(query: MetadataSearchQuery): string[] {
|
||||
const cleaned = cleanGoogleBooksTitle(query.title);
|
||||
const relaxed = relaxSeriesTitle(cleaned);
|
||||
return [
|
||||
query.isbn ? `isbn:${query.isbn}` : null,
|
||||
googleTitleQuery(cleaned, query.author, true),
|
||||
googleTitleQuery(cleaned, query.author, false),
|
||||
relaxed !== cleaned ? googleTitleQuery(relaxed, query.author, true) : null,
|
||||
relaxed !== cleaned ? googleTitleQuery(relaxed, null, false) : null,
|
||||
googleTitleQuery(cleaned, null, false)
|
||||
].filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function googleTitleQuery(title: string, author: string | null, quoted: boolean): string {
|
||||
const titlePart = quoted ? `intitle:"${title.replace(/"/g, " ")}"` : `intitle:${title}`;
|
||||
return author ? `${titlePart}+inauthor:${author}` : titlePart;
|
||||
}
|
||||
|
||||
function cleanGoogleBooksTitle(value: string): string {
|
||||
return value
|
||||
.replace(/\.[A-Za-z0-9]{2,5}$/g, " ")
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/\b(FRENCH|TRUEFRENCH|MULTI|CBZ|CBR|EPUB|PDF|eBook|ebook|scan|digital)\b/gi, " ")
|
||||
.replace(/\b(e?bdz|Paprika\+?|emuleCenter\.net)\b/gi, " ")
|
||||
.replace(/\bT(?:ome)?\s*0?(\d{1,3})\b/gi, " $1 ")
|
||||
.replace(/\bVol(?:ume)?\.?\s*0?(\d{1,3})\b/gi, " $1 ")
|
||||
.replace(/[+]+/g, " ")
|
||||
.replace(/\s+-\s+/g, " ")
|
||||
.replace(/\s*-\s*$/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function relaxSeriesTitle(value: string): string {
|
||||
return value.replace(/\b\d{1,3}\b/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function rankMatches(originalTitle: string, matches: Array<MetadataMatch & { googleRank?: number }>): MetadataMatch[] {
|
||||
return [...matches]
|
||||
.sort((left, right) => (right.googleRank ?? 0) - (left.googleRank ?? 0))
|
||||
.map(({ googleRank: _googleRank, ...match }) => match);
|
||||
}
|
||||
|
||||
function googleRank(originalTitle: string, originalAuthor: string | null, info: Record<string, unknown>): number {
|
||||
const expectedVolume = volumeNumber(originalTitle);
|
||||
const candidateTitle = [stringValue(info.title), stringValue(info.subtitle)].filter(Boolean).join(" ");
|
||||
let rank = tokenOverlap(cleanGoogleBooksTitle(originalTitle), candidateTitle) * 10;
|
||||
if (expectedVolume) {
|
||||
const candidateVolume = volumeNumber(candidateTitle);
|
||||
rank += candidateVolume === expectedVolume ? 6 : candidateVolume ? -4 : 0;
|
||||
}
|
||||
if (originalAuthor && arrayJoin(info.authors)?.toLowerCase().includes(originalAuthor.toLowerCase())) rank += 2;
|
||||
if (stringValue(info.description)) rank += 1;
|
||||
if (coverUrl(info.imageLinks)) rank += 1;
|
||||
return rank;
|
||||
}
|
||||
|
||||
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(normalizeTokens(left));
|
||||
const rightTokens = new Set(normalizeTokens(right));
|
||||
if (!leftTokens.size || !rightTokens.size) return 0;
|
||||
return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
|
||||
}
|
||||
|
||||
function normalizeTokens(value: string): string[] {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
@ -77,3 +182,20 @@ function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | nu
|
||||
const isbn10 = entries.find((entry) => entry.type === "ISBN_10")?.identifier;
|
||||
return stringValue(isbn13) ?? stringValue(isbn10);
|
||||
}
|
||||
|
||||
function isbnCandidates(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry) => stringValue((entry as { identifier?: unknown }).identifier)).filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
function coverUrl(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const links = value as Record<string, unknown>;
|
||||
return (
|
||||
stringValue(links.extraLarge) ??
|
||||
stringValue(links.large) ??
|
||||
stringValue(links.medium) ??
|
||||
stringValue(links.thumbnail) ??
|
||||
stringValue(links.smallThumbnail)
|
||||
)?.replace(/^http:/, "https:") ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user