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(); 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); } }