- 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>
202 lines
8.8 KiB
TypeScript
202 lines
8.8 KiB
TypeScript
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 {
|
|
readonly id = "googlebooks" as const;
|
|
|
|
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
|
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
|
|
if (!isbn) {
|
|
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
|
|
return matches[0] ?? null;
|
|
}
|
|
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", googleQuery);
|
|
url.searchParams.set("maxResults", "10");
|
|
url.searchParams.set("printType", "books");
|
|
if (config.apiKey) url.searchParams.set("key", config.apiKey);
|
|
|
|
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) => ({ 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: 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;
|
|
}
|
|
|
|
function arrayJoin(value: unknown): string | null {
|
|
return Array.isArray(value) && value.length ? value.map(String).join(", ") : null;
|
|
}
|
|
|
|
function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | null): string | null {
|
|
if (!Array.isArray(value)) return null;
|
|
const entries = value as Array<{ type?: unknown; identifier?: unknown }>;
|
|
const matching = entries.find((entry) => Boolean(expectedIsbn13) && toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier;
|
|
if (matching) return stringValue(matching);
|
|
const isbn13 = entries.find((entry) => entry.type === "ISBN_13")?.identifier;
|
|
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;
|
|
}
|