@prose-reader/metadata-fetcher 1.336.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/LICENCE +7 -0
- package/dist/fetchMetadata.d.ts +69 -0
- package/dist/fetchMetadata.test.d.ts +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +498 -0
- package/dist/index.js.map +1 -0
- package/dist/match/scoreMetadataCandidate.d.ts +37 -0
- package/dist/match/scoreMetadataCandidate.test.d.ts +1 -0
- package/dist/match/similarity.d.ts +20 -0
- package/dist/match/similarity.test.d.ts +1 -0
- package/dist/merge/mergeResolvedMetadata.d.ts +21 -0
- package/dist/merge/mergeResolvedMetadata.test.d.ts +1 -0
- package/dist/providers/openLibrary/createOpenLibraryProvider.d.ts +32 -0
- package/dist/providers/openLibrary/createOpenLibraryProvider.test.d.ts +1 -0
- package/dist/providers/openLibrary/marcLanguage.d.ts +6 -0
- package/dist/providers/openLibrary/parse.d.ts +29 -0
- package/dist/providers/openLibrary/resolve.d.ts +44 -0
- package/dist/providers/openLibrary/resolve.test.d.ts +1 -0
- package/dist/providers/responseError.d.ts +24 -0
- package/dist/report.d.ts +2 -0
- package/dist/types/fetchedMetadata.d.ts +86 -0
- package/dist/types/match.d.ts +48 -0
- package/dist/types/provider.d.ts +76 -0
- package/dist/utils/hasSearchTerms.d.ts +7 -0
- package/dist/utils/hasSearchTerms.test.d.ts +1 -0
- package/dist/utils/json.d.ts +11 -0
- package/dist/utils/metadataAuthors.d.ts +8 -0
- package/dist/utils/omitUndefined.d.ts +6 -0
- package/dist/utils/toIsbn13.d.ts +16 -0
- package/dist/utils/toIsbn13.test.d.ts +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
|
+
import { MetadataMatchSignal } from '../types/match.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Intent, not statistics: identifiers *are* the book, a title is what a human
|
|
5
|
+
* recognizes it by, everything else corroborates. An edition detail that
|
|
6
|
+
* differs must never sink an otherwise convincing match on its own.
|
|
7
|
+
*/
|
|
8
|
+
export declare const METADATA_MATCH_WEIGHTS: {
|
|
9
|
+
readonly isbn: 1;
|
|
10
|
+
readonly gtin: 1;
|
|
11
|
+
readonly identifiers: 0.6;
|
|
12
|
+
readonly title: 0.8;
|
|
13
|
+
readonly contributors: 0.5;
|
|
14
|
+
readonly series: 0.3;
|
|
15
|
+
readonly published: 0.2;
|
|
16
|
+
readonly publisher: 0.15;
|
|
17
|
+
readonly languages: 0.15;
|
|
18
|
+
readonly numberOfPages: 0.15;
|
|
19
|
+
};
|
|
20
|
+
export type ScoredMetadataCandidate = {
|
|
21
|
+
readonly score: number;
|
|
22
|
+
readonly signals: ReadonlyArray<MetadataMatchSignal>;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* How well a candidate agrees with the query, field by field, and why. Every
|
|
26
|
+
* provider is scored by these rules, so their scores stay comparable:
|
|
27
|
+
*
|
|
28
|
+
* - **Only comparable fields count** — a field is compared when both sides
|
|
29
|
+
* state it, so what the book doesn't know can neither raise nor lower a
|
|
30
|
+
* candidate, and nothing in common scores `0`.
|
|
31
|
+
* - **The aggregate is a weighted average** ({@link METADATA_MATCH_WEIGHTS}),
|
|
32
|
+
* putting a rich query and a sparse one on the same scale.
|
|
33
|
+
* - **A stated identifier settles it, both ways** — agreeing pins the score to
|
|
34
|
+
* `1`, contradicting to `0`. The latter is what sinks the wrong edition that
|
|
35
|
+
* agrees on everything a weighted average can see.
|
|
36
|
+
*/
|
|
37
|
+
export declare const scoreMetadataCandidate: (query: ResolvedMetadata, candidate: ResolvedMetadata) => ScoredMetadataCandidate;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** `Les Misérables` ≡ `les miserables`, `Dune: Messiah` ≡ `dune messiah`. */
|
|
2
|
+
export declare const normalizeForComparison: (value: string) => string;
|
|
3
|
+
/**
|
|
4
|
+
* Sørensen–Dice coefficient over character bigrams, `0` to `1`.
|
|
5
|
+
*
|
|
6
|
+
* Bigrams rather than an edit distance: titles disagree by whole words far
|
|
7
|
+
* more often than by typos. Spaces are stripped so `Spider-Man` and
|
|
8
|
+
* `Spider Man` stay exact.
|
|
9
|
+
*/
|
|
10
|
+
export declare const textSimilarity: (a: string, b: string) => number;
|
|
11
|
+
/**
|
|
12
|
+
* {@link textSimilarity}, with `Dune` matched against `Dune: a novel`.
|
|
13
|
+
*
|
|
14
|
+
* Only when **one** side states a subtitle: that asymmetry is the same title
|
|
15
|
+
* catalogued at a different depth. When both state one, both catalogers meant
|
|
16
|
+
* it — `Dune: Book One` and `Dune: Messiah` are two books.
|
|
17
|
+
*/
|
|
18
|
+
export declare const titleSimilarity: (a: string, b: string) => number;
|
|
19
|
+
/** {@link textSimilarity} on sorted tokens: `Herbert, Frank` ≡ `Frank Herbert`. */
|
|
20
|
+
export declare const personNameSimilarity: (a: string, b: string) => number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { MetadataProvider } from '../../types/provider.ts';
|
|
2
|
+
export declare const OPEN_LIBRARY_PROVIDER_ID = "openLibrary";
|
|
3
|
+
export type OpenLibraryProviderOptions = {
|
|
4
|
+
/** API origin. Defaults to `https://openlibrary.org`. */
|
|
5
|
+
readonly baseUrl?: string;
|
|
6
|
+
/** Cover service origin. Defaults to `https://covers.openlibrary.org`. */
|
|
7
|
+
readonly coversBaseUrl?: string;
|
|
8
|
+
/** For tests, a custom agent, or a caching layer. Defaults to the global one. */
|
|
9
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
10
|
+
/**
|
|
11
|
+
* Open Library's API etiquette asks for an identifying `User-Agent` (app
|
|
12
|
+
* name + contact) and throttles anonymous traffic harder.
|
|
13
|
+
*/
|
|
14
|
+
readonly userAgent?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Open Library — the Internet Archive's open bibliographic catalog. Free, no
|
|
18
|
+
* key, covers books broadly (much less so comics and manga).
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const provider = createOpenLibraryProvider({
|
|
22
|
+
* userAgent: "MyReader/1.0 (contact@example.com)",
|
|
23
|
+
* })
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Lookup strategy, at most two requests: an **ISBN search** when the book
|
|
27
|
+
* states one — the catalog then verifies the identity for us — falling back
|
|
28
|
+
* to a **title (+ first author) search** when the ISBN is unknown to it or
|
|
29
|
+
* absent. A query with neither an ISBN nor a title yields no candidates
|
|
30
|
+
* rather than a fishing expedition.
|
|
31
|
+
*/
|
|
32
|
+
export declare const createOpenLibraryProvider: (options?: OpenLibraryProviderOptions) => MetadataProvider;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes one MARC language code to its BCP 47 primary subtag. Unknown
|
|
3
|
+
* codes (the long tail of MARC) pass through lowercased rather than being
|
|
4
|
+
* dropped — a language we can't name is still a language the record stated.
|
|
5
|
+
*/
|
|
6
|
+
export declare const marcLanguageToBcp47: (code: string) => string | undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry of an Open Library `search.json` response, field names mirroring
|
|
3
|
+
* the API (same rule as the archive parsers: a parsed source looks like its
|
|
4
|
+
* source document). Every field is optional — the API omits what it doesn't
|
|
5
|
+
* have, and the `fields` parameter decides what it may return at all.
|
|
6
|
+
*
|
|
7
|
+
* @see https://openlibrary.org/dev/docs/api/search
|
|
8
|
+
*/
|
|
9
|
+
export type OpenLibraryDoc = {
|
|
10
|
+
/** Work key, e.g. `/works/OL45883W`. */
|
|
11
|
+
readonly key?: string;
|
|
12
|
+
readonly title?: string;
|
|
13
|
+
readonly subtitle?: string;
|
|
14
|
+
readonly author_name?: ReadonlyArray<string>;
|
|
15
|
+
readonly first_publish_year?: number;
|
|
16
|
+
readonly publisher?: ReadonlyArray<string>;
|
|
17
|
+
/** MARC 21 language codes (`eng`, `fre`), not BCP 47. */
|
|
18
|
+
readonly language?: ReadonlyArray<string>;
|
|
19
|
+
readonly subject?: ReadonlyArray<string>;
|
|
20
|
+
readonly number_of_pages_median?: number;
|
|
21
|
+
/** Cover id, addressing `covers.openlibrary.org`. */
|
|
22
|
+
readonly cover_i?: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Reads the `docs` of a `search.json` payload. A payload that isn't a record,
|
|
26
|
+
* or carries no `docs` array, yields an empty list: an unexpected response
|
|
27
|
+
* shape is "this catalog has nothing for us", not a crash.
|
|
28
|
+
*/
|
|
29
|
+
export declare const parseOpenLibrarySearchResponse: (payload: unknown) => ReadonlyArray<OpenLibraryDoc>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
|
+
import { OpenLibraryDoc } from './parse.ts';
|
|
3
|
+
/**
|
|
4
|
+
* A popular work carries hundreds of subject headings, most of them long-tail
|
|
5
|
+
* noise ("Accessible book", "Protected DAISY"), which would dwarf the entity a
|
|
6
|
+
* consumer persists. The API puts the meaningful ones first.
|
|
7
|
+
*/
|
|
8
|
+
export declare const OPEN_LIBRARY_MAX_SUBJECTS = 25;
|
|
9
|
+
/**
|
|
10
|
+
* Where every {@link OpenLibraryDoc} field lands in {@link ResolvedMetadata},
|
|
11
|
+
* compile-enforced: adding a field to the parser without declaring its home is
|
|
12
|
+
* a type error. The archive parsers' losslessness contract, and the mapping
|
|
13
|
+
* documentation.
|
|
14
|
+
*/
|
|
15
|
+
export declare const openLibraryMetadataHomes: {
|
|
16
|
+
readonly key: "identifiers";
|
|
17
|
+
readonly title: "title";
|
|
18
|
+
readonly subtitle: "title";
|
|
19
|
+
readonly author_name: "contributors";
|
|
20
|
+
readonly first_publish_year: "published";
|
|
21
|
+
readonly publisher: "publisher";
|
|
22
|
+
readonly language: "languages";
|
|
23
|
+
readonly subject: "subjects";
|
|
24
|
+
readonly number_of_pages_median: "numberOfPages";
|
|
25
|
+
readonly cover_i: "cover";
|
|
26
|
+
};
|
|
27
|
+
export declare const OPEN_LIBRARY_IDENTIFIER_SCHEME = "OpenLibrary";
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes one search hit into the cross-format vocabulary. Two choices
|
|
30
|
+
* worth stating:
|
|
31
|
+
*
|
|
32
|
+
* - **`title` folds in `subtitle`** (`Dune: Messiah`): the vocabulary has one
|
|
33
|
+
* title field, and an OPF `dc:title` normally carries the subtitle too, so
|
|
34
|
+
* comparing a bare title against a full one would cost match score.
|
|
35
|
+
* - **`isbn` is set only for an ISBN lookup**, to the queried one — the API
|
|
36
|
+
* answered "this work has that ISBN", a fact about the record. A hit
|
|
37
|
+
* describes a *work*, whose editions each have their own ISBN, so picking
|
|
38
|
+
* one out of a title search would be fabrication.
|
|
39
|
+
*/
|
|
40
|
+
export declare const resolveOpenLibraryDoc: (doc: OpenLibraryDoc, options: {
|
|
41
|
+
readonly coversBaseUrl: string;
|
|
42
|
+
/** The ISBN this record was looked up by, when it was. */
|
|
43
|
+
readonly isbn?: string;
|
|
44
|
+
}) => ResolvedMetadata;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by a provider when a catalog answered with a failing status. Carrying
|
|
3
|
+
* it is what lets `fetchMetadata` report *why* a provider dropped out: `429`
|
|
4
|
+
* is "ask again later", `503` is "the catalog is down", and a consumer decides
|
|
5
|
+
* differently for each.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* if (!response.ok) {
|
|
9
|
+
* throw new MetadataProviderResponseError(response.status, "My Catalog search failed")
|
|
10
|
+
* }
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare class MetadataProviderResponseError extends Error {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
constructor(status: number, message?: string);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The HTTP status behind a thrown error, when there is one.
|
|
19
|
+
*
|
|
20
|
+
* Structural rather than `instanceof`, so a provider built on another HTTP
|
|
21
|
+
* client works unadapted. No status means it was never a response — network,
|
|
22
|
+
* parse, bug — which is itself the answer.
|
|
23
|
+
*/
|
|
24
|
+
export declare const responseErrorStatus: (error: unknown) => number | undefined;
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
|
+
import { MetadataMatch } from './match.ts';
|
|
3
|
+
/**
|
|
4
|
+
* What one provider had to say — the twin of `ResolvedArchiveSources`, with
|
|
5
|
+
* the same contract: everything a provider contributed is also represented,
|
|
6
|
+
* merged, in {@link FetchedMetadata.metadata}, so a wrong precedence opinion
|
|
7
|
+
* stays revisable because the per-provider values never left the entity.
|
|
8
|
+
*/
|
|
9
|
+
export type FetchedMetadataSource = {
|
|
10
|
+
readonly provider: {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Every candidate the provider returned, scored, ranked best-first and
|
|
16
|
+
* capped to `limit` — rejected ones included, since "found three books, none
|
|
17
|
+
* convincing" is an answer rather than an absence.
|
|
18
|
+
*/
|
|
19
|
+
readonly matches: ReadonlyArray<MetadataMatch>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Per-provider detail, keyed by {@link MetadataProvider.id}. Open-ended:
|
|
23
|
+
* unlike the closed set of archive sources, the keys are whatever providers
|
|
24
|
+
* you passed.
|
|
25
|
+
*/
|
|
26
|
+
export type FetchedMetadataSources = Readonly<Record<string, FetchedMetadataSource>>;
|
|
27
|
+
export type FailedMetadataProvider = {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
/**
|
|
30
|
+
* Set when the failure was a response rather than a network error, a
|
|
31
|
+
* malformed payload or a bug — the difference between "rate limited, ask
|
|
32
|
+
* again later" (`429`), "the catalog is down" (`5xx`) and "we asked wrong"
|
|
33
|
+
* (`4xx`), which a bare "it failed" cannot express.
|
|
34
|
+
*
|
|
35
|
+
* Providers report it by throwing `MetadataProviderResponseError`, or any
|
|
36
|
+
* error carrying a numeric `status`.
|
|
37
|
+
*/
|
|
38
|
+
readonly status?: number;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* What remote catalogs know about a book — the equivalent of `ResolvedArchive`
|
|
42
|
+
* and just as plain: structured-clone-able, persistable, cacheable.
|
|
43
|
+
*/
|
|
44
|
+
export type FetchedMetadata = {
|
|
45
|
+
/**
|
|
46
|
+
* Schema version, for consumers persisting the entity. Bumped only when the
|
|
47
|
+
* shape or meaning of existing fields changes incompatibly.
|
|
48
|
+
*/
|
|
49
|
+
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
|
+
/**
|
|
64
|
+
* Every match across every provider, ranked best-first — the list a "did you
|
|
65
|
+
* mean?" picker renders. Ties keep the order the providers were declared in,
|
|
66
|
+
* so that order doubles as your tie-break precedence.
|
|
67
|
+
*/
|
|
68
|
+
readonly matches: ReadonlyArray<MetadataMatch>;
|
|
69
|
+
readonly sources: FetchedMetadataSources;
|
|
70
|
+
/**
|
|
71
|
+
* Providers whose search threw, each with its HTTP `status` when the catalog
|
|
72
|
+
* answered with one. Always present, and the only trace a swallowed failure
|
|
73
|
+
* leaves — a consumer refusing to cache a partial answer needs it.
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* const fetched = await fetchMetadata(resolved, { providers })
|
|
77
|
+
*
|
|
78
|
+
* // don't persist "we found nothing" when we simply couldn't ask
|
|
79
|
+
* if (fetched.failedProviders.length === 0) cache.set(bookId, fetched)
|
|
80
|
+
*
|
|
81
|
+
* // rate limited rather than broken: worth asking again, later
|
|
82
|
+
* const throttled = fetched.failedProviders.filter(({ status }) => status === 429)
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
readonly failedProviders: ReadonlyArray<FailedMetadataProvider>;
|
|
86
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
|
+
/**
|
|
3
|
+
* The fields the matcher compares — only ever when **both** sides state one,
|
|
4
|
+
* so what the query doesn't know cannot count against a candidate.
|
|
5
|
+
*/
|
|
6
|
+
export type MetadataMatchField = "isbn" | "gtin" | "identifiers" | "title" | "contributors" | "series" | "publisher" | "published" | "languages" | "numberOfPages";
|
|
7
|
+
/**
|
|
8
|
+
* One field comparison. The compared values sit next to the score so a match
|
|
9
|
+
* is explainable to a user — "same title, different publisher" — without
|
|
10
|
+
* re-deriving anything.
|
|
11
|
+
*/
|
|
12
|
+
export type MetadataMatchSignal = {
|
|
13
|
+
readonly field: MetadataMatchField;
|
|
14
|
+
/** How much the two values agree, `0` (nothing in common) to `1` (equal). */
|
|
15
|
+
readonly score: number;
|
|
16
|
+
/** The field's relative importance in the aggregate (see `METADATA_MATCH_WEIGHTS`). */
|
|
17
|
+
readonly weight: number;
|
|
18
|
+
/** The local value that was compared, rendered for display. */
|
|
19
|
+
readonly query: string;
|
|
20
|
+
/** The candidate value that was compared, rendered for display. */
|
|
21
|
+
readonly candidate: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A scored candidate: the metadata a provider found, plus why we believe (or
|
|
25
|
+
* don't) that it is the same book.
|
|
26
|
+
*/
|
|
27
|
+
export type MetadataMatch = {
|
|
28
|
+
readonly providerId: string;
|
|
29
|
+
/**
|
|
30
|
+
* Aggregate confidence, `0` to `1`: the weight-averaged score of every
|
|
31
|
+
* comparable field — except when both sides state an identifier, which
|
|
32
|
+
* settles it outright: `1` if it agrees (an ISBN/GTIN match *is* the book),
|
|
33
|
+
* `0` if it contradicts. `0` too when the two sides had no field in common
|
|
34
|
+
* to compare.
|
|
35
|
+
*/
|
|
36
|
+
readonly score: number;
|
|
37
|
+
readonly signals: ReadonlyArray<MetadataMatchSignal>;
|
|
38
|
+
/**
|
|
39
|
+
* Reached `minScore`, and so contributed to the merged
|
|
40
|
+
* `FetchedMetadata.metadata`. Rejected matches are kept and ranked, for a
|
|
41
|
+
* consumer to offer for manual confirmation.
|
|
42
|
+
*/
|
|
43
|
+
readonly accepted: boolean;
|
|
44
|
+
readonly metadata: ResolvedMetadata;
|
|
45
|
+
readonly id?: string;
|
|
46
|
+
readonly url?: string;
|
|
47
|
+
readonly raw?: unknown;
|
|
48
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
|
+
export type MetadataIdentifier = NonNullable<ResolvedMetadata["identifiers"]>[number];
|
|
3
|
+
export type MetadataProviderContext = {
|
|
4
|
+
/**
|
|
5
|
+
* How many candidates the caller will keep. A hint for the provider's own
|
|
6
|
+
* page size — the fetch enforces it as a hard cap after ranking anyway, so
|
|
7
|
+
* returning more is allowed but wasteful.
|
|
8
|
+
*/
|
|
9
|
+
readonly limit: number;
|
|
10
|
+
readonly signal?: AbortSignal;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* One record a provider believes could be the book. A provider's whole job is
|
|
14
|
+
* to search its catalog and normalize each hit into {@link ResolvedMetadata};
|
|
15
|
+
* it does **not** score its own candidates — the fetch does that, identically
|
|
16
|
+
* for everyone, so scores stay comparable.
|
|
17
|
+
*/
|
|
18
|
+
export type MetadataCandidate = {
|
|
19
|
+
readonly metadata: ResolvedMetadata;
|
|
20
|
+
readonly id?: string;
|
|
21
|
+
readonly url?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The provider's own record — provenance, and the escape hatch for fields
|
|
24
|
+
* with no home in the vocabulary. Kept only when `includeRaw` is on.
|
|
25
|
+
*/
|
|
26
|
+
readonly raw?: unknown;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* A pluggable metadata source. Implementing one is: pick a stable `id`, read
|
|
30
|
+
* the terms you can search on out of the metadata, return normalized
|
|
31
|
+
* candidates.
|
|
32
|
+
*
|
|
33
|
+
* Both sides of a lookup speak {@link ResolvedMetadata} — what the book said
|
|
34
|
+
* going in, what the catalog says coming back. There is no separate query
|
|
35
|
+
* shape to learn: the vocabulary is already sparse by contract (`field !==
|
|
36
|
+
* undefined` is a reliable presence check) and already carries everything a
|
|
37
|
+
* catalog could key on, down to the format-scoped corners. `metadataAuthors`
|
|
38
|
+
* is exported for the one derivation that needs the role vocabulary.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { type MetadataProvider, metadataAuthors } from "@prose-reader/metadata-fetcher"
|
|
42
|
+
*
|
|
43
|
+
* const myProvider: MetadataProvider = {
|
|
44
|
+
* id: "myCatalog",
|
|
45
|
+
* name: "My Catalog",
|
|
46
|
+
* search: async (metadata, { limit, signal }) => {
|
|
47
|
+
* if (metadata.title === undefined) return []
|
|
48
|
+
*
|
|
49
|
+
* const response = await fetch(
|
|
50
|
+
* `https://example.com/search?q=${encodeURIComponent(metadata.title)}` +
|
|
51
|
+
* `&author=${encodeURIComponent(metadataAuthors(metadata)[0] ?? "")}`,
|
|
52
|
+
* { signal },
|
|
53
|
+
* )
|
|
54
|
+
* const { results } = await response.json()
|
|
55
|
+
*
|
|
56
|
+
* return results.slice(0, limit).map((result) => ({
|
|
57
|
+
* id: result.id,
|
|
58
|
+
* url: `https://example.com/book/${result.id}`,
|
|
59
|
+
* raw: result,
|
|
60
|
+
* metadata: { title: result.name, isbn: result.isbn },
|
|
61
|
+
* }))
|
|
62
|
+
* },
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export type MetadataProvider = {
|
|
67
|
+
/** Unique across the providers of one fetch: it keys the result's `sources`. */
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly name: string;
|
|
70
|
+
/**
|
|
71
|
+
* The candidates the catalog has, best-effort: an empty list when there is
|
|
72
|
+
* nothing to search on, or nothing found. Throwing is allowed — a failing
|
|
73
|
+
* provider never fails the fetch, it lands in `failedProviders`.
|
|
74
|
+
*/
|
|
75
|
+
readonly search: (metadata: ResolvedMetadata, context: MetadataProviderContext) => Promise<ReadonlyArray<MetadataCandidate>>;
|
|
76
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a catalog has anything to go on, so a lookup with nothing to ask
|
|
4
|
+
* about costs no round trip. Descriptive fields alone — a publisher, a
|
|
5
|
+
* language, a page count — narrow a search but cannot start one.
|
|
6
|
+
*/
|
|
7
|
+
export declare const hasSearchTerms: (metadata: ResolvedMetadata) => boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readers for `JSON.parse` output. A catalog's payload is third-party data, so
|
|
3
|
+
* it enters as `unknown` and every field goes through a guard: a renamed or
|
|
4
|
+
* nulled field reads as absent instead of crashing the fetch, or producing a
|
|
5
|
+
* candidate with `undefined` where a string was promised.
|
|
6
|
+
*/
|
|
7
|
+
export declare const isJsonRecord: (value: unknown) => value is Record<string, unknown>;
|
|
8
|
+
export declare const readString: (record: Record<string, unknown>, key: string) => string | undefined;
|
|
9
|
+
export declare const readNumber: (record: Record<string, unknown>, key: string) => number | undefined;
|
|
10
|
+
export declare const readStringArray: (record: Record<string, unknown>, key: string) => ReadonlyArray<string> | undefined;
|
|
11
|
+
export declare const readRecordArray: (record: Record<string, unknown>, key: string) => ReadonlyArray<Record<string, unknown>>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ResolvedMetadata } from '@prose-reader/archive-reader';
|
|
2
|
+
/**
|
|
3
|
+
* The names to match a publication on: those credited as `author`, else — for
|
|
4
|
+
* a publication crediting nobody as one, like a comic archive listing only a
|
|
5
|
+
* penciler — every contributor. Order is preserved (sources list the lead
|
|
6
|
+
* creator first) and duplicates are dropped.
|
|
7
|
+
*/
|
|
8
|
+
export declare const metadataAuthors: (metadata: ResolvedMetadata) => ReadonlyArray<string>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a copy of `obj` without the keys whose value is `undefined`, so
|
|
3
|
+
* results stay sparse (an absent key and an `undefined` value are the same
|
|
4
|
+
* statement: "nothing was found for this field").
|
|
5
|
+
*/
|
|
6
|
+
export declare const omitUndefined: <T extends object>(obj: T) => T;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put an ISBN in its 13-digit form so the two printed on the same book compare
|
|
3
|
+
* equal. A book carries an ISBN-10 in its OPF and an ISBN-13 in a catalog
|
|
4
|
+
* record all the time; comparing them verbatim would read as "different
|
|
5
|
+
* books", which — ISBN being a decisive match signal — is the most damaging
|
|
6
|
+
* false negative the matcher can produce.
|
|
7
|
+
*
|
|
8
|
+
* Input is put through `normalizeIsbn`, the same function that produces
|
|
9
|
+
* {@link ResolvedMetadata.isbn} in the first place, so the hyphenated and
|
|
10
|
+
* prefixed forms a human types (`978-0-441-01359-3`, `ISBN 0-441-01359-7`)
|
|
11
|
+
* are recognized rather than silently dropped.
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when nothing ISBN-shaped can be recovered (check digits
|
|
14
|
+
* are not verified: a wrong one still identifies the intended book).
|
|
15
|
+
*/
|
|
16
|
+
export declare const toIsbn13: (isbn: string) => string | undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prose-reader/metadata-fetcher",
|
|
3
|
+
"version": "1.336.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public",
|
|
10
|
+
"registry": "https://registry.npmjs.org/"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"prose-source": "./src/index.ts",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"require": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"files": [
|
|
22
|
+
"/dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "vite build --watch --mode development",
|
|
26
|
+
"build": "tsc && vite build",
|
|
27
|
+
"test": "vitest run --coverage",
|
|
28
|
+
"tsc": "tsc",
|
|
29
|
+
"test:watch": "vitest watch"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@prose-reader/archive-reader": "^1.336.0",
|
|
33
|
+
"@prose-reader/shared": "^1.336.0"
|
|
34
|
+
},
|
|
35
|
+
"gitHead": "312ee6e898b4e4b2389bc23789082cf31f9db2f9"
|
|
36
|
+
}
|