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>
115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { basename, extname } from "node:path";
|
|
import AdmZip from "adm-zip";
|
|
import { XMLParser } from "fast-xml-parser";
|
|
|
|
export type ExtractedIdentifiers = {
|
|
isbn10: string | null;
|
|
isbn13: string | null;
|
|
candidates: string[];
|
|
};
|
|
|
|
const xmlParser = new XMLParser({
|
|
ignoreAttributes: false,
|
|
attributeNamePrefix: "@_",
|
|
textNodeName: "#text"
|
|
});
|
|
|
|
export class ExtractIdentifiers {
|
|
fromMetadataAndFile(metadata: { isbn?: string | null }, filePath: string): ExtractedIdentifiers {
|
|
const candidates = new Set<string>();
|
|
for (const value of [metadata.isbn, basename(filePath, extname(filePath))]) {
|
|
for (const isbn of findIsbns(String(value ?? ""))) candidates.add(isbn);
|
|
}
|
|
|
|
const extension = extname(filePath).toLowerCase();
|
|
if (extension === ".epub") {
|
|
for (const isbn of findIsbns(readLimitedEpubText(filePath))) candidates.add(isbn);
|
|
}
|
|
if (extension === ".pdf") {
|
|
const buffer = readFileSync(filePath);
|
|
const head = buffer.subarray(0, Math.min(buffer.length, 256 * 1024)).toString("latin1");
|
|
for (const isbn of findIsbns(head)) candidates.add(isbn);
|
|
}
|
|
|
|
return normalizeCandidates([...candidates]);
|
|
}
|
|
}
|
|
|
|
export function normalizeIsbn(value: string): string | null {
|
|
const compact = value.replace(/[^0-9X]/gi, "").toUpperCase();
|
|
if (compact.length === 10 && isValidIsbn10(compact)) return compact;
|
|
if (compact.length === 13 && isValidIsbn13(compact)) return compact;
|
|
return null;
|
|
}
|
|
|
|
export function toIsbn13(value: string): string | null {
|
|
const isbn = normalizeIsbn(value);
|
|
if (!isbn) return null;
|
|
if (isbn.length === 13) return isbn;
|
|
const stem = `978${isbn.slice(0, 9)}`;
|
|
let sum = 0;
|
|
for (let index = 0; index < stem.length; index += 1) {
|
|
sum += Number(stem[index]) * (index % 2 === 0 ? 1 : 3);
|
|
}
|
|
return `${stem}${(10 - (sum % 10)) % 10}`;
|
|
}
|
|
|
|
function normalizeCandidates(values: string[]): ExtractedIdentifiers {
|
|
const normalized = [...new Set(values.map(normalizeIsbn).filter((value): value is string => Boolean(value)))];
|
|
const isbn13 = normalized.map(toIsbn13).find((value): value is string => Boolean(value)) ?? null;
|
|
const isbn10 = normalized.find((value) => value.length === 10) ?? null;
|
|
return { isbn10, isbn13, candidates: normalized };
|
|
}
|
|
|
|
function findIsbns(text: string): string[] {
|
|
const matches = text.match(/(?:ISBN(?:-1[03])?:?\s*)?(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dX]/gi) ?? [];
|
|
return matches.map((match) => match.replace(/^ISBN(?:-1[03])?:?\s*/i, ""));
|
|
}
|
|
|
|
function isValidIsbn10(value: string): boolean {
|
|
let sum = 0;
|
|
for (let index = 0; index < 10; index += 1) {
|
|
const char = value[index];
|
|
const digit = char === "X" && index === 9 ? 10 : Number(char);
|
|
if (!Number.isInteger(digit)) return false;
|
|
sum += digit * (10 - index);
|
|
}
|
|
return sum % 11 === 0;
|
|
}
|
|
|
|
function isValidIsbn13(value: string): boolean {
|
|
let sum = 0;
|
|
for (let index = 0; index < 13; index += 1) {
|
|
const digit = Number(value[index]);
|
|
if (!Number.isInteger(digit)) return false;
|
|
sum += digit * (index % 2 === 0 ? 1 : 3);
|
|
}
|
|
return sum % 10 === 0;
|
|
}
|
|
|
|
function readLimitedEpubText(filePath: string): string {
|
|
try {
|
|
const zip = new AdmZip(filePath);
|
|
const fragments: string[] = [zip.readAsText("META-INF/container.xml")];
|
|
for (const entry of zip.getEntries()) {
|
|
if (fragments.join("").length > 256 * 1024) break;
|
|
if (!entry.isDirectory && /\.(opf|xhtml|html|htm|xml)$/i.test(entry.entryName)) {
|
|
fragments.push(stripXml(zip.readAsText(entry)));
|
|
}
|
|
}
|
|
return fragments.join("\n");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function stripXml(value: string): string {
|
|
try {
|
|
const parsed = xmlParser.parse(value);
|
|
return JSON.stringify(parsed).slice(0, 256 * 1024);
|
|
} catch {
|
|
return value.slice(0, 256 * 1024);
|
|
}
|
|
}
|