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()
@ -8,9 +8,11 @@ export class GoogleBooksProvider implements MetadataProvider {
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
const query = isbn
? `isbn:${isbn}`
: `intitle:${lookup.title}${lookup.author ? `+inauthor:${lookup.author}` : ""}`;
if (!isbn) {
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
return matches[0] ?? null;
}
const query = `isbn:${isbn}`;
const url = new URL("https://www.googleapis.com/books/v1/volumes");
url.searchParams.set("q", query);
url.searchParams.set("maxResults", "1");
@ -32,6 +34,30 @@ export class GoogleBooksProvider implements MetadataProvider {
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, lookup.identifiers.isbn13)
};
}
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
const url = new URL("https://www.googleapis.com/books/v1/volumes");
url.searchParams.set("q", `intitle:${query.title}${query.author ? `+inauthor:${query.author}` : ""}`);
url.searchParams.set("maxResults", "5");
url.searchParams.set("printType", "books");
if (config.apiKey) url.searchParams.set("key", config.apiKey);
const response = await fetch(url, { signal: AbortSignal.timeout(4000) });
if (!response.ok) return [];
const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record<string, unknown> }> };
return (data.items ?? [])
.map((item) => item.volumeInfo)
.filter((info): info is Record<string, unknown> => Boolean(info))
.map((info) => ({
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, query.isbn ? toIsbn13(query.isbn) : null)
}));
}
}
function stringValue(value: unknown): string | null {
@ -45,7 +71,7 @@ function arrayJoin(value: unknown): string | 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) => toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier;
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;