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 { 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 { const attempts = googleBookQueries(query); const matches: MetadataMatch[] = []; const seen = new Set(); 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 { 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 }> }; return (data.items ?? []) .map((item) => ({ sourceId: item.id, info: item.volumeInfo })) .filter((item): item is { sourceId: string | undefined; info: Record } => 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 { 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[] { 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): 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; return ( stringValue(links.extraLarge) ?? stringValue(links.large) ?? stringValue(links.medium) ?? stringValue(links.thumbnail) ?? stringValue(links.smallThumbnail) )?.replace(/^http:/, "https:") ?? null; }