Files
ReadaBook/apps/api/src/metadata/adapters/google-books.provider.ts
Git Agent d79f502dd2 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>
2026-08-24 09:11:18 +02:00

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;
}