fix(api,web): enrichissement métadonnées — hints locaux et scoring des correspondances

Recherche de fiches par nom peu fiable : sans ISBN, les providers
étaient interrogés avec des titres bruts peu discriminants.

- extraction de hints locaux (titre/auteur/année/isbn du fichier et du
  nom de fichier) persistés dans books.local_metadata_json
- nouveau use-case ScoreMetadataMatch : tri des résultats par score de
  correspondance avant sélection du meilleur candidat
- providers (google-books, open-library, bnf, local) durcis et nourris
  par les hints ; colonne + index local_metadata_json avec migrations
  idempotentes (création et rebuild legacy)
- web : nettoyage de la description de la fiche livre
  (cleanBookDescription, white-space pre-line)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-23 18:07:29 +02:00
parent 5de46a6f6d
commit 18d73a4db6
21 changed files with 829 additions and 56 deletions

View File

@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
@Injectable()
@ -11,26 +11,45 @@ export class OpenLibraryProvider implements MetadataProvider {
if (isbn) {
return this.lookupIsbn(isbn, lookup.identifiers.isbn13);
}
const query = `title:${lookup.title}${lookup.author ? ` author:${lookup.author}` : ""}`;
if (lookup.sourceId) {
return this.lookupEdition(lookup.sourceId, lookup.identifiers.isbn13);
}
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config);
return matches[0] ?? null;
}
async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const url = new URL("https://openlibrary.org/search.json");
url.searchParams.set("q", query);
url.searchParams.set("limit", "1");
url.searchParams.set("title", query.title);
if (query.author) url.searchParams.set("author", query.author);
if (query.year) url.searchParams.set("first_publish_year", query.year);
url.searchParams.set("limit", "5");
const response = await fetch(url, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
signal: AbortSignal.timeout(4000)
});
if (!response.ok) return null;
if (!response.ok) return [];
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
const doc = data.docs?.[0];
if (!doc) return null;
return {
return (data.docs ?? []).map((doc) => ({
title: stringValue(doc.title) ?? undefined,
sourceId: firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key),
author: arrayJoin(doc.author_name),
language: firstArrayValue(doc.language),
publisher: firstArrayValue(doc.publisher),
publishedDate: String(doc.first_publish_year ?? "") || null,
isbn: bestIsbn(doc.isbn, lookup.identifiers.isbn13)
};
isbn: bestIsbn(doc.isbn, query.isbn ? toIsbn13(query.isbn) : null)
}));
}
private async lookupEdition(sourceId: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
const editionKey = sourceId.replace(/^\/?books\//, "");
if (!editionKey) return null;
const response = await fetch(`https://openlibrary.org/books/${encodeURIComponent(editionKey)}.json`, {
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
signal: AbortSignal.timeout(4000)
});
if (!response.ok) return null;
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
}
private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
@ -39,7 +58,10 @@ export class OpenLibraryProvider implements MetadataProvider {
signal: AbortSignal.timeout(4000)
});
if (!response.ok) return null;
const edition = (await response.json()) as Record<string, unknown>;
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
}
private async editionToMatch(edition: Record<string, unknown>, expectedIsbn13: string | null): Promise<MetadataMatch> {
const author = await this.lookupAuthorName(edition.authors);
return {
title: stringValue(edition.title) ?? undefined,
@ -81,7 +103,11 @@ function arrayJoin(value: unknown): string | null {
function bestIsbn(value: unknown, expectedIsbn13: string | null): string | null {
if (!Array.isArray(value)) return null;
const values = value.map(String);
return values.find((candidate) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => toIsbn13(candidate)) ?? null;
return (
values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ??
values.find((candidate) => Boolean(toIsbn13(candidate))) ??
null
);
}
function asStringArray(value: unknown): string[] {