@prose-reader/metadata-fetcher 1.340.0 → 1.341.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fetchMetadata.d.ts +11 -15
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +159 -218
- package/dist/index.js.map +1 -1
- package/dist/types/fetchedMetadata.d.ts +5 -20
- package/dist/types/match.d.ts +2 -3
- package/package.json +4 -4
- package/dist/merge/mergeResolvedMetadata.d.ts +0 -21
- package/dist/merge/mergeResolvedMetadata.test.d.ts +0 -1
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/utils/metadataAuthors.ts","../src/utils/toIsbn13.ts","../src/match/similarity.ts","../src/match/scoreMetadataCandidate.ts","../src/utils/omitUndefined.ts","../src/merge/mergeResolvedMetadata.ts","../src/providers/responseError.ts","../src/report.ts","../src/fetchMetadata.ts","../src/providers/projectGutenberg/identifier.ts","../src/utils/json.ts","../src/providers/openLibrary/parse.ts","../src/providers/openLibrary/marcLanguage.ts","../src/providers/openLibrary/resolve.ts","../src/providers/openLibrary/createOpenLibraryProvider.ts","../src/providers/projectGutenberg/parse.ts","../src/providers/projectGutenberg/resolve.ts","../src/providers/projectGutenberg/createProjectGutenbergProvider.ts","../src/utils/hasSearchTerms.ts"],"sourcesContent":["import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\n\n/**\n * The names to match a publication on: those credited as `author`, else — for\n * a publication crediting nobody as one, like a comic archive listing only a\n * penciler — every contributor. Order is preserved (sources list the lead\n * creator first) and duplicates are dropped.\n */\nexport const metadataAuthors = (\n metadata: ResolvedMetadata,\n): ReadonlyArray<string> => {\n const contributors = metadata.contributors ?? []\n const authors = contributors.filter((contributor) =>\n contributor.roles.includes(\"author\"),\n )\n const names = (authors.length > 0 ? authors : contributors)\n .map((contributor) => contributor.name.trim())\n .filter((name) => name.length > 0)\n\n return [...new Set(names)]\n}\n","import { normalizeIsbn } from \"@prose-reader/archive-reader\"\n\nconst ISBN_13 = /^\\d{13}$/\nconst ISBN_10 = /^\\d{9}[\\dX]$/\n\n/**\n * Put an ISBN in its 13-digit form so the two printed on the same book compare\n * equal. A book carries an ISBN-10 in its OPF and an ISBN-13 in a catalog\n * record all the time; comparing them verbatim would read as \"different\n * books\", which — ISBN being a decisive match signal — is the most damaging\n * false negative the matcher can produce.\n *\n * Input is put through `normalizeIsbn`, the same function that produces\n * {@link ResolvedMetadata.isbn} in the first place, so the hyphenated and\n * prefixed forms a human types (`978-0-441-01359-3`, `ISBN 0-441-01359-7`)\n * are recognized rather than silently dropped.\n *\n * Returns `undefined` when nothing ISBN-shaped can be recovered (check digits\n * are not verified: a wrong one still identifies the intended book).\n */\nexport const toIsbn13 = (isbn: string): string | undefined => {\n const normalized = normalizeIsbn(isbn)\n\n if (normalized === undefined) return undefined\n if (ISBN_13.test(normalized)) return normalized\n if (!ISBN_10.test(normalized)) return undefined\n\n // ISBN-10 → ISBN-13: prefix the 978 Bookland code, drop the ISBN-10 check\n // digit, recompute the EAN-13 one (weights 1,3,1,3…).\n const core = `978${normalized.slice(0, 9)}`\n let sum = 0\n\n for (let index = 0; index < core.length; index++) {\n // charCodeAt over indexing: `noUncheckedIndexedAccess` types `core[i]` as\n // possibly undefined even though the length is known here\n const digit = core.charCodeAt(index) - 48\n\n sum += index % 2 === 0 ? digit : digit * 3\n }\n\n return `${core}${(10 - (sum % 10)) % 10}`\n}\n","const DIACRITICS = /[\\u0300-\\u036f]/g\nconst NON_ALPHANUMERIC = /[^\\p{L}\\p{N}]+/gu\n\n/** `Les Misérables` ≡ `les miserables`, `Dune: Messiah` ≡ `dune messiah`. */\nexport const normalizeForComparison = (value: string): string =>\n value\n .normalize(\"NFD\")\n .replace(DIACRITICS, \"\")\n .toLowerCase()\n .replace(NON_ALPHANUMERIC, \" \")\n .trim()\n\nconst bigramCounts = (value: string): Map<string, number> => {\n const counts = new Map<string, number>()\n\n for (let index = 0; index < value.length - 1; index++) {\n const bigram = value.slice(index, index + 2)\n\n counts.set(bigram, (counts.get(bigram) ?? 0) + 1)\n }\n\n return counts\n}\n\n/**\n * Sørensen–Dice coefficient over character bigrams, `0` to `1`.\n *\n * Bigrams rather than an edit distance: titles disagree by whole words far\n * more often than by typos. Spaces are stripped so `Spider-Man` and\n * `Spider Man` stay exact.\n */\nexport const textSimilarity = (a: string, b: string): number => {\n const left = normalizeForComparison(a).replaceAll(\" \", \"\")\n const right = normalizeForComparison(b).replaceAll(\" \", \"\")\n\n if (left.length === 0 || right.length === 0) return 0\n if (left === right) return 1\n // nothing to compare: a single character has no bigram\n if (left.length < 2 || right.length < 2) return 0\n\n const leftBigrams = bigramCounts(left)\n let shared = 0\n\n for (const [bigram, count] of bigramCounts(right)) {\n shared += Math.min(count, leftBigrams.get(bigram) ?? 0)\n }\n\n return (2 * shared) / (left.length - 1 + (right.length - 1))\n}\n\n/** Spaced dashes only, so `Spider-Man` stays one word. */\nconst SUBTITLE_SEPARATOR = /\\s*[:;]\\s*|\\s+[-–—]\\s+/\n\nconst splitTitle = (value: string): { main: string; hasSubtitle: boolean } => {\n const [head = \"\", ...rest] = value.split(SUBTITLE_SEPARATOR)\n const main = head.trim()\n const subtitle = rest.join(\" \").trim()\n\n if (main.length === 0) return { main: value, hasSubtitle: false }\n\n return { main, hasSubtitle: subtitle.length > 0 }\n}\n\n/** Below an exact match: the omitted half could have been a volume title. */\nconst SUBTITLE_REPAIR_CEILING = 0.9\n\ntype TitleDivision = \"volume\" | \"part\" | \"book\"\nconst TITLE_DIVISIONS: ReadonlyArray<TitleDivision> = [\"volume\", \"part\", \"book\"]\n\nconst TITLE_DIVISION_PATTERN =\n /\\b(volumes?|vols?|parts?|books?)\\s+([\\p{L}\\p{N}]+)\\b/gu\n\nconst TITLE_NUMBER_WORDS: Readonly<Record<string, number>> = {\n one: 1,\n first: 1,\n two: 2,\n second: 2,\n three: 3,\n third: 3,\n four: 4,\n fourth: 4,\n five: 5,\n fifth: 5,\n six: 6,\n sixth: 6,\n seven: 7,\n seventh: 7,\n eight: 8,\n eighth: 8,\n nine: 9,\n ninth: 9,\n ten: 10,\n tenth: 10,\n eleven: 11,\n eleventh: 11,\n twelve: 12,\n twelfth: 12,\n}\n\nconst ROMAN_DIGIT_VALUES: Readonly<Record<string, number>> = {\n i: 1,\n v: 5,\n x: 10,\n l: 50,\n c: 100,\n d: 500,\n m: 1000,\n}\n\nconst parseRomanNumeral = (value: string): number | undefined => {\n if (!/^m{0,3}(cm|cd|d?c{0,3})(xc|xl|l?x{0,3})(ix|iv|v?i{0,3})$/i.test(value))\n return undefined\n\n let total = 0\n let previous = 0\n\n for (const symbol of [...value.toLowerCase()].reverse()) {\n const current = ROMAN_DIGIT_VALUES[symbol]\n\n if (current === undefined) return undefined\n\n if (current < previous) total -= current\n else {\n total += current\n previous = current\n }\n }\n\n return total > 0 ? total : undefined\n}\n\nconst parseTitleDivisionNumber = (value: string): number | undefined => {\n if (/^\\d+$/.test(value)) return Number.parseInt(value, 10)\n\n return TITLE_NUMBER_WORDS[value] ?? parseRomanNumeral(value)\n}\n\nconst titleDivision = (value: string): TitleDivision | undefined => {\n if (value.startsWith(\"vol\")) return \"volume\"\n if (value.startsWith(\"part\")) return \"part\"\n if (value.startsWith(\"book\")) return \"book\"\n\n return undefined\n}\n\nconst explicitTitleDivisions = (\n value: string,\n): ReadonlyMap<TitleDivision, ReadonlySet<number>> => {\n const divisions = new Map<TitleDivision, Set<number>>()\n\n for (const match of normalizeForComparison(value).matchAll(\n TITLE_DIVISION_PATTERN,\n )) {\n const division =\n match[1] !== undefined ? titleDivision(match[1]) : undefined\n const number =\n match[2] !== undefined ? parseTitleDivisionNumber(match[2]) : undefined\n\n if (division === undefined || number === undefined) continue\n\n const numbers = divisions.get(division) ?? new Set<number>()\n numbers.add(number)\n divisions.set(division, numbers)\n }\n\n return divisions\n}\n\nconst titleDivisionsContradict = (a: string, b: string): boolean => {\n const left = explicitTitleDivisions(a)\n const right = explicitTitleDivisions(b)\n\n for (const division of TITLE_DIVISIONS) {\n const leftNumbers = left.get(division)\n const rightNumbers = right.get(division)\n\n if (leftNumbers === undefined || rightNumbers === undefined) continue\n if ([...leftNumbers].every((number) => !rightNumbers.has(number)))\n return true\n }\n\n return false\n}\n\n/**\n * {@link textSimilarity}, with `Dune` matched against `Dune: a novel`.\n *\n * Only when **one** side states a subtitle: that asymmetry is the same title\n * catalogued at a different depth. When both state one, both catalogers meant\n * it — `Dune: Book One` and `Dune: Messiah` are two books.\n * Explicit volume, part, or book numbers are semantic identity: when both\n * sides state different ones, the title score is `0` however similar the rest.\n */\nexport const titleSimilarity = (a: string, b: string): number => {\n if (titleDivisionsContradict(a, b)) return 0\n\n const full = textSimilarity(a, b)\n const left = splitTitle(a)\n const right = splitTitle(b)\n\n if (left.hasSubtitle === right.hasSubtitle) return full\n\n return Math.max(\n full,\n textSimilarity(left.main, right.main) * SUBTITLE_REPAIR_CEILING,\n )\n}\n\nconst sortedTokens = (value: string): string =>\n normalizeForComparison(value)\n .split(\" \")\n .filter((token) => token.length > 0)\n .sort()\n .join(\" \")\n\n/** {@link textSimilarity} on sorted tokens: `Herbert, Frank` ≡ `Frank Herbert`. */\nexport const personNameSimilarity = (a: string, b: string): number =>\n textSimilarity(sortedTokens(a), sortedTokens(b))\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport type { MetadataMatchField, MetadataMatchSignal } from \"../types/match.ts\"\nimport type { MetadataIdentifier } from \"../types/provider.ts\"\nimport { metadataAuthors } from \"../utils/metadataAuthors.ts\"\nimport { toIsbn13 } from \"../utils/toIsbn13.ts\"\nimport {\n personNameSimilarity,\n textSimilarity,\n titleSimilarity,\n} from \"./similarity.ts\"\n\n/**\n * Intent, not statistics: identifiers *are* the book, a title is what a human\n * recognizes it by, everything else corroborates. An edition detail that\n * differs must never sink an otherwise convincing match on its own.\n */\nexport const METADATA_MATCH_WEIGHTS = {\n isbn: 1,\n gtin: 1,\n identifiers: 0.6,\n title: 0.8,\n contributors: 0.5,\n series: 0.3,\n \"publication.original.date\": 0.2,\n \"publication.original.publisher\": 0.15,\n \"publication.edition.date\": 0.2,\n \"publication.edition.publisher\": 0.15,\n languages: 0.15,\n numberOfPages: 0.15,\n} as const satisfies Record<MetadataMatchField, number>\n\n/**\n * Identity rather than description, so these settle the score outright in both\n * directions. Catalogs and books disagree on titles and page counts\n * constantly; they do not agree or disagree on an ISBN by accident.\n */\nconst DECISIVE_FIELDS: ReadonlySet<MetadataMatchField> = new Set([\n \"isbn\",\n \"gtin\",\n])\n\n/** Blank is absent — the vocabulary says so, a hand-built query may not. */\nconst stated = (value: string | undefined): string | undefined => {\n const trimmed = value?.trim()\n\n return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined\n}\n\nconst identifiersHaveSameValue = (\n a: MetadataIdentifier,\n b: MetadataIdentifier,\n): boolean => {\n const aValue = stated(a.value)?.toLowerCase()\n\n return aValue !== undefined && aValue === stated(b.value)?.toLowerCase()\n}\n\nconst identifierScheme = (identifier: MetadataIdentifier): string | undefined =>\n stated(identifier.scheme)?.toLowerCase()\n\nconst identifierLabel = (identifier: MetadataIdentifier): string =>\n identifier.scheme !== undefined\n ? `${identifier.scheme}:${identifier.value}`\n : identifier.value\n\n/**\n * An unannounced scheme (the OPF `dc:identifier` case) is not a contradiction;\n * a `DOI` and an `ISBN` carrying the same digits are.\n */\nconst identifiersAgree = (\n a: MetadataIdentifier,\n b: MetadataIdentifier,\n): boolean => {\n if (!identifiersHaveSameValue(a, b)) return false\n const aScheme = identifierScheme(a)\n const bScheme = identifierScheme(b)\n\n if (aScheme === undefined || bScheme === undefined) return true\n\n return aScheme === bScheme\n}\n\n/** Only an authored scope makes a shared raw value unambiguous identity. */\nconst identifiersAgreeDecisively = (\n a: MetadataIdentifier,\n b: MetadataIdentifier,\n): boolean => {\n if (!identifiersHaveSameValue(a, b)) return false\n const aScheme = identifierScheme(a)\n const bScheme = identifierScheme(b)\n\n return aScheme !== undefined && aScheme === bScheme\n}\n\nconst primaryLanguageSubtag = (language: string): string =>\n language.trim().toLowerCase().split(\"-\")[0] ?? \"\"\n\n/** Reprints shift the year around, so a near one still corroborates. */\nconst publicationYearScore = (\n queryYear: number,\n candidateYear: number,\n): number => {\n const distance = Math.abs(queryYear - candidateYear)\n\n if (distance === 0) return 1\n if (distance === 1) return 0.6\n if (distance <= 3) return 0.3\n\n return 0\n}\n\n/**\n * Proximity, not equality — front matter and ads move the count: identical is\n * `1`, 25% apart `0.5`, half as long `0`.\n */\nconst numberOfPagesScore = (query: number, candidate: number): number => {\n const largest = Math.max(query, candidate)\n\n if (largest <= 0) return 0\n\n return Math.max(0, 1 - (Math.abs(query - candidate) / largest) * 2)\n}\n\nconst bestSimilarity = (\n values: ReadonlyArray<string>,\n candidates: ReadonlyArray<string>,\n compare: (a: string, b: string) => number,\n): number =>\n values.reduce(\n (best, value) =>\n candidates.reduce(\n (innerBest, candidate) =>\n Math.max(innerBest, compare(value, candidate)),\n best,\n ),\n 0,\n )\n\nexport type ScoredMetadataCandidate = {\n readonly score: number\n readonly signals: ReadonlyArray<MetadataMatchSignal>\n}\n\n/**\n * How well a candidate agrees with the query, field by field, and why. Every\n * provider is scored by these rules, so their scores stay comparable:\n *\n * - **Only comparable fields count** — a field is compared when both sides\n * state it, so what the book doesn't know can neither raise nor lower a\n * candidate, and nothing in common scores `0`.\n * - **The aggregate is a weighted average** ({@link METADATA_MATCH_WEIGHTS}),\n * putting a rich query and a sparse one on the same scale.\n * - **Confirmed identity settles it** — an agreeing ISBN, GTIN, or\n * scheme-scoped identifier pins the score to `1`. A contradictory ISBN or\n * GTIN pins it to `0`; two disjoint provider-specific identifier lists are\n * merely unrelated.\n */\nexport const scoreMetadataCandidate = (\n query: ResolvedMetadata,\n candidate: ResolvedMetadata,\n): ScoredMetadataCandidate => {\n const signals: MetadataMatchSignal[] = []\n\n const addSignal = (\n field: MetadataMatchField,\n values:\n | {\n score: number\n query: string\n candidate: string\n }\n | undefined,\n ) => {\n if (values === undefined) return\n\n signals.push({\n field,\n weight: METADATA_MATCH_WEIGHTS[field],\n score: Math.min(1, Math.max(0, values.score)),\n query: values.query,\n candidate: values.candidate,\n })\n }\n\n const compareStrings = (\n field: MetadataMatchField,\n rawQueryValue: string | undefined,\n rawCandidateValue: string | undefined,\n compare: (a: string, b: string) => number = textSimilarity,\n ) => {\n const queryValue = stated(rawQueryValue)\n const candidateValue = stated(rawCandidateValue)\n\n if (queryValue === undefined || candidateValue === undefined) return\n\n addSignal(field, {\n score: compare(queryValue, candidateValue),\n query: queryValue,\n candidate: candidateValue,\n })\n }\n\n const queryIsbn = stated(query.isbn)\n const candidateIsbn = stated(candidate.isbn)\n const queryIsbn13 = queryIsbn !== undefined ? toIsbn13(queryIsbn) : undefined\n const candidateIsbn13 =\n candidateIsbn !== undefined ? toIsbn13(candidateIsbn) : undefined\n\n if (\n queryIsbn !== undefined &&\n candidateIsbn !== undefined &&\n queryIsbn13 !== undefined &&\n candidateIsbn13 !== undefined\n ) {\n addSignal(\"isbn\", {\n score: queryIsbn13 === candidateIsbn13 ? 1 : 0,\n query: queryIsbn,\n candidate: candidateIsbn,\n })\n }\n\n const queryGtin = stated(query.gtin)\n const candidateGtin = stated(candidate.gtin)\n\n if (queryGtin !== undefined && candidateGtin !== undefined) {\n addSignal(\"gtin\", {\n score: queryGtin === candidateGtin ? 1 : 0,\n query: queryGtin,\n candidate: candidateGtin,\n })\n }\n\n const queryIdentifiers = query.identifiers ?? []\n const candidateIdentifiers = candidate.identifiers ?? []\n const decisiveIdentifierAgreement = queryIdentifiers.some((identifier) =>\n candidateIdentifiers.some((other) =>\n identifiersAgreeDecisively(identifier, other),\n ),\n )\n\n if (queryIdentifiers.length > 0 && candidateIdentifiers.length > 0) {\n const shared = queryIdentifiers.some((identifier) =>\n candidateIdentifiers.some((other) => identifiersAgree(identifier, other)),\n )\n\n addSignal(\"identifiers\", {\n score: shared ? 1 : 0,\n query: queryIdentifiers.map(identifierLabel).join(\", \"),\n candidate: candidateIdentifiers.map(identifierLabel).join(\", \"),\n })\n }\n\n compareStrings(\"title\", query.title, candidate.title, titleSimilarity)\n compareStrings(\n \"publication.original.publisher\",\n query.publication?.original?.publisher,\n candidate.publication?.original?.publisher,\n )\n compareStrings(\n \"publication.edition.publisher\",\n query.publication?.edition?.publisher,\n candidate.publication?.edition?.publisher,\n )\n compareStrings(\n \"series\",\n query.belongsTo?.series?.[0]?.name,\n candidate.belongsTo?.series?.[0]?.name,\n )\n\n const candidateAuthors = metadataAuthors(candidate)\n const queryAuthors = metadataAuthors(query)\n\n if (queryAuthors.length > 0 && candidateAuthors.length > 0) {\n addSignal(\"contributors\", {\n score: bestSimilarity(\n queryAuthors,\n candidateAuthors,\n personNameSimilarity,\n ),\n query: queryAuthors.join(\", \"),\n candidate: candidateAuthors.join(\", \"),\n })\n }\n\n const comparePublicationYears = (\n field: \"publication.original.date\" | \"publication.edition.date\",\n queryYear: number | undefined,\n candidateYear: number | undefined,\n ) => {\n if (queryYear === undefined || candidateYear === undefined) return\n\n addSignal(field, {\n score: publicationYearScore(queryYear, candidateYear),\n query: String(queryYear),\n candidate: String(candidateYear),\n })\n }\n\n comparePublicationYears(\n \"publication.original.date\",\n query.publication?.original?.date?.year,\n candidate.publication?.original?.date?.year,\n )\n comparePublicationYears(\n \"publication.edition.date\",\n query.publication?.edition?.date?.year,\n candidate.publication?.edition?.date?.year,\n )\n\n const queryLanguages = query.languages ?? []\n const candidateLanguages = candidate.languages ?? []\n\n if (queryLanguages.length > 0 && candidateLanguages.length > 0) {\n const candidateSubtags = new Set(\n candidateLanguages.map(primaryLanguageSubtag),\n )\n\n addSignal(\"languages\", {\n score: queryLanguages.some((language) =>\n candidateSubtags.has(primaryLanguageSubtag(language)),\n )\n ? 1\n : 0,\n query: queryLanguages.join(\", \"),\n candidate: candidateLanguages.join(\", \"),\n })\n }\n\n if (\n query.numberOfPages !== undefined &&\n candidate.numberOfPages !== undefined\n ) {\n addSignal(\"numberOfPages\", {\n score: numberOfPagesScore(query.numberOfPages, candidate.numberOfPages),\n query: String(query.numberOfPages),\n candidate: String(candidate.numberOfPages),\n })\n }\n\n const decisive = signals.filter((signal) => DECISIVE_FIELDS.has(signal.field))\n\n // Contradiction wins over agreement: refusing a publication whose stated\n // identity disagrees is the recoverable mistake. Without this the average is\n // merciful to exactly the wrong candidate — a different edition agreeing on\n // title and author scores (0.8 + 0.5) / 2.3 ≈ 0.57, accepted.\n if (decisive.some((signal) => signal.score === 0))\n return { score: 0, signals }\n if (\n decisiveIdentifierAgreement ||\n decisive.some((signal) => signal.score === 1)\n )\n return { score: 1, signals }\n\n const totalWeight = signals.reduce(\n (total, signal) => total + signal.weight,\n 0,\n )\n\n if (totalWeight === 0) return { score: 0, signals }\n\n const weighted = signals.reduce(\n (total, signal) => total + signal.score * signal.weight,\n 0,\n )\n\n return { score: weighted / totalWeight, signals }\n}\n","/**\n * Returns a copy of `obj` without the keys whose value is `undefined`, so\n * results stay sparse (an absent key and an `undefined` value are the same\n * statement: \"nothing was found for this field\").\n */\nexport const omitUndefined = <T extends object>(obj: T): T => {\n const entries = Object.entries(obj).filter(([, value]) => value !== undefined)\n\n // `as T`: Object.entries/fromEntries erase the key–value pairing; filtering\n // only drops `undefined` values, which `T`'s optional fields already allow,\n // so the runtime shape is a subset of `T` with no way to express it inline.\n return Object.fromEntries(entries) as T\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport type { MetadataIdentifier } from \"../types/provider.ts\"\nimport { omitUndefined } from \"../utils/omitUndefined.ts\"\n\nconst dedupeIdentifiers = (\n identifiers: ReadonlyArray<MetadataIdentifier>,\n): ReadonlyArray<MetadataIdentifier> => {\n const deduped = new Map<string, MetadataIdentifier>()\n\n for (const identifier of identifiers) {\n const key = `${identifier.scheme?.trim().toLowerCase() ?? \"\"}:${identifier.value.trim().toLowerCase()}`\n const existing = deduped.get(key)\n\n if (existing === undefined) {\n deduped.set(key, identifier)\n continue\n }\n\n if (identifier.unique === true && existing.unique !== true) {\n deduped.set(key, { ...existing, unique: true })\n }\n }\n\n return [...deduped.values()]\n}\n\n/**\n * Merges several {@link ResolvedMetadata} into one, **first defined wins**.\n * Precedence is the caller's — pass the sources in the order you trust them.\n *\n * ```ts\n * // the book over the catalogs, catalogs filling the gaps\n * const metadata = mergeResolvedMetadata(resolved.metadata, fetched.metadata)\n * ```\n *\n * Field-wise rather than object-wise, so a source knowing only a cover\n * contributes it without hiding another's title. Two fields are additive\n * instead: `identifiers` concatenate (deduped on scheme + value, mirroring\n * `resolveMetadata`), and `belongsTo` merges `series` and `collection`\n * independently.\n *\n * Everything else takes the first stated value whole. Unioning keyword lists\n * across catalogs is a judgement call for the consumer, not for a merge that\n * has to stay predictable.\n */\nexport const mergeResolvedMetadata = (\n ...entries: ReadonlyArray<ResolvedMetadata | undefined>\n): ResolvedMetadata => {\n const defined = entries.filter(\n (entry): entry is ResolvedMetadata => entry !== undefined,\n )\n\n const first = <K extends keyof ResolvedMetadata>(\n key: K,\n ): ResolvedMetadata[K] => {\n for (const entry of defined) {\n const value = entry[key]\n\n if (value !== undefined) return value\n }\n\n return undefined\n }\n\n const identifiers = dedupeIdentifiers(\n defined.flatMap((entry) => entry.identifiers ?? []),\n )\n\n const belongsTo = omitUndefined({\n series: defined.find((entry) => entry.belongsTo?.series !== undefined)\n ?.belongsTo?.series,\n collection: defined.find(\n (entry) => entry.belongsTo?.collection !== undefined,\n )?.belongsTo?.collection,\n })\n const publicationPart = (part: \"original\" | \"edition\") => {\n const details = omitUndefined({\n date: defined.find(\n (entry) => entry.publication?.[part]?.date !== undefined,\n )?.publication?.[part]?.date,\n publisher: defined.find(\n (entry) => entry.publication?.[part]?.publisher !== undefined,\n )?.publication?.[part]?.publisher,\n imprint: defined.find(\n (entry) => entry.publication?.[part]?.imprint !== undefined,\n )?.publication?.[part]?.imprint,\n })\n\n return details.date !== undefined ||\n details.publisher !== undefined ||\n details.imprint !== undefined\n ? details\n : undefined\n }\n const publication = omitUndefined({\n original: publicationPart(\"original\"),\n edition: publicationPart(\"edition\"),\n })\n\n // `Required` makes every key mandatory in this literal (values still accept\n // `undefined`), so a field added to the vocabulary is a compile error here\n // until its merge rule is chosen, rather than silently dropped.\n const merged: {\n [K in keyof Required<ResolvedMetadata>]: ResolvedMetadata[K]\n } = {\n title: first(\"title\"),\n cover: first(\"cover\"),\n description: first(\"description\"),\n publication:\n publication.original !== undefined || publication.edition !== undefined\n ? publication\n : undefined,\n rights: first(\"rights\"),\n languages: first(\"languages\"),\n subjects: first(\"subjects\"),\n contributors: first(\"contributors\"),\n readingDirection: first(\"readingDirection\"),\n renditionLayout: first(\"renditionLayout\"),\n renditionFlow: first(\"renditionFlow\"),\n renditionSpread: first(\"renditionSpread\"),\n numberOfPages: first(\"numberOfPages\"),\n gtin: first(\"gtin\"),\n isbn: first(\"isbn\"),\n identifiers: identifiers.length > 0 ? identifiers : undefined,\n belongsTo: Object.keys(belongsTo).length > 0 ? belongsTo : undefined,\n properties: first(\"properties\"),\n comic: first(\"comic\"),\n apple: first(\"apple\"),\n kobo: first(\"kobo\"),\n }\n\n return omitUndefined(merged)\n}\n","/**\n * Thrown by a provider when a catalog answered with a failing status. Carrying\n * it is what lets `fetchMetadata` report *why* a provider dropped out: `429`\n * is \"ask again later\", `503` is \"the catalog is down\", and a consumer decides\n * differently for each.\n *\n * ```ts\n * if (!response.ok) {\n * throw new MetadataProviderResponseError(response.status, \"My Catalog search failed\")\n * }\n * ```\n */\nexport class MetadataProviderResponseError extends Error {\n readonly status: number\n\n constructor(status: number, message?: string) {\n super(message ?? `Provider responded with status ${status}`)\n\n this.name = \"MetadataProviderResponseError\"\n this.status = status\n }\n}\n\n/**\n * The HTTP status behind a thrown error, when there is one.\n *\n * Structural rather than `instanceof`, so a provider built on another HTTP\n * client works unadapted. No status means it was never a response — network,\n * parse, bug — which is itself the answer.\n */\nexport const responseErrorStatus = (error: unknown): number | undefined => {\n if (typeof error !== \"object\" || error === null) return undefined\n if (!(\"status\" in error)) return undefined\n\n const { status } = error\n\n if (typeof status !== \"number\" || !Number.isInteger(status)) return undefined\n\n // an unrelated `status` field means something else entirely\n return status >= 100 && status <= 599 ? status : undefined\n}\n","import { Report as SharedReport } from \"@prose-reader/shared\"\n\n// Spelled out rather than read from package.json: node runs this source\n// directly in development, where a JSON import needs an import attribute.\nconst NAME = \"@prose-reader/metadata-fetcher\"\n\n// No explicit `enabled`: inherit the shared root, which auto-configures from\n// the `globalThis.__PROSE_READER_DEBUG` flag.\nexport const Report = SharedReport.namespace(NAME, undefined, {\n color: \"#b06642\",\n})\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport { scoreMetadataCandidate } from \"./match/scoreMetadataCandidate.ts\"\nimport { mergeResolvedMetadata } from \"./merge/mergeResolvedMetadata.ts\"\nimport { responseErrorStatus } from \"./providers/responseError.ts\"\nimport { Report } from \"./report.ts\"\nimport type {\n FailedMetadataProvider,\n FetchedMetadata,\n FetchedMetadataSource,\n} from \"./types/fetchedMetadata.ts\"\nimport type { MetadataMatch } from \"./types/match.ts\"\nimport type { MetadataCandidate, MetadataProvider } from \"./types/provider.ts\"\nimport { omitUndefined } from \"./utils/omitUndefined.ts\"\n\n/**\n * A {@link ResolvedMetadata}, or anything carrying one — the `ResolvedArchive`\n * shape, so the output of `resolveArchive` goes straight in. Hand-built terms\n * (`{ title: \"Dune\" }`) work too, when there is no container to resolve.\n */\nexport type FetchMetadataInput =\n | ResolvedMetadata\n | { readonly metadata: ResolvedMetadata }\n\nexport type FetchMetadataOptions = {\n /**\n * The catalogs to ask, queried concurrently. Their order is the tie-break\n * precedence: equally-scored matches rank in the order declared here, and\n * that ranking drives the merged `metadata`.\n */\n readonly providers: ReadonlyArray<MetadataProvider>\n /**\n * Hard cap on the matches kept **per provider**, best first; also the\n * page-size hint each provider receives. Defaults to `5`.\n */\n readonly limit?: number\n /**\n * Score a match must reach to be `accepted`, and so contribute to the merged\n * `metadata`. Defaults to `0.5`. Below it, matches are still returned and\n * ranked, for a consumer to offer as \"did you mean?\".\n */\n readonly minScore?: number\n /**\n * Keep each provider's own record on `match.raw`. Defaults to `false`: it is\n * provenance most consumers don't need, and can dwarf what they persist.\n */\n readonly includeRaw?: boolean\n readonly signal?: AbortSignal\n}\n\nconst DEFAULT_LIMIT = 5\nconst DEFAULT_MIN_SCORE = 0.5\nconst FETCHED_METADATA_VERSION = 2\n\nconst toMatch = ({\n candidate,\n providerId,\n score,\n signals,\n accepted,\n includeRaw,\n}: {\n candidate: MetadataCandidate\n providerId: string\n score: number\n signals: MetadataMatch[\"signals\"]\n accepted: boolean\n includeRaw: boolean\n}): MetadataMatch =>\n omitUndefined({\n providerId,\n score,\n signals,\n accepted,\n metadata: candidate.metadata,\n id: candidate.id,\n url: candidate.url,\n raw: includeRaw ? candidate.raw : undefined,\n })\n\n/**\n * Asks every provider what it knows about a book, scores the answers against\n * what you gave it, and merges the convincing ones into a single\n * {@link ResolvedMetadata} — the vocabulary `resolveArchive` produces, so\n * remote and embedded metadata stay interchangeable.\n *\n * ```ts\n * import { resolveArchive } from \"@prose-reader/archive-reader\"\n * import {\n * createOpenLibraryProvider,\n * createProjectGutenbergProvider,\n * fetchMetadata,\n * mergeResolvedMetadata,\n * } from \"@prose-reader/metadata-fetcher\"\n *\n * const resolved = await resolveArchive(archive)\n * const fetched = await fetchMetadata(resolved, {\n * providers: [\n * createProjectGutenbergProvider(),\n * createOpenLibraryProvider(),\n * ],\n * })\n *\n * // what the catalogs found, and why we believe it\n * fetched.metadata.cover?.uri\n * fetched.matches[0]?.signals // [{ field: \"isbn\", score: 1, … }, …]\n *\n * // the book wins, the catalogs fill its gaps\n * const metadata = mergeResolvedMetadata(resolved.metadata, fetched.metadata)\n * ```\n *\n * Error policy mirrors `resolveArchive`: a provider that throws never fails\n * the fetch — it is logged through the debug `Report` and listed in\n * `failedProviders`. An abort is the exception: cancelling through `signal`\n * rejects, since the caller asked for it.\n */\nexport const fetchMetadata = async (\n input: FetchMetadataInput,\n options: FetchMetadataOptions,\n): Promise<FetchedMetadata> => {\n const metadata = \"metadata\" in input ? input.metadata : input\n const limit = options.limit ?? DEFAULT_LIMIT\n const minScore = options.minScore ?? DEFAULT_MIN_SCORE\n const includeRaw = options.includeRaw === true\n\n const results = await Promise.all(\n options.providers.map(async (provider) => {\n try {\n const candidates = await provider.search(metadata, {\n limit,\n signal: options.signal,\n })\n\n return { provider, candidates, failure: undefined }\n } catch (error) {\n // the caller pulled the plug: their cancellation, their rejection\n if (options.signal?.aborted === true) throw error\n\n // catalogs in the wild are flaky: a failing provider never fails the\n // fetch, it just doesn't contribute. The status, when the catalog\n // answered with one, is what tells a rate limit from an outage.\n Report.error(`fetchMetadata: provider \"${provider.id}\" failed`, error)\n\n return {\n provider,\n candidates: undefined,\n failure: omitUndefined({\n id: provider.id,\n status: responseErrorStatus(error),\n }),\n }\n }\n }),\n )\n\n const failedProviders: FailedMetadataProvider[] = results.flatMap((result) =>\n result.failure !== undefined ? [result.failure] : [],\n )\n\n const sources: Record<string, FetchedMetadataSource> = {}\n const matches: MetadataMatch[] = []\n\n for (const { provider, candidates } of results) {\n if (candidates === undefined) continue\n\n const providerMatches = candidates\n .map((candidate) => {\n const { score, signals } = scoreMetadataCandidate(\n metadata,\n candidate.metadata,\n )\n\n return toMatch({\n candidate,\n providerId: provider.id,\n score,\n signals,\n accepted: score >= minScore,\n includeRaw,\n })\n })\n // `sort` is stable, so equally-scored candidates keep the order the\n // provider ranked them in\n .sort((a, b) => b.score - a.score)\n .slice(0, limit)\n\n // ids key `sources`, so a duplicate would silently drop a provider's\n // detail — the kind of thing that costs an hour to notice\n if (sources[provider.id] !== undefined) {\n Report.warn(\n `fetchMetadata: several providers share the id \"${provider.id}\"`,\n )\n }\n\n sources[provider.id] = {\n provider: { id: provider.id, name: provider.name },\n matches: providerMatches,\n }\n\n matches.push(...providerMatches)\n }\n\n // again stable: providers were walked in declaration order, so that order\n // breaks ties between equally convincing catalogs\n const rankedMatches = [...matches].sort((a, b) => b.score - a.score)\n\n return {\n version: FETCHED_METADATA_VERSION,\n metadata: mergeResolvedMetadata(\n ...rankedMatches\n .filter((match) => match.accepted)\n .map((match) => match.metadata),\n ),\n matches: rankedMatches,\n sources,\n failedProviders,\n }\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\n\nexport const PROJECT_GUTENBERG_IDENTIFIER_SCHEME = \"ProjectGutenberg\"\n\nexport type ProjectGutenbergLookup = {\n readonly id: string\n /** The book's exact identifier spelling, echoed only after PG confirms it. */\n readonly identifier: {\n readonly value: string\n readonly scheme?: string\n }\n}\n\nconst GUTENBERG_HOSTS: ReadonlySet<string> = new Set([\n \"gutenberg.org\",\n \"www.gutenberg.org\",\n])\n\nconst GUTENBERG_PATHS: ReadonlyArray<RegExp> = [\n /^\\/ebooks\\/(\\d+)(?:[./]|$)/i,\n /^\\/files\\/(\\d+)(?:\\/|$)/i,\n /^\\/cache\\/epub\\/(\\d+)(?:\\/|$)/i,\n /^\\/(\\d+)(?:\\/|$)/,\n]\n\nconst normalizedProjectGutenbergId = (value: string): string | undefined => {\n const trimmed = value.trim()\n\n if (!/^\\d+$/.test(trimmed)) return undefined\n\n const normalized = trimmed.replace(/^0+(?=\\d)/, \"\")\n\n return normalized !== \"0\" ? normalized : undefined\n}\n\nconst projectGutenbergIdFromUrl = (value: string): string | undefined => {\n try {\n const url = new URL(value.trim())\n\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") return undefined\n if (!GUTENBERG_HOSTS.has(url.hostname.toLowerCase())) return undefined\n\n for (const pattern of GUTENBERG_PATHS) {\n const id = pattern.exec(url.pathname)?.[1]\n\n if (id !== undefined) return normalizedProjectGutenbergId(id)\n }\n } catch {\n return undefined\n }\n\n return undefined\n}\n\n/**\n * Recognizes Project Gutenberg's own numeric identifier and official URL\n * forms. Calling providers decide explicitly how to use this crosswalk; the\n * shared scorer never reinterprets arbitrary URLs.\n */\nexport const projectGutenbergLookupFromMetadata = (\n metadata: ResolvedMetadata,\n): ProjectGutenbergLookup | undefined => {\n for (const identifier of metadata.identifiers ?? []) {\n const scheme = identifier.scheme?.trim().toLowerCase()\n const id =\n scheme === PROJECT_GUTENBERG_IDENTIFIER_SCHEME.toLowerCase()\n ? normalizedProjectGutenbergId(identifier.value)\n : scheme === undefined || scheme === \"url\" || scheme === \"uri\"\n ? projectGutenbergIdFromUrl(identifier.value)\n : undefined\n\n if (id === undefined) continue\n\n return {\n id,\n identifier: {\n value: identifier.value,\n ...(identifier.scheme !== undefined\n ? { scheme: identifier.scheme }\n : {}),\n },\n }\n }\n\n return undefined\n}\n","/**\n * Readers for `JSON.parse` output. A catalog's payload is third-party data, so\n * it enters as `unknown` and every field goes through a guard: a renamed or\n * nulled field reads as absent instead of crashing the fetch, or producing a\n * candidate with `undefined` where a string was promised.\n */\nexport const isJsonRecord = (\n value: unknown,\n): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value)\n\nexport const readString = (\n record: Record<string, unknown>,\n key: string,\n): string | undefined => {\n const value = record[key]\n\n if (typeof value !== \"string\") return undefined\n\n const trimmed = value.trim()\n\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport const readNumber = (\n record: Record<string, unknown>,\n key: string,\n): number | undefined => {\n const value = record[key]\n\n return typeof value === \"number\" && Number.isFinite(value) ? value : undefined\n}\n\nexport const readStringArray = (\n record: Record<string, unknown>,\n key: string,\n): ReadonlyArray<string> | undefined => {\n const value = record[key]\n\n if (!Array.isArray(value)) return undefined\n\n const strings = value\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0)\n\n return strings.length > 0 ? strings : undefined\n}\n\nexport const readRecordArray = (\n record: Record<string, unknown>,\n key: string,\n): ReadonlyArray<Record<string, unknown>> => {\n const value = record[key]\n\n if (!Array.isArray(value)) return []\n\n return value.filter(isJsonRecord)\n}\n","import {\n isJsonRecord,\n readNumber,\n readRecordArray,\n readString,\n readStringArray,\n} from \"../../utils/json.ts\"\n\n/**\n * One entry of an Open Library `search.json` response, field names mirroring\n * the API (same rule as the archive parsers: a parsed source looks like its\n * source document). Every field is optional — the API omits what it doesn't\n * have, and the `fields` parameter decides what it may return at all.\n *\n * @see https://openlibrary.org/dev/docs/api/search\n */\nexport type OpenLibraryDoc = {\n /** Work key, e.g. `/works/OL45883W`. */\n readonly key?: string\n readonly title?: string\n readonly subtitle?: string\n readonly author_name?: ReadonlyArray<string>\n readonly first_publish_year?: number\n /** MARC 21 language codes (`eng`, `fre`), not BCP 47. */\n readonly language?: ReadonlyArray<string>\n readonly subject?: ReadonlyArray<string>\n readonly number_of_pages_median?: number\n /** Cover id, addressing `covers.openlibrary.org`. */\n readonly cover_i?: number\n /** Project Gutenberg ebook ids attached to the work's editions. */\n readonly id_project_gutenberg?: ReadonlyArray<string>\n}\n\nconst parseDoc = (record: Record<string, unknown>): OpenLibraryDoc => ({\n key: readString(record, \"key\"),\n title: readString(record, \"title\"),\n subtitle: readString(record, \"subtitle\"),\n author_name: readStringArray(record, \"author_name\"),\n first_publish_year: readNumber(record, \"first_publish_year\"),\n language: readStringArray(record, \"language\"),\n subject: readStringArray(record, \"subject\"),\n number_of_pages_median: readNumber(record, \"number_of_pages_median\"),\n cover_i: readNumber(record, \"cover_i\"),\n id_project_gutenberg: readStringArray(record, \"id_project_gutenberg\"),\n})\n\n/**\n * Reads the `docs` of a `search.json` payload. A payload that isn't a record,\n * or carries no `docs` array, yields an empty list: an unexpected response\n * shape is \"this catalog has nothing for us\", not a crash.\n */\nexport const parseOpenLibrarySearchResponse = (\n payload: unknown,\n): ReadonlyArray<OpenLibraryDoc> => {\n if (!isJsonRecord(payload)) return []\n\n return readRecordArray(payload, \"docs\").map(parseDoc)\n}\n","/**\n * MARC 21 language codes → BCP 47 primary subtags, for the codes a general\n * book catalog actually returns. Open Library reports `language` as MARC\n * (`eng`, `fre`), while {@link ResolvedMetadata.languages} is BCP 47 by\n * contract, so the mapping is part of normalizing — not a nicety.\n *\n * Both MARC variants are listed where they differ (bibliographic `fre` and\n * terminology `fra`), since records in the wild carry either.\n */\nconst MARC_TO_BCP47: Readonly<Record<string, string>> = {\n afr: \"af\",\n alb: \"sq\",\n amh: \"am\",\n ara: \"ar\",\n arm: \"hy\",\n asm: \"as\",\n aze: \"az\",\n baq: \"eu\",\n bel: \"be\",\n ben: \"bn\",\n bos: \"bs\",\n bul: \"bg\",\n bur: \"my\",\n cat: \"ca\",\n ces: \"cs\",\n chi: \"zh\",\n cym: \"cy\",\n cze: \"cs\",\n dan: \"da\",\n deu: \"de\",\n dut: \"nl\",\n ell: \"el\",\n eng: \"en\",\n epo: \"eo\",\n est: \"et\",\n eus: \"eu\",\n fao: \"fo\",\n fas: \"fa\",\n fin: \"fi\",\n fra: \"fr\",\n fre: \"fr\",\n geo: \"ka\",\n ger: \"de\",\n gle: \"ga\",\n glg: \"gl\",\n gre: \"el\",\n guj: \"gu\",\n heb: \"he\",\n hin: \"hi\",\n hrv: \"hr\",\n hun: \"hu\",\n hye: \"hy\",\n ice: \"is\",\n ind: \"id\",\n isl: \"is\",\n ita: \"it\",\n jpn: \"ja\",\n kan: \"kn\",\n kat: \"ka\",\n kaz: \"kk\",\n khm: \"km\",\n kor: \"ko\",\n lao: \"lo\",\n lat: \"la\",\n lav: \"lv\",\n lit: \"lt\",\n ltz: \"lb\",\n mac: \"mk\",\n mal: \"ml\",\n mar: \"mr\",\n may: \"ms\",\n mkd: \"mk\",\n mlt: \"mt\",\n mon: \"mn\",\n msa: \"ms\",\n mya: \"my\",\n nep: \"ne\",\n nld: \"nl\",\n nno: \"nn\",\n nob: \"nb\",\n nor: \"no\",\n ori: \"or\",\n pan: \"pa\",\n per: \"fa\",\n pol: \"pl\",\n por: \"pt\",\n rom: \"ro\",\n ron: \"ro\",\n rum: \"ro\",\n rus: \"ru\",\n scc: \"sr\",\n scr: \"hr\",\n sin: \"si\",\n slk: \"sk\",\n slo: \"sk\",\n slv: \"sl\",\n snd: \"sd\",\n spa: \"es\",\n sqi: \"sq\",\n srp: \"sr\",\n swa: \"sw\",\n swe: \"sv\",\n tam: \"ta\",\n tel: \"te\",\n tgl: \"tl\",\n tha: \"th\",\n tur: \"tr\",\n ukr: \"uk\",\n urd: \"ur\",\n uzb: \"uz\",\n vie: \"vi\",\n wel: \"cy\",\n yid: \"yi\",\n zho: \"zh\",\n}\n\n/**\n * MARC placeholders that say \"no single language\": keeping them would put a\n * non-language in a BCP 47 field, so they resolve to nothing.\n */\nconst NON_LANGUAGE_CODES = new Set([\"mul\", \"und\", \"zxx\", \"sgn\"])\n\n/**\n * Normalizes one MARC language code to its BCP 47 primary subtag. Unknown\n * codes (the long tail of MARC) pass through lowercased rather than being\n * dropped — a language we can't name is still a language the record stated.\n */\nexport const marcLanguageToBcp47 = (code: string): string | undefined => {\n const normalized = code.trim().toLowerCase()\n\n if (normalized.length === 0) return undefined\n if (NON_LANGUAGE_CODES.has(normalized)) return undefined\n\n return MARC_TO_BCP47[normalized] ?? normalized\n}\n","import type {\n ResolvedMetadata,\n ResolvedMetadataHome,\n} from \"@prose-reader/archive-reader\"\nimport { omitUndefined } from \"../../utils/omitUndefined.ts\"\nimport { marcLanguageToBcp47 } from \"./marcLanguage.ts\"\nimport type { OpenLibraryDoc } from \"./parse.ts\"\n\n/**\n * A popular work carries hundreds of subject headings, most of them long-tail\n * noise (\"Accessible book\", \"Protected DAISY\"), which would dwarf the entity a\n * consumer persists. The API puts the meaningful ones first.\n */\nexport const OPEN_LIBRARY_MAX_SUBJECTS = 25\n\n/**\n * Where every {@link OpenLibraryDoc} field lands in {@link ResolvedMetadata},\n * compile-enforced: adding a field to the parser without declaring its home is\n * a type error. The archive parsers' losslessness contract, and the mapping\n * documentation.\n */\nexport const openLibraryMetadataHomes = {\n key: \"identifiers\",\n title: \"title\",\n subtitle: \"title\",\n author_name: \"contributors\",\n first_publish_year: \"publication.original.date\",\n language: \"languages\",\n subject: \"subjects\",\n number_of_pages_median: \"numberOfPages\",\n cover_i: \"cover\",\n id_project_gutenberg: \"identifiers\",\n} as const satisfies Record<\n keyof OpenLibraryDoc,\n ResolvedMetadataHome | \"identifiers\"\n>\n\nexport const OPEN_LIBRARY_IDENTIFIER_SCHEME = \"OpenLibrary\"\nconst PROJECT_GUTENBERG_IDENTIFIER_SCHEME = \"ProjectGutenberg\"\n\nconst emptyToUndefined = <T>(\n values: ReadonlyArray<T>,\n): ReadonlyArray<T> | undefined => (values.length > 0 ? values : undefined)\n\n/**\n * Normalizes one search hit into the cross-format vocabulary. Two choices\n * worth stating:\n *\n * - **`title` folds in `subtitle`** (`Dune: Messiah`): the vocabulary has one\n * title field, and an OPF `dc:title` normally carries the subtitle too, so\n * comparing a bare title against a full one would cost match score.\n * - **`isbn` is set only for an ISBN lookup**, to the queried one — the API\n * answered \"this work has that ISBN\", a fact about the record. A hit\n * describes a *work*, whose editions each have their own ISBN, so picking\n * one out of a title search would be fabrication.\n */\nexport const resolveOpenLibraryDoc = (\n doc: OpenLibraryDoc,\n options: {\n readonly coversBaseUrl: string\n /** The ISBN this record was looked up by, when it was. */\n readonly isbn?: string\n /** The source identifier that an exact Gutenberg-id lookup confirmed. */\n readonly matchedProjectGutenbergIdentifier?: {\n readonly value: string\n readonly scheme?: string\n }\n },\n): ResolvedMetadata => {\n const title =\n doc.title !== undefined && doc.subtitle !== undefined\n ? `${doc.title}: ${doc.subtitle}`\n : doc.title\n\n const languages = emptyToUndefined([\n ...new Set(\n (doc.language ?? [])\n .map(marcLanguageToBcp47)\n .filter((language): language is string => language !== undefined),\n ),\n ])\n\n const identifiers = [\n ...(options.isbn !== undefined\n ? [{ value: options.isbn, scheme: \"ISBN\" }]\n : []),\n ...(options.matchedProjectGutenbergIdentifier !== undefined\n ? [options.matchedProjectGutenbergIdentifier]\n : []),\n ...(doc.id_project_gutenberg ?? []).map((value) => ({\n value,\n scheme: PROJECT_GUTENBERG_IDENTIFIER_SCHEME,\n })),\n ...(doc.key !== undefined\n ? [{ value: doc.key, scheme: OPEN_LIBRARY_IDENTIFIER_SCHEME }]\n : []),\n ]\n\n return omitUndefined({\n title,\n cover:\n doc.cover_i !== undefined\n ? {\n // absolute, not container-relative: a catalog addresses its\n // covers in its own space, with nothing to rebase onto\n uri: `${options.coversBaseUrl}/b/id/${doc.cover_i}-L.jpg`,\n mediaType: \"image/jpeg\",\n confidence: \"derived\" as const,\n }\n : undefined,\n publication:\n doc.first_publish_year !== undefined\n ? { original: { date: { year: doc.first_publish_year } } }\n : undefined,\n languages,\n subjects: emptyToUndefined(\n (doc.subject ?? []).slice(0, OPEN_LIBRARY_MAX_SUBJECTS),\n ),\n contributors: emptyToUndefined(\n (doc.author_name ?? []).map((name) => ({\n name,\n roles: [\"author\" as const],\n })),\n ),\n numberOfPages: doc.number_of_pages_median,\n isbn: options.isbn,\n identifiers: emptyToUndefined(identifiers),\n })\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport type {\n MetadataCandidate,\n MetadataProvider,\n MetadataProviderContext,\n} from \"../../types/provider.ts\"\nimport { metadataAuthors } from \"../../utils/metadataAuthors.ts\"\nimport {\n type ProjectGutenbergLookup,\n projectGutenbergLookupFromMetadata,\n} from \"../projectGutenberg/identifier.ts\"\nimport { MetadataProviderResponseError } from \"../responseError.ts\"\nimport { type OpenLibraryDoc, parseOpenLibrarySearchResponse } from \"./parse.ts\"\nimport { resolveOpenLibraryDoc } from \"./resolve.ts\"\n\nexport const OPEN_LIBRARY_PROVIDER_ID = \"openLibrary\"\n\nconst DEFAULT_BASE_URL = \"https://openlibrary.org\"\nconst DEFAULT_COVERS_BASE_URL = \"https://covers.openlibrary.org\"\n\n/**\n * Only what {@link resolveOpenLibraryDoc} maps: `search.json` returns every\n * edition key and IA identifier by default, so `fields` is the difference\n * between a few hundred bytes per hit and a few hundred kilobytes.\n */\nconst SEARCH_FIELDS = [\n \"key\",\n \"title\",\n \"subtitle\",\n \"author_name\",\n \"first_publish_year\",\n \"language\",\n \"subject\",\n \"number_of_pages_median\",\n \"cover_i\",\n \"id_project_gutenberg\",\n].join(\",\")\n\nexport type OpenLibraryProviderOptions = {\n /** API origin. Defaults to `https://openlibrary.org`. */\n readonly baseUrl?: string\n /** Cover service origin. Defaults to `https://covers.openlibrary.org`. */\n readonly coversBaseUrl?: string\n /** For tests, a custom agent, or a caching layer. Defaults to the global one. */\n readonly fetch?: typeof globalThis.fetch\n /**\n * Open Library's API etiquette asks for an identifying `User-Agent` (app\n * name + contact) and throttles anonymous traffic harder.\n */\n readonly userAgent?: string\n}\n\nconst searchTerms = (\n metadata: ResolvedMetadata,\n): Record<string, string> | undefined => {\n const title = metadata.title?.trim()\n\n if (title === undefined || title.length === 0) return undefined\n\n const author = metadataAuthors(metadata)[0]\n\n return {\n title,\n ...(author !== undefined ? { author } : {}),\n }\n}\n\n/**\n * Open Library — the Internet Archive's open bibliographic catalog. Free, no\n * key, covers books broadly (much less so comics and manga).\n *\n * ```ts\n * const provider = createOpenLibraryProvider({\n * userAgent: \"MyReader/1.0 (contact@example.com)\",\n * })\n * ```\n *\n * Lookup strategy, at most three requests: an **ISBN search** when the book\n * states one; an exact **Project Gutenberg id search** when an identifier is\n * an official Gutenberg URL; then a **title (+ first author) search**. A query\n * with none of those terms yields no candidates rather than a fishing\n * expedition.\n */\nexport const createOpenLibraryProvider = (\n options: OpenLibraryProviderOptions = {},\n): MetadataProvider => {\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL\n const coversBaseUrl = options.coversBaseUrl ?? DEFAULT_COVERS_BASE_URL\n\n const searchDocs = async (\n terms: Record<string, string>,\n context: MetadataProviderContext,\n ): Promise<ReadonlyArray<OpenLibraryDoc>> => {\n const url = new URL(\"/search.json\", baseUrl)\n\n for (const [key, value] of Object.entries(terms)) {\n url.searchParams.set(key, value)\n }\n\n url.searchParams.set(\"fields\", SEARCH_FIELDS)\n url.searchParams.set(\"limit\", String(context.limit))\n\n // read lazily so a consumer stubbing the global afterwards still wins\n const doFetch = options.fetch ?? globalThis.fetch\n const response = await doFetch(url.toString(), {\n signal: context.signal,\n headers: {\n Accept: \"application/json\",\n ...(options.userAgent !== undefined\n ? { \"User-Agent\": options.userAgent }\n : {}),\n },\n })\n\n if (!response.ok) {\n throw new MetadataProviderResponseError(\n response.status,\n `Open Library search failed with status ${response.status}`,\n )\n }\n\n return parseOpenLibrarySearchResponse(await response.json())\n }\n\n const toCandidates = (\n docs: ReadonlyArray<OpenLibraryDoc>,\n options: {\n readonly isbn?: string\n readonly confirmedProjectGutenberg?: ProjectGutenbergLookup\n } = {},\n ): ReadonlyArray<MetadataCandidate> =>\n docs.map((doc) => ({\n metadata: resolveOpenLibraryDoc(doc, {\n coversBaseUrl,\n isbn: options.isbn,\n matchedProjectGutenbergIdentifier:\n options.confirmedProjectGutenberg?.identifier,\n }),\n id: doc.key,\n url: doc.key !== undefined ? `${baseUrl}${doc.key}` : undefined,\n raw: doc,\n }))\n\n return {\n id: OPEN_LIBRARY_PROVIDER_ID,\n name: \"Open Library\",\n search: async (metadata, context) => {\n const isbn = metadata.isbn?.trim() || undefined\n const projectGutenberg = projectGutenbergLookupFromMetadata(metadata)\n\n if (isbn !== undefined) {\n const docs = await searchDocs({ isbn }, context)\n\n if (docs.length > 0) return toCandidates(docs, { isbn })\n }\n\n if (projectGutenberg !== undefined) {\n const docs = await searchDocs(\n { q: `id_project_gutenberg:${projectGutenberg.id}` },\n context,\n )\n\n if (docs.length > 0) {\n return toCandidates(docs, {\n confirmedProjectGutenberg: projectGutenberg,\n })\n }\n }\n\n const terms = searchTerms(metadata)\n\n if (terms === undefined) return []\n\n return toCandidates(await searchDocs(terms, context))\n },\n }\n}\n","import type { XmlElement, XmlNodeBase } from \"xmldoc\"\nimport { XmlDocument } from \"xmldoc\"\n\nexport type ProjectGutenbergContributor = {\n readonly name: string\n /** MARC relator code, or `aut` for `dcterms:creator`. */\n readonly role: string\n}\n\nexport type ProjectGutenbergCover = {\n readonly uri: string\n readonly mediaType?: string\n}\n\n/** The subset of one official per-eBook RDF record that we consume. */\nexport type ProjectGutenbergRecord = {\n readonly id: string\n readonly title?: string\n readonly publisher?: string\n readonly issued?: string\n /** Original print publication statement (`MARC 260`). */\n readonly originalPublication?: string\n readonly rights?: string\n readonly description?: string\n readonly summary?: string\n readonly languages?: ReadonlyArray<string>\n readonly subjects?: ReadonlyArray<string>\n readonly bookshelves?: ReadonlyArray<string>\n readonly contributors?: ReadonlyArray<ProjectGutenbergContributor>\n readonly cover?: ProjectGutenbergCover\n}\n\nconst elementLocalName = (name: string): string =>\n name.includes(\":\") ? name.slice(name.lastIndexOf(\":\") + 1) : name\n\nconst isXmlElement = (node: XmlNodeBase): node is XmlElement =>\n node.type === \"element\"\n\nconst childrenNamedLocal = (\n parent: XmlElement,\n localName: string,\n): ReadonlyArray<XmlElement> =>\n parent.children.filter(\n (child): child is XmlElement =>\n isXmlElement(child) &&\n elementLocalName(child.name).toLowerCase() === localName.toLowerCase(),\n )\n\nconst childNamedLocal = (\n parent: XmlElement,\n localName: string,\n): XmlElement | undefined => childrenNamedLocal(parent, localName)[0]\n\nconst text = (element: XmlElement | undefined): string | undefined => {\n const trimmed = element?.val.trim()\n\n return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined\n}\n\nconst attributeNamedLocal = (\n element: XmlElement,\n localName: string,\n): string | undefined => {\n for (const [name, value] of Object.entries(element.attr)) {\n if (elementLocalName(name).toLowerCase() !== localName.toLowerCase()) {\n continue\n }\n\n const trimmed = value.trim()\n\n if (trimmed.length > 0) return trimmed\n }\n\n return undefined\n}\n\nconst valuesFromDescriptions = (\n ebook: XmlElement,\n localName: string,\n): ReadonlyArray<string> =>\n childrenNamedLocal(ebook, localName).flatMap((container) => {\n const description = childNamedLocal(container, \"Description\")\n const value = text(childNamedLocal(description ?? container, \"value\"))\n\n return value !== undefined ? [value] : []\n })\n\nconst projectGutenbergIdFromEbook = (ebook: XmlElement): string | undefined => {\n const about = attributeNamedLocal(ebook, \"about\")\n const id =\n about !== undefined ? /(?:^|\\/)ebooks\\/(\\d+)$/.exec(about)?.[1] : undefined\n\n if (id === undefined) return undefined\n\n const normalized = id.replace(/^0+(?=\\d)/, \"\")\n\n return normalized !== \"0\" ? normalized : undefined\n}\n\nconst contributorsFromEbook = (\n ebook: XmlElement,\n): ReadonlyArray<ProjectGutenbergContributor> => {\n const contributors: ProjectGutenbergContributor[] = []\n\n for (const child of ebook.children) {\n if (!isXmlElement(child)) continue\n\n const isCreator = child.name.toLowerCase() === \"dcterms:creator\"\n const isMarcRelator = child.name.toLowerCase().startsWith(\"marcrel:\")\n\n if (!isCreator && !isMarcRelator) continue\n\n const name = text(\n childNamedLocal(childNamedLocal(child, \"agent\") ?? child, \"name\"),\n )\n\n if (name === undefined) continue\n\n contributors.push({\n name,\n role: isCreator ? \"aut\" : elementLocalName(child.name).toLowerCase(),\n })\n }\n\n return contributors\n}\n\nconst subjectValuesFromEbook = (ebook: XmlElement): ReadonlyArray<string> =>\n childrenNamedLocal(ebook, \"subject\").flatMap((container) => {\n const description = childNamedLocal(container, \"Description\")\n const memberOf =\n description !== undefined\n ? attributeNamedLocal(\n childNamedLocal(description, \"memberOf\") ?? description,\n \"resource\",\n )\n : undefined\n const value = text(\n description !== undefined\n ? childNamedLocal(description, \"value\")\n : undefined,\n )\n\n return value !== undefined && memberOf?.endsWith(\"/LCSH\") === true\n ? [value]\n : []\n })\n\nconst coverFromEbook = (\n ebook: XmlElement,\n): ProjectGutenbergCover | undefined => {\n const covers = childrenNamedLocal(ebook, \"hasFormat\").flatMap(\n (formatContainer) => {\n const file = childNamedLocal(formatContainer, \"file\")\n\n if (file === undefined) return []\n\n const uri = attributeNamedLocal(file, \"about\")\n const mediaType = childrenNamedLocal(file, \"format\")\n .map((format) => {\n const description = childNamedLocal(format, \"Description\")\n\n return text(childNamedLocal(description ?? format, \"value\"))\n })\n .find((value) => value?.startsWith(\"image/\") === true)\n\n if (\n uri === undefined ||\n mediaType === undefined ||\n !/\\.cover\\.(?:medium|small)\\.[a-z0-9]+(?:$|[?#])/i.test(uri)\n ) {\n return []\n }\n\n return [{ uri, mediaType }]\n },\n )\n\n return (\n covers.find((cover) => /\\.cover\\.medium\\./i.test(cover.uri)) ?? covers[0]\n )\n}\n\nconst emptyToUndefined = <T>(\n values: ReadonlyArray<T>,\n): ReadonlyArray<T> | undefined => (values.length > 0 ? values : undefined)\n\n/**\n * Parses one official Project Gutenberg per-eBook RDF document. Unsupported\n * XML yields `undefined`; malformed XML throws with a provider-specific label.\n */\nexport const parseProjectGutenbergRdf = (\n xml: string,\n): ProjectGutenbergRecord | undefined => {\n let document: XmlDocument\n\n try {\n document = new XmlDocument(xml)\n } catch (cause) {\n const message = cause instanceof Error ? cause.message : String(cause)\n throw new Error(`Project Gutenberg RDF is malformed: ${message}`, { cause })\n }\n\n if (elementLocalName(document.name).toLowerCase() !== \"rdf\") {\n return undefined\n }\n\n const ebook = childNamedLocal(document, \"ebook\")\n\n if (ebook === undefined) return undefined\n\n const id = projectGutenbergIdFromEbook(ebook)\n\n if (id === undefined) return undefined\n\n const languages = [...new Set(valuesFromDescriptions(ebook, \"language\"))]\n const subjects = [...new Set(subjectValuesFromEbook(ebook))]\n const bookshelves = [...new Set(valuesFromDescriptions(ebook, \"bookshelf\"))]\n const contributors = contributorsFromEbook(ebook)\n\n return {\n id,\n title: text(childNamedLocal(ebook, \"title\")),\n publisher: text(childNamedLocal(ebook, \"publisher\")),\n issued: text(childNamedLocal(ebook, \"issued\")),\n originalPublication: text(childNamedLocal(ebook, \"marc260\")),\n rights: text(childNamedLocal(ebook, \"rights\")),\n description: text(childNamedLocal(ebook, \"description\")),\n summary: text(childNamedLocal(ebook, \"marc520\")),\n languages: emptyToUndefined(languages),\n subjects: emptyToUndefined(subjects),\n bookshelves: emptyToUndefined(bookshelves),\n contributors: emptyToUndefined(contributors),\n cover: coverFromEbook(ebook),\n }\n}\n","import type {\n ResolvedContributor,\n ResolvedContributorRole,\n ResolvedCover,\n ResolvedDate,\n ResolvedMetadata,\n ResolvedMetadataHome,\n ResolvedPublication,\n} from \"@prose-reader/archive-reader\"\nimport { omitUndefined } from \"../../utils/omitUndefined.ts\"\nimport { PROJECT_GUTENBERG_IDENTIFIER_SCHEME } from \"./identifier.ts\"\nimport type { ProjectGutenbergRecord } from \"./parse.ts\"\n\nexport const PROJECT_GUTENBERG_MAX_SUBJECTS = 25\n\n/** Compile-enforced map from every parsed RDF field to its resolved home. */\nexport const projectGutenbergMetadataHomes = {\n id: \"identifiers\",\n title: \"title\",\n publisher: \"publication.edition.publisher\",\n issued: \"publication.edition.date\",\n originalPublication: \"publication.original\",\n rights: \"rights\",\n description: \"description\",\n summary: \"description\",\n languages: \"languages\",\n subjects: \"subjects\",\n bookshelves: \"subjects\",\n contributors: \"contributors\",\n cover: \"cover\",\n} as const satisfies Record<\n keyof ProjectGutenbergRecord,\n ResolvedMetadataHome | \"identifiers\"\n>\n\nconst MARC_RELATOR_TO_ROLE: Readonly<\n Partial<Record<string, ResolvedContributorRole>>\n> = {\n art: \"artist\",\n aut: \"author\",\n clr: \"colorist\",\n ctb: \"contributor\",\n edt: \"editor\",\n ill: \"illustrator\",\n nrt: \"narrator\",\n trl: \"translator\",\n}\n\nconst resolvedContributors = (\n record: ProjectGutenbergRecord,\n): ReadonlyArray<ResolvedContributor> | undefined => {\n const contributors = new Map<string, { name: string; roles: string[] }>()\n\n for (const contributor of record.contributors ?? []) {\n const key = contributor.name.trim().toLowerCase()\n const role =\n MARC_RELATOR_TO_ROLE[contributor.role.trim().toLowerCase()] ??\n contributor.role\n const existing = contributors.get(key)\n\n if (existing === undefined) {\n contributors.set(key, { name: contributor.name, roles: [role] })\n } else if (!existing.roles.includes(role)) {\n existing.roles.push(role)\n }\n }\n\n return contributors.size > 0 ? [...contributors.values()] : undefined\n}\n\nconst resolvedIssuedDate = (\n issued: string | undefined,\n): ResolvedDate | undefined => {\n if (issued === undefined) return undefined\n\n const match = /^(\\d{4})(?:-(\\d{2})(?:-(\\d{2}))?)?$/.exec(issued.trim())\n\n if (match === null) return undefined\n\n const year = Number(match[1])\n const month = match[2] !== undefined ? Number(match[2]) : undefined\n const day = match[3] !== undefined ? Number(match[3]) : undefined\n\n if (\n !Number.isInteger(year) ||\n year <= 0 ||\n (month !== undefined && (month < 1 || month > 12)) ||\n (day !== undefined && (day < 1 || day > 31))\n ) {\n return undefined\n }\n\n return {\n year,\n ...(month !== undefined ? { month } : {}),\n ...(day !== undefined ? { day } : {}),\n }\n}\n\nconst marc260Subfields = (\n statement: string,\n code: string,\n): ReadonlyArray<string> =>\n statement\n .split(\"$\")\n .slice(1)\n .flatMap((subfield) =>\n subfield[0]?.toLowerCase() === code.toLowerCase()\n ? [subfield.slice(1).trim()]\n : [],\n )\n .filter((value) => value.length > 0)\n\nconst originalPublicationFromMarc260 = (\n statement: string | undefined,\n): ResolvedPublication | undefined => {\n if (statement === undefined) return undefined\n\n const publisherValues = marc260Subfields(statement, \"b\")\n const rawPublisher =\n publisherValues.length === 1 ? publisherValues[0] : undefined\n const publisher = rawPublisher?.replace(/[\\s,;:/]+$/u, \"\").trim()\n const dateValues = marc260Subfields(statement, \"c\")\n const years = [\n ...new Set(\n dateValues.flatMap((value) =>\n [...value.matchAll(/\\d{4}/g)].flatMap((match) =>\n match[0] !== undefined ? [Number(match[0])] : [],\n ),\n ),\n ),\n ]\n const date = years.length === 1 ? { year: years[0] } : undefined\n const details = omitUndefined({\n date,\n publisher:\n publisher !== undefined &&\n publisher.length > 0 &&\n !/^\\[?s\\.n\\.?\\]?$/i.test(publisher)\n ? publisher\n : undefined,\n })\n\n return details.date !== undefined || details.publisher !== undefined\n ? details\n : undefined\n}\n\nconst absoluteHttpUrl = (\n value: string,\n baseUrl: string,\n): string | undefined => {\n try {\n const url = new URL(value, baseUrl)\n\n return url.protocol === \"http:\" || url.protocol === \"https:\"\n ? url.toString()\n : undefined\n } catch {\n return undefined\n }\n}\n\nconst emptyToUndefined = <T>(\n values: ReadonlyArray<T>,\n): ReadonlyArray<T> | undefined => (values.length > 0 ? values : undefined)\n\nexport const resolveProjectGutenbergRecord = (\n record: ProjectGutenbergRecord,\n options: {\n readonly baseUrl: string\n readonly matchedIdentifier: {\n readonly value: string\n readonly scheme?: string\n }\n },\n): ResolvedMetadata => {\n const identifiers = [\n options.matchedIdentifier,\n { value: record.id, scheme: PROJECT_GUTENBERG_IDENTIFIER_SCHEME },\n ].filter(\n (identifier, index, all) =>\n all.findIndex(\n (other) =>\n other.value.trim().toLowerCase() ===\n identifier.value.trim().toLowerCase() &&\n other.scheme?.trim().toLowerCase() ===\n identifier.scheme?.trim().toLowerCase(),\n ) === index,\n )\n const subjects = [\n ...new Set([...(record.subjects ?? []), ...(record.bookshelves ?? [])]),\n ].slice(0, PROJECT_GUTENBERG_MAX_SUBJECTS)\n const coverUri =\n record.cover !== undefined\n ? absoluteHttpUrl(record.cover.uri, options.baseUrl)\n : undefined\n const cover: ResolvedCover | undefined =\n coverUri !== undefined\n ? {\n uri: coverUri,\n mediaType: record.cover?.mediaType,\n confidence: \"derived\",\n }\n : undefined\n const edition = omitUndefined({\n date: resolvedIssuedDate(record.issued),\n publisher: record.publisher,\n })\n const original = originalPublicationFromMarc260(record.originalPublication)\n const publication = omitUndefined({\n original,\n edition:\n edition.date !== undefined || edition.publisher !== undefined\n ? edition\n : undefined,\n })\n\n return omitUndefined({\n title: record.title,\n publication:\n publication.original !== undefined || publication.edition !== undefined\n ? publication\n : undefined,\n description: record.summary ?? record.description,\n rights: record.rights,\n languages: emptyToUndefined([...new Set(record.languages ?? [])]),\n subjects: emptyToUndefined(subjects),\n contributors: resolvedContributors(record),\n identifiers,\n cover,\n })\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport type {\n MetadataProvider,\n MetadataProviderContext,\n} from \"../../types/provider.ts\"\nimport { MetadataProviderResponseError } from \"../responseError.ts\"\nimport { projectGutenbergLookupFromMetadata } from \"./identifier.ts\"\nimport { parseProjectGutenbergRdf } from \"./parse.ts\"\nimport { resolveProjectGutenbergRecord } from \"./resolve.ts\"\n\nexport const PROJECT_GUTENBERG_PROVIDER_ID = \"projectGutenberg\"\n\nconst DEFAULT_BASE_URL = \"https://www.gutenberg.org\"\n\nexport type ProjectGutenbergProviderOptions = {\n /** Catalog origin. Defaults to `https://www.gutenberg.org`. */\n readonly baseUrl?: string\n /** For tests, a custom agent, or a caching layer. Defaults to the global one. */\n readonly fetch?: typeof globalThis.fetch\n /** Optional identifying user agent sent with the RDF request. */\n readonly userAgent?: string\n}\n\n/**\n * Project Gutenberg's official catalog, queried through its per-eBook RDF\n * records. The provider performs exact identifier lookups only: official\n * Gutenberg URLs and numeric `ProjectGutenberg` identifiers are accepted;\n * titles are deliberately never used to crawl or fuzzily search the site.\n */\nexport const createProjectGutenbergProvider = (\n options: ProjectGutenbergProviderOptions = {},\n): MetadataProvider => {\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL\n\n const search = async (\n metadata: ResolvedMetadata,\n context: MetadataProviderContext,\n ) => {\n const lookup = projectGutenbergLookupFromMetadata(metadata)\n\n if (lookup === undefined) return []\n\n const recordUrl = new URL(\n `/cache/epub/${lookup.id}/pg${lookup.id}.rdf`,\n baseUrl,\n )\n const doFetch = options.fetch ?? globalThis.fetch\n const response = await doFetch(recordUrl.toString(), {\n signal: context.signal,\n headers: {\n Accept: \"application/rdf+xml, application/xml;q=0.9\",\n ...(options.userAgent !== undefined\n ? { \"User-Agent\": options.userAgent }\n : {}),\n },\n })\n\n if (response.status === 404) return []\n\n if (!response.ok) {\n throw new MetadataProviderResponseError(\n response.status,\n `Project Gutenberg metadata lookup failed with status ${response.status}`,\n )\n }\n\n const record = parseProjectGutenbergRdf(await response.text())\n\n if (record === undefined || record.id !== lookup.id) {\n throw new Error(\n `Project Gutenberg RDF did not contain the requested eBook ${lookup.id}`,\n )\n }\n\n const bookUrl = new URL(`/ebooks/${lookup.id}`, baseUrl).toString()\n\n return [\n {\n id: record.id,\n url: bookUrl,\n raw: record,\n metadata: resolveProjectGutenbergRecord(record, {\n baseUrl,\n matchedIdentifier: lookup.identifier,\n }),\n },\n ]\n }\n\n return {\n id: PROJECT_GUTENBERG_PROVIDER_ID,\n name: \"Project Gutenberg\",\n search,\n }\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport { metadataAuthors } from \"./metadataAuthors.ts\"\n\n/**\n * Whether a catalog has anything to go on, so a lookup with nothing to ask\n * about costs no round trip. Publication details, a language, or a page count\n * narrow a search but cannot start one.\n */\nexport const hasSearchTerms = (metadata: ResolvedMetadata): boolean =>\n [\n metadata.title,\n metadata.isbn,\n metadata.gtin,\n metadata.belongsTo?.series?.[0]?.name,\n ].some((value) => value !== undefined && value.trim().length > 0) ||\n (metadata.identifiers ?? []).length > 0 ||\n metadataAuthors(metadata).length > 0\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/utils/metadataAuthors.ts","../src/utils/toIsbn13.ts","../src/match/similarity.ts","../src/match/scoreMetadataCandidate.ts","../src/providers/responseError.ts","../src/report.ts","../src/utils/omitUndefined.ts","../src/fetchMetadata.ts","../src/providers/projectGutenberg/identifier.ts","../src/utils/json.ts","../src/providers/openLibrary/parse.ts","../src/providers/openLibrary/marcLanguage.ts","../src/providers/openLibrary/resolve.ts","../src/providers/openLibrary/createOpenLibraryProvider.ts","../src/providers/projectGutenberg/parse.ts","../src/providers/projectGutenberg/resolve.ts","../src/providers/projectGutenberg/createProjectGutenbergProvider.ts","../src/utils/hasSearchTerms.ts"],"sourcesContent":["import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\n\n/**\n * The names to match a publication on: those credited as `author`, else — for\n * a publication crediting nobody as one, like a comic archive listing only a\n * penciler — every contributor. Order is preserved (sources list the lead\n * creator first) and duplicates are dropped.\n */\nexport const metadataAuthors = (\n metadata: ResolvedMetadata,\n): ReadonlyArray<string> => {\n const contributors = metadata.contributors ?? []\n const authors = contributors.filter((contributor) =>\n contributor.roles.includes(\"author\"),\n )\n const names = (authors.length > 0 ? authors : contributors)\n .map((contributor) => contributor.name.trim())\n .filter((name) => name.length > 0)\n\n return [...new Set(names)]\n}\n","import { normalizeIsbn } from \"@prose-reader/archive-reader\"\n\nconst ISBN_13 = /^\\d{13}$/\nconst ISBN_10 = /^\\d{9}[\\dX]$/\n\n/**\n * Put an ISBN in its 13-digit form so the two printed on the same book compare\n * equal. A book carries an ISBN-10 in its OPF and an ISBN-13 in a catalog\n * record all the time; comparing them verbatim would read as \"different\n * books\", which — ISBN being a decisive match signal — is the most damaging\n * false negative the matcher can produce.\n *\n * Input is put through `normalizeIsbn`, the same function that produces\n * {@link ResolvedMetadata.isbn} in the first place, so the hyphenated and\n * prefixed forms a human types (`978-0-441-01359-3`, `ISBN 0-441-01359-7`)\n * are recognized rather than silently dropped.\n *\n * Returns `undefined` when nothing ISBN-shaped can be recovered (check digits\n * are not verified: a wrong one still identifies the intended book).\n */\nexport const toIsbn13 = (isbn: string): string | undefined => {\n const normalized = normalizeIsbn(isbn)\n\n if (normalized === undefined) return undefined\n if (ISBN_13.test(normalized)) return normalized\n if (!ISBN_10.test(normalized)) return undefined\n\n // ISBN-10 → ISBN-13: prefix the 978 Bookland code, drop the ISBN-10 check\n // digit, recompute the EAN-13 one (weights 1,3,1,3…).\n const core = `978${normalized.slice(0, 9)}`\n let sum = 0\n\n for (let index = 0; index < core.length; index++) {\n // charCodeAt over indexing: `noUncheckedIndexedAccess` types `core[i]` as\n // possibly undefined even though the length is known here\n const digit = core.charCodeAt(index) - 48\n\n sum += index % 2 === 0 ? digit : digit * 3\n }\n\n return `${core}${(10 - (sum % 10)) % 10}`\n}\n","const DIACRITICS = /[\\u0300-\\u036f]/g\nconst NON_ALPHANUMERIC = /[^\\p{L}\\p{N}]+/gu\n\n/** `Les Misérables` ≡ `les miserables`, `Dune: Messiah` ≡ `dune messiah`. */\nexport const normalizeForComparison = (value: string): string =>\n value\n .normalize(\"NFD\")\n .replace(DIACRITICS, \"\")\n .toLowerCase()\n .replace(NON_ALPHANUMERIC, \" \")\n .trim()\n\nconst bigramCounts = (value: string): Map<string, number> => {\n const counts = new Map<string, number>()\n\n for (let index = 0; index < value.length - 1; index++) {\n const bigram = value.slice(index, index + 2)\n\n counts.set(bigram, (counts.get(bigram) ?? 0) + 1)\n }\n\n return counts\n}\n\n/**\n * Sørensen–Dice coefficient over character bigrams, `0` to `1`.\n *\n * Bigrams rather than an edit distance: titles disagree by whole words far\n * more often than by typos. Spaces are stripped so `Spider-Man` and\n * `Spider Man` stay exact.\n */\nexport const textSimilarity = (a: string, b: string): number => {\n const left = normalizeForComparison(a).replaceAll(\" \", \"\")\n const right = normalizeForComparison(b).replaceAll(\" \", \"\")\n\n if (left.length === 0 || right.length === 0) return 0\n if (left === right) return 1\n // nothing to compare: a single character has no bigram\n if (left.length < 2 || right.length < 2) return 0\n\n const leftBigrams = bigramCounts(left)\n let shared = 0\n\n for (const [bigram, count] of bigramCounts(right)) {\n shared += Math.min(count, leftBigrams.get(bigram) ?? 0)\n }\n\n return (2 * shared) / (left.length - 1 + (right.length - 1))\n}\n\n/** Spaced dashes only, so `Spider-Man` stays one word. */\nconst SUBTITLE_SEPARATOR = /\\s*[:;]\\s*|\\s+[-–—]\\s+/\n\nconst splitTitle = (value: string): { main: string; hasSubtitle: boolean } => {\n const [head = \"\", ...rest] = value.split(SUBTITLE_SEPARATOR)\n const main = head.trim()\n const subtitle = rest.join(\" \").trim()\n\n if (main.length === 0) return { main: value, hasSubtitle: false }\n\n return { main, hasSubtitle: subtitle.length > 0 }\n}\n\n/** Below an exact match: the omitted half could have been a volume title. */\nconst SUBTITLE_REPAIR_CEILING = 0.9\n\ntype TitleDivision = \"volume\" | \"part\" | \"book\"\nconst TITLE_DIVISIONS: ReadonlyArray<TitleDivision> = [\"volume\", \"part\", \"book\"]\n\nconst TITLE_DIVISION_PATTERN =\n /\\b(volumes?|vols?|parts?|books?)\\s+([\\p{L}\\p{N}]+)\\b/gu\n\nconst TITLE_NUMBER_WORDS: Readonly<Record<string, number>> = {\n one: 1,\n first: 1,\n two: 2,\n second: 2,\n three: 3,\n third: 3,\n four: 4,\n fourth: 4,\n five: 5,\n fifth: 5,\n six: 6,\n sixth: 6,\n seven: 7,\n seventh: 7,\n eight: 8,\n eighth: 8,\n nine: 9,\n ninth: 9,\n ten: 10,\n tenth: 10,\n eleven: 11,\n eleventh: 11,\n twelve: 12,\n twelfth: 12,\n}\n\nconst ROMAN_DIGIT_VALUES: Readonly<Record<string, number>> = {\n i: 1,\n v: 5,\n x: 10,\n l: 50,\n c: 100,\n d: 500,\n m: 1000,\n}\n\nconst parseRomanNumeral = (value: string): number | undefined => {\n if (!/^m{0,3}(cm|cd|d?c{0,3})(xc|xl|l?x{0,3})(ix|iv|v?i{0,3})$/i.test(value))\n return undefined\n\n let total = 0\n let previous = 0\n\n for (const symbol of [...value.toLowerCase()].reverse()) {\n const current = ROMAN_DIGIT_VALUES[symbol]\n\n if (current === undefined) return undefined\n\n if (current < previous) total -= current\n else {\n total += current\n previous = current\n }\n }\n\n return total > 0 ? total : undefined\n}\n\nconst parseTitleDivisionNumber = (value: string): number | undefined => {\n if (/^\\d+$/.test(value)) return Number.parseInt(value, 10)\n\n return TITLE_NUMBER_WORDS[value] ?? parseRomanNumeral(value)\n}\n\nconst titleDivision = (value: string): TitleDivision | undefined => {\n if (value.startsWith(\"vol\")) return \"volume\"\n if (value.startsWith(\"part\")) return \"part\"\n if (value.startsWith(\"book\")) return \"book\"\n\n return undefined\n}\n\nconst explicitTitleDivisions = (\n value: string,\n): ReadonlyMap<TitleDivision, ReadonlySet<number>> => {\n const divisions = new Map<TitleDivision, Set<number>>()\n\n for (const match of normalizeForComparison(value).matchAll(\n TITLE_DIVISION_PATTERN,\n )) {\n const division =\n match[1] !== undefined ? titleDivision(match[1]) : undefined\n const number =\n match[2] !== undefined ? parseTitleDivisionNumber(match[2]) : undefined\n\n if (division === undefined || number === undefined) continue\n\n const numbers = divisions.get(division) ?? new Set<number>()\n numbers.add(number)\n divisions.set(division, numbers)\n }\n\n return divisions\n}\n\nconst titleDivisionsContradict = (a: string, b: string): boolean => {\n const left = explicitTitleDivisions(a)\n const right = explicitTitleDivisions(b)\n\n for (const division of TITLE_DIVISIONS) {\n const leftNumbers = left.get(division)\n const rightNumbers = right.get(division)\n\n if (leftNumbers === undefined || rightNumbers === undefined) continue\n if ([...leftNumbers].every((number) => !rightNumbers.has(number)))\n return true\n }\n\n return false\n}\n\n/**\n * {@link textSimilarity}, with `Dune` matched against `Dune: a novel`.\n *\n * Only when **one** side states a subtitle: that asymmetry is the same title\n * catalogued at a different depth. When both state one, both catalogers meant\n * it — `Dune: Book One` and `Dune: Messiah` are two books.\n * Explicit volume, part, or book numbers are semantic identity: when both\n * sides state different ones, the title score is `0` however similar the rest.\n */\nexport const titleSimilarity = (a: string, b: string): number => {\n if (titleDivisionsContradict(a, b)) return 0\n\n const full = textSimilarity(a, b)\n const left = splitTitle(a)\n const right = splitTitle(b)\n\n if (left.hasSubtitle === right.hasSubtitle) return full\n\n return Math.max(\n full,\n textSimilarity(left.main, right.main) * SUBTITLE_REPAIR_CEILING,\n )\n}\n\nconst sortedTokens = (value: string): string =>\n normalizeForComparison(value)\n .split(\" \")\n .filter((token) => token.length > 0)\n .sort()\n .join(\" \")\n\n/** {@link textSimilarity} on sorted tokens: `Herbert, Frank` ≡ `Frank Herbert`. */\nexport const personNameSimilarity = (a: string, b: string): number =>\n textSimilarity(sortedTokens(a), sortedTokens(b))\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport type { MetadataMatchField, MetadataMatchSignal } from \"../types/match.ts\"\nimport type { MetadataIdentifier } from \"../types/provider.ts\"\nimport { metadataAuthors } from \"../utils/metadataAuthors.ts\"\nimport { toIsbn13 } from \"../utils/toIsbn13.ts\"\nimport {\n personNameSimilarity,\n textSimilarity,\n titleSimilarity,\n} from \"./similarity.ts\"\n\n/**\n * Intent, not statistics: identifiers *are* the book, a title is what a human\n * recognizes it by, everything else corroborates. An edition detail that\n * differs must never sink an otherwise convincing match on its own.\n */\nexport const METADATA_MATCH_WEIGHTS = {\n isbn: 1,\n gtin: 1,\n identifiers: 0.6,\n title: 0.8,\n contributors: 0.5,\n series: 0.3,\n \"publication.original.date\": 0.2,\n \"publication.original.publisher\": 0.15,\n \"publication.edition.date\": 0.2,\n \"publication.edition.publisher\": 0.15,\n languages: 0.15,\n numberOfPages: 0.15,\n} as const satisfies Record<MetadataMatchField, number>\n\n/**\n * Identity rather than description, so these settle the score outright in both\n * directions. Catalogs and books disagree on titles and page counts\n * constantly; they do not agree or disagree on an ISBN by accident.\n */\nconst DECISIVE_FIELDS: ReadonlySet<MetadataMatchField> = new Set([\n \"isbn\",\n \"gtin\",\n])\n\n/** Blank is absent — the vocabulary says so, a hand-built query may not. */\nconst stated = (value: string | undefined): string | undefined => {\n const trimmed = value?.trim()\n\n return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined\n}\n\nconst identifiersHaveSameValue = (\n a: MetadataIdentifier,\n b: MetadataIdentifier,\n): boolean => {\n const aValue = stated(a.value)?.toLowerCase()\n\n return aValue !== undefined && aValue === stated(b.value)?.toLowerCase()\n}\n\nconst identifierScheme = (identifier: MetadataIdentifier): string | undefined =>\n stated(identifier.scheme)?.toLowerCase()\n\nconst identifierLabel = (identifier: MetadataIdentifier): string =>\n identifier.scheme !== undefined\n ? `${identifier.scheme}:${identifier.value}`\n : identifier.value\n\n/**\n * An unannounced scheme (the OPF `dc:identifier` case) is not a contradiction;\n * a `DOI` and an `ISBN` carrying the same digits are.\n */\nconst identifiersAgree = (\n a: MetadataIdentifier,\n b: MetadataIdentifier,\n): boolean => {\n if (!identifiersHaveSameValue(a, b)) return false\n const aScheme = identifierScheme(a)\n const bScheme = identifierScheme(b)\n\n if (aScheme === undefined || bScheme === undefined) return true\n\n return aScheme === bScheme\n}\n\n/** Only an authored scope makes a shared raw value unambiguous identity. */\nconst identifiersAgreeDecisively = (\n a: MetadataIdentifier,\n b: MetadataIdentifier,\n): boolean => {\n if (!identifiersHaveSameValue(a, b)) return false\n const aScheme = identifierScheme(a)\n const bScheme = identifierScheme(b)\n\n return aScheme !== undefined && aScheme === bScheme\n}\n\nconst primaryLanguageSubtag = (language: string): string =>\n language.trim().toLowerCase().split(\"-\")[0] ?? \"\"\n\n/** Reprints shift the year around, so a near one still corroborates. */\nconst publicationYearScore = (\n queryYear: number,\n candidateYear: number,\n): number => {\n const distance = Math.abs(queryYear - candidateYear)\n\n if (distance === 0) return 1\n if (distance === 1) return 0.6\n if (distance <= 3) return 0.3\n\n return 0\n}\n\n/**\n * Proximity, not equality — front matter and ads move the count: identical is\n * `1`, 25% apart `0.5`, half as long `0`.\n */\nconst numberOfPagesScore = (query: number, candidate: number): number => {\n const largest = Math.max(query, candidate)\n\n if (largest <= 0) return 0\n\n return Math.max(0, 1 - (Math.abs(query - candidate) / largest) * 2)\n}\n\nconst bestSimilarity = (\n values: ReadonlyArray<string>,\n candidates: ReadonlyArray<string>,\n compare: (a: string, b: string) => number,\n): number =>\n values.reduce(\n (best, value) =>\n candidates.reduce(\n (innerBest, candidate) =>\n Math.max(innerBest, compare(value, candidate)),\n best,\n ),\n 0,\n )\n\nexport type ScoredMetadataCandidate = {\n readonly score: number\n readonly signals: ReadonlyArray<MetadataMatchSignal>\n}\n\n/**\n * How well a candidate agrees with the query, field by field, and why. Every\n * provider is scored by these rules, so their scores stay comparable:\n *\n * - **Only comparable fields count** — a field is compared when both sides\n * state it, so what the book doesn't know can neither raise nor lower a\n * candidate, and nothing in common scores `0`.\n * - **The aggregate is a weighted average** ({@link METADATA_MATCH_WEIGHTS}),\n * putting a rich query and a sparse one on the same scale.\n * - **Confirmed identity settles it** — an agreeing ISBN, GTIN, or\n * scheme-scoped identifier pins the score to `1`. A contradictory ISBN or\n * GTIN pins it to `0`; two disjoint provider-specific identifier lists are\n * merely unrelated.\n */\nexport const scoreMetadataCandidate = (\n query: ResolvedMetadata,\n candidate: ResolvedMetadata,\n): ScoredMetadataCandidate => {\n const signals: MetadataMatchSignal[] = []\n\n const addSignal = (\n field: MetadataMatchField,\n values:\n | {\n score: number\n query: string\n candidate: string\n }\n | undefined,\n ) => {\n if (values === undefined) return\n\n signals.push({\n field,\n weight: METADATA_MATCH_WEIGHTS[field],\n score: Math.min(1, Math.max(0, values.score)),\n query: values.query,\n candidate: values.candidate,\n })\n }\n\n const compareStrings = (\n field: MetadataMatchField,\n rawQueryValue: string | undefined,\n rawCandidateValue: string | undefined,\n compare: (a: string, b: string) => number = textSimilarity,\n ) => {\n const queryValue = stated(rawQueryValue)\n const candidateValue = stated(rawCandidateValue)\n\n if (queryValue === undefined || candidateValue === undefined) return\n\n addSignal(field, {\n score: compare(queryValue, candidateValue),\n query: queryValue,\n candidate: candidateValue,\n })\n }\n\n const queryIsbn = stated(query.isbn)\n const candidateIsbn = stated(candidate.isbn)\n const queryIsbn13 = queryIsbn !== undefined ? toIsbn13(queryIsbn) : undefined\n const candidateIsbn13 =\n candidateIsbn !== undefined ? toIsbn13(candidateIsbn) : undefined\n\n if (\n queryIsbn !== undefined &&\n candidateIsbn !== undefined &&\n queryIsbn13 !== undefined &&\n candidateIsbn13 !== undefined\n ) {\n addSignal(\"isbn\", {\n score: queryIsbn13 === candidateIsbn13 ? 1 : 0,\n query: queryIsbn,\n candidate: candidateIsbn,\n })\n }\n\n const queryGtin = stated(query.gtin)\n const candidateGtin = stated(candidate.gtin)\n\n if (queryGtin !== undefined && candidateGtin !== undefined) {\n addSignal(\"gtin\", {\n score: queryGtin === candidateGtin ? 1 : 0,\n query: queryGtin,\n candidate: candidateGtin,\n })\n }\n\n const queryIdentifiers = query.identifiers ?? []\n const candidateIdentifiers = candidate.identifiers ?? []\n const decisiveIdentifierAgreement = queryIdentifiers.some((identifier) =>\n candidateIdentifiers.some((other) =>\n identifiersAgreeDecisively(identifier, other),\n ),\n )\n\n if (queryIdentifiers.length > 0 && candidateIdentifiers.length > 0) {\n const shared = queryIdentifiers.some((identifier) =>\n candidateIdentifiers.some((other) => identifiersAgree(identifier, other)),\n )\n\n addSignal(\"identifiers\", {\n score: shared ? 1 : 0,\n query: queryIdentifiers.map(identifierLabel).join(\", \"),\n candidate: candidateIdentifiers.map(identifierLabel).join(\", \"),\n })\n }\n\n compareStrings(\"title\", query.title, candidate.title, titleSimilarity)\n compareStrings(\n \"publication.original.publisher\",\n query.publication?.original?.publisher,\n candidate.publication?.original?.publisher,\n )\n compareStrings(\n \"publication.edition.publisher\",\n query.publication?.edition?.publisher,\n candidate.publication?.edition?.publisher,\n )\n compareStrings(\n \"series\",\n query.belongsTo?.series?.[0]?.name,\n candidate.belongsTo?.series?.[0]?.name,\n )\n\n const candidateAuthors = metadataAuthors(candidate)\n const queryAuthors = metadataAuthors(query)\n\n if (queryAuthors.length > 0 && candidateAuthors.length > 0) {\n addSignal(\"contributors\", {\n score: bestSimilarity(\n queryAuthors,\n candidateAuthors,\n personNameSimilarity,\n ),\n query: queryAuthors.join(\", \"),\n candidate: candidateAuthors.join(\", \"),\n })\n }\n\n const comparePublicationYears = (\n field: \"publication.original.date\" | \"publication.edition.date\",\n queryYear: number | undefined,\n candidateYear: number | undefined,\n ) => {\n if (queryYear === undefined || candidateYear === undefined) return\n\n addSignal(field, {\n score: publicationYearScore(queryYear, candidateYear),\n query: String(queryYear),\n candidate: String(candidateYear),\n })\n }\n\n comparePublicationYears(\n \"publication.original.date\",\n query.publication?.original?.date?.year,\n candidate.publication?.original?.date?.year,\n )\n comparePublicationYears(\n \"publication.edition.date\",\n query.publication?.edition?.date?.year,\n candidate.publication?.edition?.date?.year,\n )\n\n const queryLanguages = query.languages ?? []\n const candidateLanguages = candidate.languages ?? []\n\n if (queryLanguages.length > 0 && candidateLanguages.length > 0) {\n const candidateSubtags = new Set(\n candidateLanguages.map(primaryLanguageSubtag),\n )\n\n addSignal(\"languages\", {\n score: queryLanguages.some((language) =>\n candidateSubtags.has(primaryLanguageSubtag(language)),\n )\n ? 1\n : 0,\n query: queryLanguages.join(\", \"),\n candidate: candidateLanguages.join(\", \"),\n })\n }\n\n if (\n query.numberOfPages !== undefined &&\n candidate.numberOfPages !== undefined\n ) {\n addSignal(\"numberOfPages\", {\n score: numberOfPagesScore(query.numberOfPages, candidate.numberOfPages),\n query: String(query.numberOfPages),\n candidate: String(candidate.numberOfPages),\n })\n }\n\n const decisive = signals.filter((signal) => DECISIVE_FIELDS.has(signal.field))\n\n // Contradiction wins over agreement: refusing a publication whose stated\n // identity disagrees is the recoverable mistake. Without this the average is\n // merciful to exactly the wrong candidate — a different edition agreeing on\n // title and author scores (0.8 + 0.5) / 2.3 ≈ 0.57, accepted.\n if (decisive.some((signal) => signal.score === 0))\n return { score: 0, signals }\n if (\n decisiveIdentifierAgreement ||\n decisive.some((signal) => signal.score === 1)\n )\n return { score: 1, signals }\n\n const totalWeight = signals.reduce(\n (total, signal) => total + signal.weight,\n 0,\n )\n\n if (totalWeight === 0) return { score: 0, signals }\n\n const weighted = signals.reduce(\n (total, signal) => total + signal.score * signal.weight,\n 0,\n )\n\n return { score: weighted / totalWeight, signals }\n}\n","/**\n * Thrown by a provider when a catalog answered with a failing status. Carrying\n * it is what lets `fetchMetadata` report *why* a provider dropped out: `429`\n * is \"ask again later\", `503` is \"the catalog is down\", and a consumer decides\n * differently for each.\n *\n * ```ts\n * if (!response.ok) {\n * throw new MetadataProviderResponseError(response.status, \"My Catalog search failed\")\n * }\n * ```\n */\nexport class MetadataProviderResponseError extends Error {\n readonly status: number\n\n constructor(status: number, message?: string) {\n super(message ?? `Provider responded with status ${status}`)\n\n this.name = \"MetadataProviderResponseError\"\n this.status = status\n }\n}\n\n/**\n * The HTTP status behind a thrown error, when there is one.\n *\n * Structural rather than `instanceof`, so a provider built on another HTTP\n * client works unadapted. No status means it was never a response — network,\n * parse, bug — which is itself the answer.\n */\nexport const responseErrorStatus = (error: unknown): number | undefined => {\n if (typeof error !== \"object\" || error === null) return undefined\n if (!(\"status\" in error)) return undefined\n\n const { status } = error\n\n if (typeof status !== \"number\" || !Number.isInteger(status)) return undefined\n\n // an unrelated `status` field means something else entirely\n return status >= 100 && status <= 599 ? status : undefined\n}\n","import { Report as SharedReport } from \"@prose-reader/shared\"\n\n// Spelled out rather than read from package.json: node runs this source\n// directly in development, where a JSON import needs an import attribute.\nconst NAME = \"@prose-reader/metadata-fetcher\"\n\n// No explicit `enabled`: inherit the shared root, which auto-configures from\n// the `globalThis.__PROSE_READER_DEBUG` flag.\nexport const Report = SharedReport.namespace(NAME, undefined, {\n color: \"#b06642\",\n})\n","/**\n * Returns a copy of `obj` without the keys whose value is `undefined`, so\n * results stay sparse (an absent key and an `undefined` value are the same\n * statement: \"nothing was found for this field\").\n */\nexport const omitUndefined = <T extends object>(obj: T): T => {\n const entries = Object.entries(obj).filter(([, value]) => value !== undefined)\n\n // `as T`: Object.entries/fromEntries erase the key–value pairing; filtering\n // only drops `undefined` values, which `T`'s optional fields already allow,\n // so the runtime shape is a subset of `T` with no way to express it inline.\n return Object.fromEntries(entries) as T\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport { scoreMetadataCandidate } from \"./match/scoreMetadataCandidate.ts\"\nimport { responseErrorStatus } from \"./providers/responseError.ts\"\nimport { Report } from \"./report.ts\"\nimport type {\n FailedMetadataProvider,\n FetchedMetadata,\n FetchedMetadataSource,\n} from \"./types/fetchedMetadata.ts\"\nimport type { MetadataMatch } from \"./types/match.ts\"\nimport type { MetadataCandidate, MetadataProvider } from \"./types/provider.ts\"\nimport { omitUndefined } from \"./utils/omitUndefined.ts\"\n\n/**\n * A {@link ResolvedMetadata}, or anything carrying one — the `ResolvedArchive`\n * shape, so the output of `resolveArchive` goes straight in. Hand-built terms\n * (`{ title: \"Dune\" }`) work too, when there is no container to resolve.\n */\nexport type FetchMetadataInput =\n | ResolvedMetadata\n | { readonly metadata: ResolvedMetadata }\n\nexport type FetchMetadataOptions = {\n /**\n * The catalogs to ask, queried concurrently. Their order is the tie-break\n * precedence: equally-scored matches rank in the order declared here.\n */\n readonly providers: ReadonlyArray<MetadataProvider>\n /**\n * Hard cap on the matches kept **per provider**, best first; also the\n * page-size hint each provider receives. Defaults to `5`.\n */\n readonly limit?: number\n /**\n * Score a match must reach to be `accepted`. Defaults to `0.5`. Below it,\n * matches are still returned and ranked, for a consumer to offer as \"did\n * you mean?\".\n */\n readonly minScore?: number\n /**\n * Keep each provider's own record on `match.raw`. Defaults to `false`: it is\n * provenance most consumers don't need, and can dwarf what they persist.\n */\n readonly includeRaw?: boolean\n readonly signal?: AbortSignal\n}\n\nconst DEFAULT_LIMIT = 5\nconst DEFAULT_MIN_SCORE = 0.5\nconst FETCHED_METADATA_VERSION = 3\n\nconst toMatch = ({\n candidate,\n providerId,\n score,\n signals,\n accepted,\n includeRaw,\n}: {\n candidate: MetadataCandidate\n providerId: string\n score: number\n signals: MetadataMatch[\"signals\"]\n accepted: boolean\n includeRaw: boolean\n}): MetadataMatch =>\n omitUndefined({\n providerId,\n score,\n signals,\n accepted,\n metadata: candidate.metadata,\n id: candidate.id,\n url: candidate.url,\n raw: includeRaw ? candidate.raw : undefined,\n })\n\n/**\n * Asks every provider what it knows about a book, scores the answers against\n * what you gave it, and returns the ranked candidates with the evidence behind\n * each score. Candidates use the same {@link ResolvedMetadata} vocabulary as\n * `resolveArchive`, so remote and embedded metadata stay interchangeable.\n *\n * ```ts\n * import { resolveArchive } from \"@prose-reader/archive-reader\"\n * import {\n * createOpenLibraryProvider,\n * createProjectGutenbergProvider,\n * fetchMetadata,\n * } from \"@prose-reader/metadata-fetcher\"\n *\n * const resolved = await resolveArchive(archive)\n * const fetched = await fetchMetadata(resolved, {\n * providers: [\n * createProjectGutenbergProvider(),\n * createOpenLibraryProvider(),\n * ],\n * })\n *\n * // what the best accepted candidate says, and why we believe it\n * const match = fetched.matches.find(({ accepted }) => accepted)\n * match?.metadata.cover?.uri\n * match?.signals // [{ field: \"isbn\", score: 1, … }, …]\n * ```\n *\n * Error policy mirrors `resolveArchive`: a provider that throws never fails\n * the fetch — it is logged through the debug `Report` and listed in\n * `failedProviders`. An abort is the exception: cancelling through `signal`\n * rejects, since the caller asked for it.\n */\nexport const fetchMetadata = async (\n input: FetchMetadataInput,\n options: FetchMetadataOptions,\n): Promise<FetchedMetadata> => {\n const metadata = \"metadata\" in input ? input.metadata : input\n const limit = options.limit ?? DEFAULT_LIMIT\n const minScore = options.minScore ?? DEFAULT_MIN_SCORE\n const includeRaw = options.includeRaw === true\n\n const results = await Promise.all(\n options.providers.map(async (provider) => {\n try {\n const candidates = await provider.search(metadata, {\n limit,\n signal: options.signal,\n })\n\n return { provider, candidates, failure: undefined }\n } catch (error) {\n // the caller pulled the plug: their cancellation, their rejection\n if (options.signal?.aborted === true) throw error\n\n // catalogs in the wild are flaky: a failing provider never fails the\n // fetch, it just doesn't contribute. The status, when the catalog\n // answered with one, is what tells a rate limit from an outage.\n Report.error(`fetchMetadata: provider \"${provider.id}\" failed`, error)\n\n return {\n provider,\n candidates: undefined,\n failure: omitUndefined({\n id: provider.id,\n status: responseErrorStatus(error),\n }),\n }\n }\n }),\n )\n\n const failedProviders: FailedMetadataProvider[] = results.flatMap((result) =>\n result.failure !== undefined ? [result.failure] : [],\n )\n\n const sources: Record<string, FetchedMetadataSource> = {}\n const matches: MetadataMatch[] = []\n\n for (const { provider, candidates } of results) {\n if (candidates === undefined) continue\n\n const providerMatches = candidates\n .map((candidate) => {\n const { score, signals } = scoreMetadataCandidate(\n metadata,\n candidate.metadata,\n )\n\n return toMatch({\n candidate,\n providerId: provider.id,\n score,\n signals,\n accepted: score >= minScore,\n includeRaw,\n })\n })\n // `sort` is stable, so equally-scored candidates keep the order the\n // provider ranked them in\n .sort((a, b) => b.score - a.score)\n .slice(0, limit)\n\n // ids key `sources`, so a duplicate would silently drop a provider's\n // detail — the kind of thing that costs an hour to notice\n if (sources[provider.id] !== undefined) {\n Report.warn(\n `fetchMetadata: several providers share the id \"${provider.id}\"`,\n )\n }\n\n sources[provider.id] = {\n provider: { id: provider.id, name: provider.name },\n matches: providerMatches,\n }\n\n matches.push(...providerMatches)\n }\n\n // again stable: providers were walked in declaration order, so that order\n // breaks ties between equally convincing catalogs\n const rankedMatches = [...matches].sort((a, b) => b.score - a.score)\n\n return {\n version: FETCHED_METADATA_VERSION,\n matches: rankedMatches,\n sources,\n failedProviders,\n }\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\n\nexport const PROJECT_GUTENBERG_IDENTIFIER_SCHEME = \"ProjectGutenberg\"\n\nexport type ProjectGutenbergLookup = {\n readonly id: string\n /** The book's exact identifier spelling, echoed only after PG confirms it. */\n readonly identifier: {\n readonly value: string\n readonly scheme?: string\n }\n}\n\nconst GUTENBERG_HOSTS: ReadonlySet<string> = new Set([\n \"gutenberg.org\",\n \"www.gutenberg.org\",\n])\n\nconst GUTENBERG_PATHS: ReadonlyArray<RegExp> = [\n /^\\/ebooks\\/(\\d+)(?:[./]|$)/i,\n /^\\/files\\/(\\d+)(?:\\/|$)/i,\n /^\\/cache\\/epub\\/(\\d+)(?:\\/|$)/i,\n /^\\/(\\d+)(?:\\/|$)/,\n]\n\nconst normalizedProjectGutenbergId = (value: string): string | undefined => {\n const trimmed = value.trim()\n\n if (!/^\\d+$/.test(trimmed)) return undefined\n\n const normalized = trimmed.replace(/^0+(?=\\d)/, \"\")\n\n return normalized !== \"0\" ? normalized : undefined\n}\n\nconst projectGutenbergIdFromUrl = (value: string): string | undefined => {\n try {\n const url = new URL(value.trim())\n\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") return undefined\n if (!GUTENBERG_HOSTS.has(url.hostname.toLowerCase())) return undefined\n\n for (const pattern of GUTENBERG_PATHS) {\n const id = pattern.exec(url.pathname)?.[1]\n\n if (id !== undefined) return normalizedProjectGutenbergId(id)\n }\n } catch {\n return undefined\n }\n\n return undefined\n}\n\n/**\n * Recognizes Project Gutenberg's own numeric identifier and official URL\n * forms. Calling providers decide explicitly how to use this crosswalk; the\n * shared scorer never reinterprets arbitrary URLs.\n */\nexport const projectGutenbergLookupFromMetadata = (\n metadata: ResolvedMetadata,\n): ProjectGutenbergLookup | undefined => {\n for (const identifier of metadata.identifiers ?? []) {\n const scheme = identifier.scheme?.trim().toLowerCase()\n const id =\n scheme === PROJECT_GUTENBERG_IDENTIFIER_SCHEME.toLowerCase()\n ? normalizedProjectGutenbergId(identifier.value)\n : scheme === undefined || scheme === \"url\" || scheme === \"uri\"\n ? projectGutenbergIdFromUrl(identifier.value)\n : undefined\n\n if (id === undefined) continue\n\n return {\n id,\n identifier: {\n value: identifier.value,\n ...(identifier.scheme !== undefined\n ? { scheme: identifier.scheme }\n : {}),\n },\n }\n }\n\n return undefined\n}\n","/**\n * Readers for `JSON.parse` output. A catalog's payload is third-party data, so\n * it enters as `unknown` and every field goes through a guard: a renamed or\n * nulled field reads as absent instead of crashing the fetch, or producing a\n * candidate with `undefined` where a string was promised.\n */\nexport const isJsonRecord = (\n value: unknown,\n): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value)\n\nexport const readString = (\n record: Record<string, unknown>,\n key: string,\n): string | undefined => {\n const value = record[key]\n\n if (typeof value !== \"string\") return undefined\n\n const trimmed = value.trim()\n\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport const readNumber = (\n record: Record<string, unknown>,\n key: string,\n): number | undefined => {\n const value = record[key]\n\n return typeof value === \"number\" && Number.isFinite(value) ? value : undefined\n}\n\nexport const readStringArray = (\n record: Record<string, unknown>,\n key: string,\n): ReadonlyArray<string> | undefined => {\n const value = record[key]\n\n if (!Array.isArray(value)) return undefined\n\n const strings = value\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0)\n\n return strings.length > 0 ? strings : undefined\n}\n\nexport const readRecordArray = (\n record: Record<string, unknown>,\n key: string,\n): ReadonlyArray<Record<string, unknown>> => {\n const value = record[key]\n\n if (!Array.isArray(value)) return []\n\n return value.filter(isJsonRecord)\n}\n","import {\n isJsonRecord,\n readNumber,\n readRecordArray,\n readString,\n readStringArray,\n} from \"../../utils/json.ts\"\n\n/**\n * One entry of an Open Library `search.json` response, field names mirroring\n * the API (same rule as the archive parsers: a parsed source looks like its\n * source document). Every field is optional — the API omits what it doesn't\n * have, and the `fields` parameter decides what it may return at all.\n *\n * @see https://openlibrary.org/dev/docs/api/search\n */\nexport type OpenLibraryDoc = {\n /** Work key, e.g. `/works/OL45883W`. */\n readonly key?: string\n readonly title?: string\n readonly subtitle?: string\n readonly author_name?: ReadonlyArray<string>\n readonly first_publish_year?: number\n /** MARC 21 language codes (`eng`, `fre`), not BCP 47. */\n readonly language?: ReadonlyArray<string>\n readonly subject?: ReadonlyArray<string>\n readonly number_of_pages_median?: number\n /** Cover id, addressing `covers.openlibrary.org`. */\n readonly cover_i?: number\n /** Project Gutenberg ebook ids attached to the work's editions. */\n readonly id_project_gutenberg?: ReadonlyArray<string>\n}\n\nconst parseDoc = (record: Record<string, unknown>): OpenLibraryDoc => ({\n key: readString(record, \"key\"),\n title: readString(record, \"title\"),\n subtitle: readString(record, \"subtitle\"),\n author_name: readStringArray(record, \"author_name\"),\n first_publish_year: readNumber(record, \"first_publish_year\"),\n language: readStringArray(record, \"language\"),\n subject: readStringArray(record, \"subject\"),\n number_of_pages_median: readNumber(record, \"number_of_pages_median\"),\n cover_i: readNumber(record, \"cover_i\"),\n id_project_gutenberg: readStringArray(record, \"id_project_gutenberg\"),\n})\n\n/**\n * Reads the `docs` of a `search.json` payload. A payload that isn't a record,\n * or carries no `docs` array, yields an empty list: an unexpected response\n * shape is \"this catalog has nothing for us\", not a crash.\n */\nexport const parseOpenLibrarySearchResponse = (\n payload: unknown,\n): ReadonlyArray<OpenLibraryDoc> => {\n if (!isJsonRecord(payload)) return []\n\n return readRecordArray(payload, \"docs\").map(parseDoc)\n}\n","/**\n * MARC 21 language codes → BCP 47 primary subtags, for the codes a general\n * book catalog actually returns. Open Library reports `language` as MARC\n * (`eng`, `fre`), while {@link ResolvedMetadata.languages} is BCP 47 by\n * contract, so the mapping is part of normalizing — not a nicety.\n *\n * Both MARC variants are listed where they differ (bibliographic `fre` and\n * terminology `fra`), since records in the wild carry either.\n */\nconst MARC_TO_BCP47: Readonly<Record<string, string>> = {\n afr: \"af\",\n alb: \"sq\",\n amh: \"am\",\n ara: \"ar\",\n arm: \"hy\",\n asm: \"as\",\n aze: \"az\",\n baq: \"eu\",\n bel: \"be\",\n ben: \"bn\",\n bos: \"bs\",\n bul: \"bg\",\n bur: \"my\",\n cat: \"ca\",\n ces: \"cs\",\n chi: \"zh\",\n cym: \"cy\",\n cze: \"cs\",\n dan: \"da\",\n deu: \"de\",\n dut: \"nl\",\n ell: \"el\",\n eng: \"en\",\n epo: \"eo\",\n est: \"et\",\n eus: \"eu\",\n fao: \"fo\",\n fas: \"fa\",\n fin: \"fi\",\n fra: \"fr\",\n fre: \"fr\",\n geo: \"ka\",\n ger: \"de\",\n gle: \"ga\",\n glg: \"gl\",\n gre: \"el\",\n guj: \"gu\",\n heb: \"he\",\n hin: \"hi\",\n hrv: \"hr\",\n hun: \"hu\",\n hye: \"hy\",\n ice: \"is\",\n ind: \"id\",\n isl: \"is\",\n ita: \"it\",\n jpn: \"ja\",\n kan: \"kn\",\n kat: \"ka\",\n kaz: \"kk\",\n khm: \"km\",\n kor: \"ko\",\n lao: \"lo\",\n lat: \"la\",\n lav: \"lv\",\n lit: \"lt\",\n ltz: \"lb\",\n mac: \"mk\",\n mal: \"ml\",\n mar: \"mr\",\n may: \"ms\",\n mkd: \"mk\",\n mlt: \"mt\",\n mon: \"mn\",\n msa: \"ms\",\n mya: \"my\",\n nep: \"ne\",\n nld: \"nl\",\n nno: \"nn\",\n nob: \"nb\",\n nor: \"no\",\n ori: \"or\",\n pan: \"pa\",\n per: \"fa\",\n pol: \"pl\",\n por: \"pt\",\n rom: \"ro\",\n ron: \"ro\",\n rum: \"ro\",\n rus: \"ru\",\n scc: \"sr\",\n scr: \"hr\",\n sin: \"si\",\n slk: \"sk\",\n slo: \"sk\",\n slv: \"sl\",\n snd: \"sd\",\n spa: \"es\",\n sqi: \"sq\",\n srp: \"sr\",\n swa: \"sw\",\n swe: \"sv\",\n tam: \"ta\",\n tel: \"te\",\n tgl: \"tl\",\n tha: \"th\",\n tur: \"tr\",\n ukr: \"uk\",\n urd: \"ur\",\n uzb: \"uz\",\n vie: \"vi\",\n wel: \"cy\",\n yid: \"yi\",\n zho: \"zh\",\n}\n\n/**\n * MARC placeholders that say \"no single language\": keeping them would put a\n * non-language in a BCP 47 field, so they resolve to nothing.\n */\nconst NON_LANGUAGE_CODES = new Set([\"mul\", \"und\", \"zxx\", \"sgn\"])\n\n/**\n * Normalizes one MARC language code to its BCP 47 primary subtag. Unknown\n * codes (the long tail of MARC) pass through lowercased rather than being\n * dropped — a language we can't name is still a language the record stated.\n */\nexport const marcLanguageToBcp47 = (code: string): string | undefined => {\n const normalized = code.trim().toLowerCase()\n\n if (normalized.length === 0) return undefined\n if (NON_LANGUAGE_CODES.has(normalized)) return undefined\n\n return MARC_TO_BCP47[normalized] ?? normalized\n}\n","import type {\n ResolvedMetadata,\n ResolvedMetadataHome,\n} from \"@prose-reader/archive-reader\"\nimport { omitUndefined } from \"../../utils/omitUndefined.ts\"\nimport { marcLanguageToBcp47 } from \"./marcLanguage.ts\"\nimport type { OpenLibraryDoc } from \"./parse.ts\"\n\n/**\n * A popular work carries hundreds of subject headings, most of them long-tail\n * noise (\"Accessible book\", \"Protected DAISY\"), which would dwarf the entity a\n * consumer persists. The API puts the meaningful ones first.\n */\nexport const OPEN_LIBRARY_MAX_SUBJECTS = 25\n\n/**\n * Where every {@link OpenLibraryDoc} field lands in {@link ResolvedMetadata},\n * compile-enforced: adding a field to the parser without declaring its home is\n * a type error. The archive parsers' losslessness contract, and the mapping\n * documentation.\n */\nexport const openLibraryMetadataHomes = {\n key: \"identifiers\",\n title: \"title\",\n subtitle: \"title\",\n author_name: \"contributors\",\n first_publish_year: \"publication.original.date\",\n language: \"languages\",\n subject: \"subjects\",\n number_of_pages_median: \"numberOfPages\",\n cover_i: \"cover\",\n id_project_gutenberg: \"identifiers\",\n} as const satisfies Record<\n keyof OpenLibraryDoc,\n ResolvedMetadataHome | \"identifiers\"\n>\n\nexport const OPEN_LIBRARY_IDENTIFIER_SCHEME = \"OpenLibrary\"\nconst PROJECT_GUTENBERG_IDENTIFIER_SCHEME = \"ProjectGutenberg\"\n\nconst emptyToUndefined = <T>(\n values: ReadonlyArray<T>,\n): ReadonlyArray<T> | undefined => (values.length > 0 ? values : undefined)\n\n/**\n * Normalizes one search hit into the cross-format vocabulary. Two choices\n * worth stating:\n *\n * - **`title` folds in `subtitle`** (`Dune: Messiah`): the vocabulary has one\n * title field, and an OPF `dc:title` normally carries the subtitle too, so\n * comparing a bare title against a full one would cost match score.\n * - **`isbn` is set only for an ISBN lookup**, to the queried one — the API\n * answered \"this work has that ISBN\", a fact about the record. A hit\n * describes a *work*, whose editions each have their own ISBN, so picking\n * one out of a title search would be fabrication.\n */\nexport const resolveOpenLibraryDoc = (\n doc: OpenLibraryDoc,\n options: {\n readonly coversBaseUrl: string\n /** The ISBN this record was looked up by, when it was. */\n readonly isbn?: string\n /** The source identifier that an exact Gutenberg-id lookup confirmed. */\n readonly matchedProjectGutenbergIdentifier?: {\n readonly value: string\n readonly scheme?: string\n }\n },\n): ResolvedMetadata => {\n const title =\n doc.title !== undefined && doc.subtitle !== undefined\n ? `${doc.title}: ${doc.subtitle}`\n : doc.title\n\n const languages = emptyToUndefined([\n ...new Set(\n (doc.language ?? [])\n .map(marcLanguageToBcp47)\n .filter((language): language is string => language !== undefined),\n ),\n ])\n\n const identifiers = [\n ...(options.isbn !== undefined\n ? [{ value: options.isbn, scheme: \"ISBN\" }]\n : []),\n ...(options.matchedProjectGutenbergIdentifier !== undefined\n ? [options.matchedProjectGutenbergIdentifier]\n : []),\n ...(doc.id_project_gutenberg ?? []).map((value) => ({\n value,\n scheme: PROJECT_GUTENBERG_IDENTIFIER_SCHEME,\n })),\n ...(doc.key !== undefined\n ? [{ value: doc.key, scheme: OPEN_LIBRARY_IDENTIFIER_SCHEME }]\n : []),\n ]\n\n return omitUndefined({\n title,\n cover:\n doc.cover_i !== undefined\n ? {\n // absolute, not container-relative: a catalog addresses its\n // covers in its own space, with nothing to rebase onto\n uri: `${options.coversBaseUrl}/b/id/${doc.cover_i}-L.jpg`,\n mediaType: \"image/jpeg\",\n confidence: \"derived\" as const,\n }\n : undefined,\n publication:\n doc.first_publish_year !== undefined\n ? { original: { date: { year: doc.first_publish_year } } }\n : undefined,\n languages,\n subjects: emptyToUndefined(\n (doc.subject ?? []).slice(0, OPEN_LIBRARY_MAX_SUBJECTS),\n ),\n contributors: emptyToUndefined(\n (doc.author_name ?? []).map((name) => ({\n name,\n roles: [\"author\" as const],\n })),\n ),\n numberOfPages: doc.number_of_pages_median,\n isbn: options.isbn,\n identifiers: emptyToUndefined(identifiers),\n })\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport type {\n MetadataCandidate,\n MetadataProvider,\n MetadataProviderContext,\n} from \"../../types/provider.ts\"\nimport { metadataAuthors } from \"../../utils/metadataAuthors.ts\"\nimport {\n type ProjectGutenbergLookup,\n projectGutenbergLookupFromMetadata,\n} from \"../projectGutenberg/identifier.ts\"\nimport { MetadataProviderResponseError } from \"../responseError.ts\"\nimport { type OpenLibraryDoc, parseOpenLibrarySearchResponse } from \"./parse.ts\"\nimport { resolveOpenLibraryDoc } from \"./resolve.ts\"\n\nexport const OPEN_LIBRARY_PROVIDER_ID = \"openLibrary\"\n\nconst DEFAULT_BASE_URL = \"https://openlibrary.org\"\nconst DEFAULT_COVERS_BASE_URL = \"https://covers.openlibrary.org\"\n\n/**\n * Only what {@link resolveOpenLibraryDoc} maps: `search.json` returns every\n * edition key and IA identifier by default, so `fields` is the difference\n * between a few hundred bytes per hit and a few hundred kilobytes.\n */\nconst SEARCH_FIELDS = [\n \"key\",\n \"title\",\n \"subtitle\",\n \"author_name\",\n \"first_publish_year\",\n \"language\",\n \"subject\",\n \"number_of_pages_median\",\n \"cover_i\",\n \"id_project_gutenberg\",\n].join(\",\")\n\nexport type OpenLibraryProviderOptions = {\n /** API origin. Defaults to `https://openlibrary.org`. */\n readonly baseUrl?: string\n /** Cover service origin. Defaults to `https://covers.openlibrary.org`. */\n readonly coversBaseUrl?: string\n /** For tests, a custom agent, or a caching layer. Defaults to the global one. */\n readonly fetch?: typeof globalThis.fetch\n /**\n * Open Library's API etiquette asks for an identifying `User-Agent` (app\n * name + contact) and throttles anonymous traffic harder.\n */\n readonly userAgent?: string\n}\n\nconst searchTerms = (\n metadata: ResolvedMetadata,\n): Record<string, string> | undefined => {\n const title = metadata.title?.trim()\n\n if (title === undefined || title.length === 0) return undefined\n\n const author = metadataAuthors(metadata)[0]\n\n return {\n title,\n ...(author !== undefined ? { author } : {}),\n }\n}\n\n/**\n * Open Library — the Internet Archive's open bibliographic catalog. Free, no\n * key, covers books broadly (much less so comics and manga).\n *\n * ```ts\n * const provider = createOpenLibraryProvider({\n * userAgent: \"MyReader/1.0 (contact@example.com)\",\n * })\n * ```\n *\n * Lookup strategy, at most three requests: an **ISBN search** when the book\n * states one; an exact **Project Gutenberg id search** when an identifier is\n * an official Gutenberg URL; then a **title (+ first author) search**. A query\n * with none of those terms yields no candidates rather than a fishing\n * expedition.\n */\nexport const createOpenLibraryProvider = (\n options: OpenLibraryProviderOptions = {},\n): MetadataProvider => {\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL\n const coversBaseUrl = options.coversBaseUrl ?? DEFAULT_COVERS_BASE_URL\n\n const searchDocs = async (\n terms: Record<string, string>,\n context: MetadataProviderContext,\n ): Promise<ReadonlyArray<OpenLibraryDoc>> => {\n const url = new URL(\"/search.json\", baseUrl)\n\n for (const [key, value] of Object.entries(terms)) {\n url.searchParams.set(key, value)\n }\n\n url.searchParams.set(\"fields\", SEARCH_FIELDS)\n url.searchParams.set(\"limit\", String(context.limit))\n\n // read lazily so a consumer stubbing the global afterwards still wins\n const doFetch = options.fetch ?? globalThis.fetch\n const response = await doFetch(url.toString(), {\n signal: context.signal,\n headers: {\n Accept: \"application/json\",\n ...(options.userAgent !== undefined\n ? { \"User-Agent\": options.userAgent }\n : {}),\n },\n })\n\n if (!response.ok) {\n throw new MetadataProviderResponseError(\n response.status,\n `Open Library search failed with status ${response.status}`,\n )\n }\n\n return parseOpenLibrarySearchResponse(await response.json())\n }\n\n const toCandidates = (\n docs: ReadonlyArray<OpenLibraryDoc>,\n options: {\n readonly isbn?: string\n readonly confirmedProjectGutenberg?: ProjectGutenbergLookup\n } = {},\n ): ReadonlyArray<MetadataCandidate> =>\n docs.map((doc) => ({\n metadata: resolveOpenLibraryDoc(doc, {\n coversBaseUrl,\n isbn: options.isbn,\n matchedProjectGutenbergIdentifier:\n options.confirmedProjectGutenberg?.identifier,\n }),\n id: doc.key,\n url: doc.key !== undefined ? `${baseUrl}${doc.key}` : undefined,\n raw: doc,\n }))\n\n return {\n id: OPEN_LIBRARY_PROVIDER_ID,\n name: \"Open Library\",\n search: async (metadata, context) => {\n const isbn = metadata.isbn?.trim() || undefined\n const projectGutenberg = projectGutenbergLookupFromMetadata(metadata)\n\n if (isbn !== undefined) {\n const docs = await searchDocs({ isbn }, context)\n\n if (docs.length > 0) return toCandidates(docs, { isbn })\n }\n\n if (projectGutenberg !== undefined) {\n const docs = await searchDocs(\n { q: `id_project_gutenberg:${projectGutenberg.id}` },\n context,\n )\n\n if (docs.length > 0) {\n return toCandidates(docs, {\n confirmedProjectGutenberg: projectGutenberg,\n })\n }\n }\n\n const terms = searchTerms(metadata)\n\n if (terms === undefined) return []\n\n return toCandidates(await searchDocs(terms, context))\n },\n }\n}\n","import type { XmlElement, XmlNodeBase } from \"xmldoc\"\nimport { XmlDocument } from \"xmldoc\"\n\nexport type ProjectGutenbergContributor = {\n readonly name: string\n /** MARC relator code, or `aut` for `dcterms:creator`. */\n readonly role: string\n}\n\nexport type ProjectGutenbergCover = {\n readonly uri: string\n readonly mediaType?: string\n}\n\n/** The subset of one official per-eBook RDF record that we consume. */\nexport type ProjectGutenbergRecord = {\n readonly id: string\n readonly title?: string\n readonly publisher?: string\n readonly issued?: string\n /** Original print publication statement (`MARC 260`). */\n readonly originalPublication?: string\n readonly rights?: string\n readonly description?: string\n readonly summary?: string\n readonly languages?: ReadonlyArray<string>\n readonly subjects?: ReadonlyArray<string>\n readonly bookshelves?: ReadonlyArray<string>\n readonly contributors?: ReadonlyArray<ProjectGutenbergContributor>\n readonly cover?: ProjectGutenbergCover\n}\n\nconst elementLocalName = (name: string): string =>\n name.includes(\":\") ? name.slice(name.lastIndexOf(\":\") + 1) : name\n\nconst isXmlElement = (node: XmlNodeBase): node is XmlElement =>\n node.type === \"element\"\n\nconst childrenNamedLocal = (\n parent: XmlElement,\n localName: string,\n): ReadonlyArray<XmlElement> =>\n parent.children.filter(\n (child): child is XmlElement =>\n isXmlElement(child) &&\n elementLocalName(child.name).toLowerCase() === localName.toLowerCase(),\n )\n\nconst childNamedLocal = (\n parent: XmlElement,\n localName: string,\n): XmlElement | undefined => childrenNamedLocal(parent, localName)[0]\n\nconst text = (element: XmlElement | undefined): string | undefined => {\n const trimmed = element?.val.trim()\n\n return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined\n}\n\nconst attributeNamedLocal = (\n element: XmlElement,\n localName: string,\n): string | undefined => {\n for (const [name, value] of Object.entries(element.attr)) {\n if (elementLocalName(name).toLowerCase() !== localName.toLowerCase()) {\n continue\n }\n\n const trimmed = value.trim()\n\n if (trimmed.length > 0) return trimmed\n }\n\n return undefined\n}\n\nconst valuesFromDescriptions = (\n ebook: XmlElement,\n localName: string,\n): ReadonlyArray<string> =>\n childrenNamedLocal(ebook, localName).flatMap((container) => {\n const description = childNamedLocal(container, \"Description\")\n const value = text(childNamedLocal(description ?? container, \"value\"))\n\n return value !== undefined ? [value] : []\n })\n\nconst projectGutenbergIdFromEbook = (ebook: XmlElement): string | undefined => {\n const about = attributeNamedLocal(ebook, \"about\")\n const id =\n about !== undefined ? /(?:^|\\/)ebooks\\/(\\d+)$/.exec(about)?.[1] : undefined\n\n if (id === undefined) return undefined\n\n const normalized = id.replace(/^0+(?=\\d)/, \"\")\n\n return normalized !== \"0\" ? normalized : undefined\n}\n\nconst contributorsFromEbook = (\n ebook: XmlElement,\n): ReadonlyArray<ProjectGutenbergContributor> => {\n const contributors: ProjectGutenbergContributor[] = []\n\n for (const child of ebook.children) {\n if (!isXmlElement(child)) continue\n\n const isCreator = child.name.toLowerCase() === \"dcterms:creator\"\n const isMarcRelator = child.name.toLowerCase().startsWith(\"marcrel:\")\n\n if (!isCreator && !isMarcRelator) continue\n\n const name = text(\n childNamedLocal(childNamedLocal(child, \"agent\") ?? child, \"name\"),\n )\n\n if (name === undefined) continue\n\n contributors.push({\n name,\n role: isCreator ? \"aut\" : elementLocalName(child.name).toLowerCase(),\n })\n }\n\n return contributors\n}\n\nconst subjectValuesFromEbook = (ebook: XmlElement): ReadonlyArray<string> =>\n childrenNamedLocal(ebook, \"subject\").flatMap((container) => {\n const description = childNamedLocal(container, \"Description\")\n const memberOf =\n description !== undefined\n ? attributeNamedLocal(\n childNamedLocal(description, \"memberOf\") ?? description,\n \"resource\",\n )\n : undefined\n const value = text(\n description !== undefined\n ? childNamedLocal(description, \"value\")\n : undefined,\n )\n\n return value !== undefined && memberOf?.endsWith(\"/LCSH\") === true\n ? [value]\n : []\n })\n\nconst coverFromEbook = (\n ebook: XmlElement,\n): ProjectGutenbergCover | undefined => {\n const covers = childrenNamedLocal(ebook, \"hasFormat\").flatMap(\n (formatContainer) => {\n const file = childNamedLocal(formatContainer, \"file\")\n\n if (file === undefined) return []\n\n const uri = attributeNamedLocal(file, \"about\")\n const mediaType = childrenNamedLocal(file, \"format\")\n .map((format) => {\n const description = childNamedLocal(format, \"Description\")\n\n return text(childNamedLocal(description ?? format, \"value\"))\n })\n .find((value) => value?.startsWith(\"image/\") === true)\n\n if (\n uri === undefined ||\n mediaType === undefined ||\n !/\\.cover\\.(?:medium|small)\\.[a-z0-9]+(?:$|[?#])/i.test(uri)\n ) {\n return []\n }\n\n return [{ uri, mediaType }]\n },\n )\n\n return (\n covers.find((cover) => /\\.cover\\.medium\\./i.test(cover.uri)) ?? covers[0]\n )\n}\n\nconst emptyToUndefined = <T>(\n values: ReadonlyArray<T>,\n): ReadonlyArray<T> | undefined => (values.length > 0 ? values : undefined)\n\n/**\n * Parses one official Project Gutenberg per-eBook RDF document. Unsupported\n * XML yields `undefined`; malformed XML throws with a provider-specific label.\n */\nexport const parseProjectGutenbergRdf = (\n xml: string,\n): ProjectGutenbergRecord | undefined => {\n let document: XmlDocument\n\n try {\n document = new XmlDocument(xml)\n } catch (cause) {\n const message = cause instanceof Error ? cause.message : String(cause)\n throw new Error(`Project Gutenberg RDF is malformed: ${message}`, { cause })\n }\n\n if (elementLocalName(document.name).toLowerCase() !== \"rdf\") {\n return undefined\n }\n\n const ebook = childNamedLocal(document, \"ebook\")\n\n if (ebook === undefined) return undefined\n\n const id = projectGutenbergIdFromEbook(ebook)\n\n if (id === undefined) return undefined\n\n const languages = [...new Set(valuesFromDescriptions(ebook, \"language\"))]\n const subjects = [...new Set(subjectValuesFromEbook(ebook))]\n const bookshelves = [...new Set(valuesFromDescriptions(ebook, \"bookshelf\"))]\n const contributors = contributorsFromEbook(ebook)\n\n return {\n id,\n title: text(childNamedLocal(ebook, \"title\")),\n publisher: text(childNamedLocal(ebook, \"publisher\")),\n issued: text(childNamedLocal(ebook, \"issued\")),\n originalPublication: text(childNamedLocal(ebook, \"marc260\")),\n rights: text(childNamedLocal(ebook, \"rights\")),\n description: text(childNamedLocal(ebook, \"description\")),\n summary: text(childNamedLocal(ebook, \"marc520\")),\n languages: emptyToUndefined(languages),\n subjects: emptyToUndefined(subjects),\n bookshelves: emptyToUndefined(bookshelves),\n contributors: emptyToUndefined(contributors),\n cover: coverFromEbook(ebook),\n }\n}\n","import type {\n ResolvedContributor,\n ResolvedContributorRole,\n ResolvedCover,\n ResolvedDate,\n ResolvedMetadata,\n ResolvedMetadataHome,\n ResolvedPublication,\n} from \"@prose-reader/archive-reader\"\nimport { omitUndefined } from \"../../utils/omitUndefined.ts\"\nimport { PROJECT_GUTENBERG_IDENTIFIER_SCHEME } from \"./identifier.ts\"\nimport type { ProjectGutenbergRecord } from \"./parse.ts\"\n\nexport const PROJECT_GUTENBERG_MAX_SUBJECTS = 25\n\n/** Compile-enforced map from every parsed RDF field to its resolved home. */\nexport const projectGutenbergMetadataHomes = {\n id: \"identifiers\",\n title: \"title\",\n publisher: \"publication.edition.publisher\",\n issued: \"publication.edition.date\",\n originalPublication: \"publication.original\",\n rights: \"rights\",\n description: \"description\",\n summary: \"description\",\n languages: \"languages\",\n subjects: \"subjects\",\n bookshelves: \"subjects\",\n contributors: \"contributors\",\n cover: \"cover\",\n} as const satisfies Record<\n keyof ProjectGutenbergRecord,\n ResolvedMetadataHome | \"identifiers\"\n>\n\nconst MARC_RELATOR_TO_ROLE: Readonly<\n Partial<Record<string, ResolvedContributorRole>>\n> = {\n art: \"artist\",\n aut: \"author\",\n clr: \"colorist\",\n ctb: \"contributor\",\n edt: \"editor\",\n ill: \"illustrator\",\n nrt: \"narrator\",\n trl: \"translator\",\n}\n\nconst resolvedContributors = (\n record: ProjectGutenbergRecord,\n): ReadonlyArray<ResolvedContributor> | undefined => {\n const contributors = new Map<string, { name: string; roles: string[] }>()\n\n for (const contributor of record.contributors ?? []) {\n const key = contributor.name.trim().toLowerCase()\n const role =\n MARC_RELATOR_TO_ROLE[contributor.role.trim().toLowerCase()] ??\n contributor.role\n const existing = contributors.get(key)\n\n if (existing === undefined) {\n contributors.set(key, { name: contributor.name, roles: [role] })\n } else if (!existing.roles.includes(role)) {\n existing.roles.push(role)\n }\n }\n\n return contributors.size > 0 ? [...contributors.values()] : undefined\n}\n\nconst resolvedIssuedDate = (\n issued: string | undefined,\n): ResolvedDate | undefined => {\n if (issued === undefined) return undefined\n\n const match = /^(\\d{4})(?:-(\\d{2})(?:-(\\d{2}))?)?$/.exec(issued.trim())\n\n if (match === null) return undefined\n\n const year = Number(match[1])\n const month = match[2] !== undefined ? Number(match[2]) : undefined\n const day = match[3] !== undefined ? Number(match[3]) : undefined\n\n if (\n !Number.isInteger(year) ||\n year <= 0 ||\n (month !== undefined && (month < 1 || month > 12)) ||\n (day !== undefined && (day < 1 || day > 31))\n ) {\n return undefined\n }\n\n return {\n year,\n ...(month !== undefined ? { month } : {}),\n ...(day !== undefined ? { day } : {}),\n }\n}\n\nconst marc260Subfields = (\n statement: string,\n code: string,\n): ReadonlyArray<string> =>\n statement\n .split(\"$\")\n .slice(1)\n .flatMap((subfield) =>\n subfield[0]?.toLowerCase() === code.toLowerCase()\n ? [subfield.slice(1).trim()]\n : [],\n )\n .filter((value) => value.length > 0)\n\nconst originalPublicationFromMarc260 = (\n statement: string | undefined,\n): ResolvedPublication | undefined => {\n if (statement === undefined) return undefined\n\n const publisherValues = marc260Subfields(statement, \"b\")\n const rawPublisher =\n publisherValues.length === 1 ? publisherValues[0] : undefined\n const publisher = rawPublisher?.replace(/[\\s,;:/]+$/u, \"\").trim()\n const dateValues = marc260Subfields(statement, \"c\")\n const years = [\n ...new Set(\n dateValues.flatMap((value) =>\n [...value.matchAll(/\\d{4}/g)].flatMap((match) =>\n match[0] !== undefined ? [Number(match[0])] : [],\n ),\n ),\n ),\n ]\n const date = years.length === 1 ? { year: years[0] } : undefined\n const details = omitUndefined({\n date,\n publisher:\n publisher !== undefined &&\n publisher.length > 0 &&\n !/^\\[?s\\.n\\.?\\]?$/i.test(publisher)\n ? publisher\n : undefined,\n })\n\n return details.date !== undefined || details.publisher !== undefined\n ? details\n : undefined\n}\n\nconst absoluteHttpUrl = (\n value: string,\n baseUrl: string,\n): string | undefined => {\n try {\n const url = new URL(value, baseUrl)\n\n return url.protocol === \"http:\" || url.protocol === \"https:\"\n ? url.toString()\n : undefined\n } catch {\n return undefined\n }\n}\n\nconst emptyToUndefined = <T>(\n values: ReadonlyArray<T>,\n): ReadonlyArray<T> | undefined => (values.length > 0 ? values : undefined)\n\nexport const resolveProjectGutenbergRecord = (\n record: ProjectGutenbergRecord,\n options: {\n readonly baseUrl: string\n readonly matchedIdentifier: {\n readonly value: string\n readonly scheme?: string\n }\n },\n): ResolvedMetadata => {\n const identifiers = [\n options.matchedIdentifier,\n { value: record.id, scheme: PROJECT_GUTENBERG_IDENTIFIER_SCHEME },\n ].filter(\n (identifier, index, all) =>\n all.findIndex(\n (other) =>\n other.value.trim().toLowerCase() ===\n identifier.value.trim().toLowerCase() &&\n other.scheme?.trim().toLowerCase() ===\n identifier.scheme?.trim().toLowerCase(),\n ) === index,\n )\n const subjects = [\n ...new Set([...(record.subjects ?? []), ...(record.bookshelves ?? [])]),\n ].slice(0, PROJECT_GUTENBERG_MAX_SUBJECTS)\n const coverUri =\n record.cover !== undefined\n ? absoluteHttpUrl(record.cover.uri, options.baseUrl)\n : undefined\n const cover: ResolvedCover | undefined =\n coverUri !== undefined\n ? {\n uri: coverUri,\n mediaType: record.cover?.mediaType,\n confidence: \"derived\",\n }\n : undefined\n const edition = omitUndefined({\n date: resolvedIssuedDate(record.issued),\n publisher: record.publisher,\n })\n const original = originalPublicationFromMarc260(record.originalPublication)\n const publication = omitUndefined({\n original,\n edition:\n edition.date !== undefined || edition.publisher !== undefined\n ? edition\n : undefined,\n })\n\n return omitUndefined({\n title: record.title,\n publication:\n publication.original !== undefined || publication.edition !== undefined\n ? publication\n : undefined,\n description: record.summary ?? record.description,\n rights: record.rights,\n languages: emptyToUndefined([...new Set(record.languages ?? [])]),\n subjects: emptyToUndefined(subjects),\n contributors: resolvedContributors(record),\n identifiers,\n cover,\n })\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport type {\n MetadataProvider,\n MetadataProviderContext,\n} from \"../../types/provider.ts\"\nimport { MetadataProviderResponseError } from \"../responseError.ts\"\nimport { projectGutenbergLookupFromMetadata } from \"./identifier.ts\"\nimport { parseProjectGutenbergRdf } from \"./parse.ts\"\nimport { resolveProjectGutenbergRecord } from \"./resolve.ts\"\n\nexport const PROJECT_GUTENBERG_PROVIDER_ID = \"projectGutenberg\"\n\nconst DEFAULT_BASE_URL = \"https://www.gutenberg.org\"\n\nexport type ProjectGutenbergProviderOptions = {\n /** Catalog origin. Defaults to `https://www.gutenberg.org`. */\n readonly baseUrl?: string\n /** For tests, a custom agent, or a caching layer. Defaults to the global one. */\n readonly fetch?: typeof globalThis.fetch\n /** Optional identifying user agent sent with the RDF request. */\n readonly userAgent?: string\n}\n\n/**\n * Project Gutenberg's official catalog, queried through its per-eBook RDF\n * records. The provider performs exact identifier lookups only: official\n * Gutenberg URLs and numeric `ProjectGutenberg` identifiers are accepted;\n * titles are deliberately never used to crawl or fuzzily search the site.\n */\nexport const createProjectGutenbergProvider = (\n options: ProjectGutenbergProviderOptions = {},\n): MetadataProvider => {\n const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL\n\n const search = async (\n metadata: ResolvedMetadata,\n context: MetadataProviderContext,\n ) => {\n const lookup = projectGutenbergLookupFromMetadata(metadata)\n\n if (lookup === undefined) return []\n\n const recordUrl = new URL(\n `/cache/epub/${lookup.id}/pg${lookup.id}.rdf`,\n baseUrl,\n )\n const doFetch = options.fetch ?? globalThis.fetch\n const response = await doFetch(recordUrl.toString(), {\n signal: context.signal,\n headers: {\n Accept: \"application/rdf+xml, application/xml;q=0.9\",\n ...(options.userAgent !== undefined\n ? { \"User-Agent\": options.userAgent }\n : {}),\n },\n })\n\n if (response.status === 404) return []\n\n if (!response.ok) {\n throw new MetadataProviderResponseError(\n response.status,\n `Project Gutenberg metadata lookup failed with status ${response.status}`,\n )\n }\n\n const record = parseProjectGutenbergRdf(await response.text())\n\n if (record === undefined || record.id !== lookup.id) {\n throw new Error(\n `Project Gutenberg RDF did not contain the requested eBook ${lookup.id}`,\n )\n }\n\n const bookUrl = new URL(`/ebooks/${lookup.id}`, baseUrl).toString()\n\n return [\n {\n id: record.id,\n url: bookUrl,\n raw: record,\n metadata: resolveProjectGutenbergRecord(record, {\n baseUrl,\n matchedIdentifier: lookup.identifier,\n }),\n },\n ]\n }\n\n return {\n id: PROJECT_GUTENBERG_PROVIDER_ID,\n name: \"Project Gutenberg\",\n search,\n }\n}\n","import type { ResolvedMetadata } from \"@prose-reader/archive-reader\"\nimport { metadataAuthors } from \"./metadataAuthors.ts\"\n\n/**\n * Whether a catalog has anything to go on, so a lookup with nothing to ask\n * about costs no round trip. Publication details, a language, or a page count\n * narrow a search but cannot start one.\n */\nexport const hasSearchTerms = (metadata: ResolvedMetadata): boolean =>\n [\n metadata.title,\n metadata.isbn,\n metadata.gtin,\n metadata.belongsTo?.series?.[0]?.name,\n ].some((value) => value !== undefined && value.trim().length > 0) ||\n (metadata.identifiers ?? []).length > 0 ||\n metadataAuthors(metadata).length > 0\n"],"mappings":"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"}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
1
|
import { MetadataMatch } from './match.ts';
|
|
3
2
|
/**
|
|
4
|
-
* What one provider had to say
|
|
5
|
-
*
|
|
6
|
-
* merged, in {@link FetchedMetadata.metadata}, so a wrong precedence opinion
|
|
7
|
-
* stays revisable because the per-provider values never left the entity.
|
|
3
|
+
* What one provider had to say: every candidate, with the provider identity
|
|
4
|
+
* needed to present or revisit those results independently.
|
|
8
5
|
*/
|
|
9
6
|
export type FetchedMetadataSource = {
|
|
10
7
|
readonly provider: {
|
|
@@ -38,8 +35,9 @@ export type FailedMetadataProvider = {
|
|
|
38
35
|
readonly status?: number;
|
|
39
36
|
};
|
|
40
37
|
/**
|
|
41
|
-
* What remote catalogs
|
|
42
|
-
*
|
|
38
|
+
* What remote catalogs found for a book, kept as ranked alternatives rather
|
|
39
|
+
* than consolidated into a potentially synthetic record. The entity is plain
|
|
40
|
+
* JSON: structured-clone-able, persistable and cacheable.
|
|
43
41
|
*/
|
|
44
42
|
export type FetchedMetadata = {
|
|
45
43
|
/**
|
|
@@ -47,19 +45,6 @@ export type FetchedMetadata = {
|
|
|
47
45
|
* shape or meaning of existing fields changes incompatibly.
|
|
48
46
|
*/
|
|
49
47
|
readonly version: number;
|
|
50
|
-
/**
|
|
51
|
-
* What the providers found, merged field-wise with the highest-scoring
|
|
52
|
-
* accepted match winning. Empty when nothing matched confidently enough.
|
|
53
|
-
*
|
|
54
|
-
* **Only** the remote answer: the local metadata is deliberately not folded
|
|
55
|
-
* in, so a consumer decides who wins.
|
|
56
|
-
*
|
|
57
|
-
* ```ts
|
|
58
|
-
* // trust the book over the catalog, fill the gaps from the catalog
|
|
59
|
-
* const metadata = mergeResolvedMetadata(resolved.metadata, fetched.metadata)
|
|
60
|
-
* ```
|
|
61
|
-
*/
|
|
62
|
-
readonly metadata: ResolvedMetadata;
|
|
63
48
|
/**
|
|
64
49
|
* Every match across every provider, ranked best-first — the list a "did you
|
|
65
50
|
* mean?" picker renders. Ties keep the order the providers were declared in,
|
package/dist/types/match.d.ts
CHANGED
|
@@ -36,9 +36,8 @@ export type MetadataMatch = {
|
|
|
36
36
|
readonly score: number;
|
|
37
37
|
readonly signals: ReadonlyArray<MetadataMatchSignal>;
|
|
38
38
|
/**
|
|
39
|
-
* Reached `minScore
|
|
40
|
-
*
|
|
41
|
-
* consumer to offer for manual confirmation.
|
|
39
|
+
* Reached `minScore`. Rejected matches are kept and ranked, for a consumer
|
|
40
|
+
* to offer for manual confirmation.
|
|
42
41
|
*/
|
|
43
42
|
readonly accepted: boolean;
|
|
44
43
|
readonly metadata: ResolvedMetadata;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prose-reader/metadata-fetcher",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.341.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.cjs",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"test:watch": "vitest watch"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@prose-reader/archive-reader": "^1.
|
|
33
|
-
"@prose-reader/shared": "^1.
|
|
32
|
+
"@prose-reader/archive-reader": "^1.341.0",
|
|
33
|
+
"@prose-reader/shared": "^1.341.0",
|
|
34
34
|
"xmldoc": "^2.0.0"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "620bf290b88f12f2313f7b19aa4d4abdcf124ef1"
|
|
37
37
|
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
|
-
/**
|
|
3
|
-
* Merges several {@link ResolvedMetadata} into one, **first defined wins**.
|
|
4
|
-
* Precedence is the caller's — pass the sources in the order you trust them.
|
|
5
|
-
*
|
|
6
|
-
* ```ts
|
|
7
|
-
* // the book over the catalogs, catalogs filling the gaps
|
|
8
|
-
* const metadata = mergeResolvedMetadata(resolved.metadata, fetched.metadata)
|
|
9
|
-
* ```
|
|
10
|
-
*
|
|
11
|
-
* Field-wise rather than object-wise, so a source knowing only a cover
|
|
12
|
-
* contributes it without hiding another's title. Two fields are additive
|
|
13
|
-
* instead: `identifiers` concatenate (deduped on scheme + value, mirroring
|
|
14
|
-
* `resolveMetadata`), and `belongsTo` merges `series` and `collection`
|
|
15
|
-
* independently.
|
|
16
|
-
*
|
|
17
|
-
* Everything else takes the first stated value whole. Unioning keyword lists
|
|
18
|
-
* across catalogs is a judgement call for the consumer, not for a merge that
|
|
19
|
-
* has to stay predictable.
|
|
20
|
-
*/
|
|
21
|
-
export declare const mergeResolvedMetadata: (...entries: ReadonlyArray<ResolvedMetadata | undefined>) => ResolvedMetadata;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|