feat(api,shared): métadonnées multi-providers et automatisation des scans/enrichissements
Chaîne de résolution metadata (local, Open Library, Google Books, BNF) avec activation/priorité/clé API par provider, colonnes isbn13 et identifiers sur les livres, et intégration au scanner pour compléter métadonnées et jaquettes manquantes. Module automation: réglages persistés, planifications scan/enrichissement et déclenchement manuel, exposés via des endpoints admin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
38
apps/api/src/metadata/adapters/bnf.provider.ts
Normal file
38
apps/api/src/metadata/adapters/bnf.provider.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
||||
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
|
||||
|
||||
@Injectable()
|
||||
export class BnfProvider implements MetadataProvider {
|
||||
readonly id = "bnf" as const;
|
||||
|
||||
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||
const query = lookup.identifiers.isbn13
|
||||
? `bib.isbn all "${lookup.identifiers.isbn13}"`
|
||||
: `bib.title all "${lookup.title.replace(/"/g, " ")}"`;
|
||||
const url = new URL("https://catalogue.bnf.fr/api/SRU");
|
||||
url.searchParams.set("version", "1.2");
|
||||
url.searchParams.set("operation", "searchRetrieve");
|
||||
url.searchParams.set("query", query);
|
||||
url.searchParams.set("maximumRecords", "1");
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
if (!response.ok) return null;
|
||||
const parsed = parser.parse(await response.text());
|
||||
const record = parsed?.searchRetrieveResponse?.records?.record?.recordData;
|
||||
if (!record) return null;
|
||||
const text = JSON.stringify(record);
|
||||
return {
|
||||
title: match(text, /"titleInfo"[^}]*"title":"([^"]+)"/),
|
||||
author: match(text, /"namePart":"([^"]+)"/),
|
||||
publisher: match(text, /"publisher":"([^"]+)"/),
|
||||
publishedDate: match(text, /"dateIssued":"([^"]+)"/)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function match(value: string, pattern: RegExp): string | undefined {
|
||||
return value.match(pattern)?.[1];
|
||||
}
|
||||
47
apps/api/src/metadata/adapters/google-books.provider.ts
Normal file
47
apps/api/src/metadata/adapters/google-books.provider.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleBooksProvider implements MetadataProvider {
|
||||
readonly id = "googlebooks" as const;
|
||||
|
||||
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||
const query = lookup.identifiers.isbn13
|
||||
? `isbn:${lookup.identifiers.isbn13}`
|
||||
: `intitle:${lookup.title}${lookup.author ? `+inauthor:${lookup.author}` : ""}`;
|
||||
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("maxResults", "1");
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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): string | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const isbn13 = value.find((entry) => entry?.type === "ISBN_13")?.identifier;
|
||||
const isbn10 = value.find((entry) => entry?.type === "ISBN_10")?.identifier;
|
||||
return stringValue(isbn13) ?? stringValue(isbn10);
|
||||
}
|
||||
16
apps/api/src/metadata/adapters/local.provider.ts
Normal file
16
apps/api/src/metadata/adapters/local.provider.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
||||
|
||||
@Injectable()
|
||||
export class LocalMetadataProvider implements MetadataProvider {
|
||||
readonly id = "local" as const;
|
||||
|
||||
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch> {
|
||||
return {
|
||||
title: lookup.title,
|
||||
author: lookup.author,
|
||||
isbn: lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10,
|
||||
identifiers: lookup.identifiers
|
||||
};
|
||||
}
|
||||
}
|
||||
33
apps/api/src/metadata/adapters/open-library.provider.ts
Normal file
33
apps/api/src/metadata/adapters/open-library.provider.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
||||
|
||||
@Injectable()
|
||||
export class OpenLibraryProvider implements MetadataProvider {
|
||||
readonly id = "openlibrary" as const;
|
||||
|
||||
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||
const query = lookup.identifiers.isbn13
|
||||
? `isbn:${encodeURIComponent(lookup.identifiers.isbn13)}`
|
||||
: `title:${encodeURIComponent(lookup.title)}${lookup.author ? ` author:${encodeURIComponent(lookup.author)}` : ""}`;
|
||||
const response = await fetch(`https://openlibrary.org/search.json?q=${query}&limit=1`, {
|
||||
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||
signal: AbortSignal.timeout(4000)
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return null;
|
||||
return {
|
||||
author: firstArrayValue(doc.author_name),
|
||||
language: firstArrayValue(doc.language),
|
||||
publisher: firstArrayValue(doc.publisher),
|
||||
publishedDate: String(doc.first_publish_year ?? "") || null,
|
||||
isbn: firstArrayValue(doc.isbn)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function firstArrayValue(value: unknown): string | null {
|
||||
if (!Array.isArray(value) || !value.length) return null;
|
||||
return String(value[0]);
|
||||
}
|
||||
Reference in New Issue
Block a user