@khorsheed/dsh-reader 0.2.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.
Files changed (58) hide show
  1. package/README.en.md +90 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +90 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +15119 -0
  6. package/lib/index.js +3660 -0
  7. package/lib/invariant.js +38 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +1738 -0
  10. package/lib/typert.remote-client.d.ts +82 -0
  11. package/lib/typert.remote-client.js +1115 -0
  12. package/lib/types/arxiv.d.ts +47 -0
  13. package/lib/types/arxiv.js +76 -0
  14. package/lib/types/client/ReaderPane.d.ts +71 -0
  15. package/lib/types/client/ReaderPane.js +3135 -0
  16. package/lib/types/client/contract.d.ts +254 -0
  17. package/lib/types/client/contract.js +1 -0
  18. package/lib/types/client/definition.d.ts +27 -0
  19. package/lib/types/client/definition.js +32 -0
  20. package/lib/types/client/extract-article.d.ts +102 -0
  21. package/lib/types/client/extract-article.js +1024 -0
  22. package/lib/types/client/icons.d.ts +27 -0
  23. package/lib/types/client/icons.js +11 -0
  24. package/lib/types/client/index.d.ts +34 -0
  25. package/lib/types/client/index.js +222 -0
  26. package/lib/types/client/locales.d.ts +391 -0
  27. package/lib/types/client/locales.js +382 -0
  28. package/lib/types/client/parse-rss.d.ts +69 -0
  29. package/lib/types/client/parse-rss.js +656 -0
  30. package/lib/types/client/quote.d.ts +89 -0
  31. package/lib/types/client/quote.js +119 -0
  32. package/lib/types/client/selectors.d.ts +167 -0
  33. package/lib/types/client/selectors.js +413 -0
  34. package/lib/types/client/session.d.ts +171 -0
  35. package/lib/types/client/session.js +223 -0
  36. package/lib/types/client/store.d.ts +181 -0
  37. package/lib/types/client/store.js +226 -0
  38. package/lib/types/client/translate.d.ts +379 -0
  39. package/lib/types/client/translate.js +873 -0
  40. package/lib/types/index.d.ts +48 -0
  41. package/lib/types/index.js +25 -0
  42. package/lib/types/invariant.d.ts +29 -0
  43. package/lib/types/invariant.js +38 -0
  44. package/lib/types/link-resolvers.d.ts +107 -0
  45. package/lib/types/link-resolvers.js +299 -0
  46. package/lib/types/remote.d.ts +224 -0
  47. package/lib/types/remote.js +275 -0
  48. package/lib/types/schedule.d.ts +78 -0
  49. package/lib/types/schedule.js +114 -0
  50. package/lib/types/service.d.ts +633 -0
  51. package/lib/types/service.js +1697 -0
  52. package/lib/types/store.d.ts +224 -0
  53. package/lib/types/store.js +916 -0
  54. package/lib/types/types.d.ts +710 -0
  55. package/lib/types/types.js +182 -0
  56. package/locale/en.json +6 -0
  57. package/locale/zh.json +6 -0
  58. package/package.json +128 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Reader plugin, host half: provides the service core and mounts its Remote
3
+ * face. Composing this plugin out of a profile removes every surface it adds.
4
+ *
5
+ * The host half owns the two things the browser cannot do: reaching the
6
+ * network through the sanctioned `ctx.web` seam, and persisting state next to
7
+ * the deployment. The browser half owns parsing. See `service.ts` for the
8
+ * fetch policy and `store.ts` for the persistence shape; both are deliberately
9
+ * mirrored on the side-chat precedent so this package reads like its
10
+ * neighbours.
11
+ *
12
+ * @module @khorsheed/dsh-reader
13
+ */
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ import { ReaderService } from './service.ts';
16
+ export { ReaderService, classifyPayload, normalizeUrl } from './service.ts';
17
+ export { ReaderRemoteService } from './remote.ts';
18
+ export type { ReaderRemoteConfig } from './remote.ts';
19
+ export { MAX_CACHED_BODIES, MAX_TAGS, ReaderStore, ReaderStoreError, boundAnnotations, boundPayloads, emptyStateDoc, normalizeStateDoc, pruneOrphanTags, resolveReaderStateRoot, serializeStateDoc, } from './store.ts';
20
+ export type { ReaderStoreRead } from './store.ts';
21
+ export * from './schedule.ts';
22
+ export * from './types.ts';
23
+ declare module '@deepseek-ai/cordis' {
24
+ interface Context {
25
+ /** The reader plugin's service core. */
26
+ reader: ReaderService;
27
+ }
28
+ }
29
+ /** Cordis plugin name used by loader diagnostics. */
30
+ export declare const name = "reader";
31
+ /** Optional state-root override, for deployments that keep state elsewhere. */
32
+ export interface ReaderConfig {
33
+ /** Directory holding `state.json`; defaults to `$DSH_HOME/state/dsh-reader`. */
34
+ readonly stateRoot?: string;
35
+ }
36
+ /**
37
+ * Plugin body: provide the service core, then mount the Remote data face.
38
+ *
39
+ * No `static inject`: every optional capability (`fs`, `web`, `sideChat`) is
40
+ * probed at the point of use, so the row boots in any composition and degrades
41
+ * — memory-only state without `fs`, `unsupported-content` without `web`, a
42
+ * hidden side-chat gesture without `sideChat`.
43
+ *
44
+ * @param ctx - owning Cordis context.
45
+ * @param config - optional plugin config.
46
+ */
47
+ export declare function apply(ctx: Context, config?: ReaderConfig): void;
48
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { ReaderService } from "./service.js";
2
+ import { ReaderRemoteService } from "./remote.js";
3
+ export { ReaderService, classifyPayload, normalizeUrl } from "./service.js";
4
+ export { ReaderRemoteService } from "./remote.js";
5
+ export { MAX_CACHED_BODIES, MAX_TAGS, ReaderStore, ReaderStoreError, boundAnnotations, boundPayloads, emptyStateDoc, normalizeStateDoc, pruneOrphanTags, resolveReaderStateRoot, serializeStateDoc, } from "./store.js";
6
+ export * from "./schedule.js";
7
+ export * from "./types.js";
8
+ /** Cordis plugin name used by loader diagnostics. */
9
+ export const name = 'reader';
10
+ /**
11
+ * Plugin body: provide the service core, then mount the Remote data face.
12
+ *
13
+ * No `static inject`: every optional capability (`fs`, `web`, `sideChat`) is
14
+ * probed at the point of use, so the row boots in any composition and degrades
15
+ * — memory-only state without `fs`, `unsupported-content` without `web`, a
16
+ * hidden side-chat gesture without `sideChat`.
17
+ *
18
+ * @param ctx - owning Cordis context.
19
+ * @param config - optional plugin config.
20
+ */
21
+ export function apply(ctx, config = {}) {
22
+ const service = new ReaderService(ctx, config);
23
+ ctx.provide('reader', service);
24
+ ctx.plugin(ReaderRemoteService, {});
25
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Package-owned invariant companion for `@khorsheed/dsh-reader`.
3
+ * @module @khorsheed/dsh-reader/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants';
7
+ /** Cordis companion plugin name. */
8
+ export declare const name = "reader-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ export declare const inject: string[];
11
+ /**
12
+ * Capability probe for the reader: the host half fetches and persists, so it
13
+ * needs a runtime that has `URL` (for the source-URL policy) and a timer (for
14
+ * the daily refresh). Both are standard in a dsh host, so this is a
15
+ * low-value check — registered for package-ownership consistency, and kept
16
+ * pure (no network, no filesystem, no session) so a broken deployment is never
17
+ * reported as a healthy one and vice versa.
18
+ *
19
+ * @param _ctx - host context (unused; the probe touches process globals only).
20
+ * @param fail - invariant failure reporter.
21
+ */
22
+ export declare const install: InvariantInstaller;
23
+ /**
24
+ * Register this package's invariant companion.
25
+ * @param ctx - Cordis context carrying the invariant service.
26
+ * @returns the installed registration's disposer after setup succeeds.
27
+ */
28
+ export declare const apply: (ctx: Context) => Promise<() => void>;
29
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Package-owned invariant companion for `@khorsheed/dsh-reader`.
3
+ * @module @khorsheed/dsh-reader/invariant
4
+ */
5
+ /** The identity the package publishes under (the manifest's `name`). */
6
+ const PACKAGE_NAME = '@khorsheed/dsh-reader';
7
+ /** Cordis companion plugin name. */
8
+ export const name = 'reader-invariant';
9
+ /** Service required before the companion can reserve package ownership. */
10
+ export const inject = ['invariants'];
11
+ /**
12
+ * Capability probe for the reader: the host half fetches and persists, so it
13
+ * needs a runtime that has `URL` (for the source-URL policy) and a timer (for
14
+ * the daily refresh). Both are standard in a dsh host, so this is a
15
+ * low-value check — registered for package-ownership consistency, and kept
16
+ * pure (no network, no filesystem, no session) so a broken deployment is never
17
+ * reported as a healthy one and vice versa.
18
+ *
19
+ * @param _ctx - host context (unused; the probe touches process globals only).
20
+ * @param fail - invariant failure reporter.
21
+ */
22
+ export const install = async (_ctx, fail) => {
23
+ try {
24
+ if (typeof globalThis.URL !== 'function')
25
+ fail('reader requires a runtime with URL');
26
+ if (typeof globalThis.setTimeout !== 'function')
27
+ fail('reader requires a runtime with timers');
28
+ }
29
+ catch {
30
+ fail('reader requires a standard node runtime');
31
+ }
32
+ };
33
+ /**
34
+ * Register this package's invariant companion.
35
+ * @param ctx - Cordis context carrying the invariant service.
36
+ * @returns the installed registration's disposer after setup succeeds.
37
+ */
38
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
@@ -0,0 +1,107 @@
1
+ import type { ReaderPreviewFailureCode } from './types.ts';
2
+ /** What a resolver makes of a link it owns. */
3
+ export type LinkResolution = {
4
+ readonly kind: 'arxiv';
5
+ /** The paper's arXiv id, version included, as the API reported it. */
6
+ readonly id: string;
7
+ /** The paper's title on arXiv — the saved card's default label. */
8
+ readonly title: string;
9
+ } | {
10
+ readonly kind: 'link-only';
11
+ readonly code: ReaderPreviewFailureCode;
12
+ /** The classifier's own words, kept for diagnosis (the locale sentence rides the code). */
13
+ readonly message: string;
14
+ };
15
+ /**
16
+ * The network face a resolver may use: one GET, the status and the decoded
17
+ * body. Injected — in production it is the host's sanctioned `ctx.web` seam;
18
+ * in tests a script.
19
+ */
20
+ export type ResolverFetch = (url: string) => Promise<{
21
+ readonly statusCode: number;
22
+ readonly body: string;
23
+ }>;
24
+ /** One row of the resolver table. */
25
+ export interface LinkResolver {
26
+ /** The source's name, for logs and tests. */
27
+ readonly name: string;
28
+ /** Whether this URL is the source's. Cheap and total — no network. */
29
+ readonly match: (url: URL) => boolean;
30
+ /** Resolve a matched URL, or return null to leave it to the ordinary path. */
31
+ readonly resolve: (url: URL, fetch: ResolverFetch) => Promise<LinkResolution | null>;
32
+ }
33
+ /**
34
+ * The gate a title-search candidate must pass. Normalized edit similarity ≥ the
35
+ * floor, PLUS one shared author family name when both sides name authors;
36
+ * authorless comparisons rise to the strict bar.
37
+ */
38
+ export declare const TITLE_SIMILARITY_MIN = 0.85;
39
+ export declare const TITLE_SIMILARITY_STRICT = 0.95;
40
+ /** One arXiv search hit, parsed. */
41
+ export interface ArxivSearchHit {
42
+ readonly id: string;
43
+ readonly title: string;
44
+ readonly authors: readonly string[];
45
+ }
46
+ /**
47
+ * Lowercase, accent-folded, punctuation-to-space — the form two titles are
48
+ * compared in. CJK characters are letters for this purpose (a Japanese paper
49
+ * title must not collapse to nothing).
50
+ */
51
+ export declare function normalizeTitle(title: string): string;
52
+ /** Levenshtein distance, two rows, on ALREADY-normalized titles. */
53
+ export declare function editDistance(a: string, b: string): number;
54
+ /**
55
+ * How alike two titles are: 1 for identical after normalization, 0 for nothing
56
+ * in common. Either side empty is a 0 — there is nothing to compare.
57
+ */
58
+ export declare function titleSimilarity(a: string, b: string): number;
59
+ /**
60
+ * The family-name set of an author list, accent-folded and lowercased.
61
+ *
62
+ * Full names ("Nicholas G. Polson", the arXiv spelling) reduce to their last
63
+ * token; structured records (Crossref's `family`) arrive already reduced.
64
+ */
65
+ export declare function familyNames(authors: readonly string[]): Set<string>;
66
+ /**
67
+ * Whether one arXiv hit may stand in for the paper a DOI names.
68
+ *
69
+ * The author factor is what makes "no match → degrade" honest: short generic
70
+ * titles collide across DIFFERENT papers (measured: Crossref's "Deep learning"
71
+ * — LeCun, Bengio, Hinton — is a perfect title match for arXiv's "Deep
72
+ * Learning" — Polson, Sokolov), so title similarity alone guesses.
73
+ *
74
+ * @param candidate - the arXiv hit.
75
+ * @param wanted - the Crossref record's title and authors.
76
+ * @returns true when the candidate is the same paper beyond a title accident.
77
+ */
78
+ export declare function gateArxivCandidate(candidate: {
79
+ readonly title: string;
80
+ readonly authors: readonly string[];
81
+ }, wanted: {
82
+ readonly title: string;
83
+ readonly authors: readonly string[];
84
+ }): boolean;
85
+ /** The title phrase the arXiv API is asked for: the title's first words, quote-safe. */
86
+ export declare function titleQuery(title: string): string;
87
+ /**
88
+ * The entries of an arXiv API Atom answer. A deliberately narrow read — the
89
+ * host runtime has no XML parser, and this document's shape is fixed: each
90
+ * `<entry>` carries its `<id>` (the abs URL) and `<title>` first.
91
+ */
92
+ export declare function parseArxivSearch(xml: string): ArxivSearchHit[];
93
+ /** The resolver table, in match order. */
94
+ export declare const LINK_RESOLVERS: readonly LinkResolver[];
95
+ /**
96
+ * Resolve a pasted link through the table.
97
+ *
98
+ * `null` — not an answer, but the ABSENCE of one — sends the URL down the
99
+ * ordinary fetch-and-classify path. A resolver's own failure is caught by the
100
+ * caller (the service): a DOI lookup that throws must not break an add.
101
+ *
102
+ * @param url - the normalized pasted URL.
103
+ * @param fetch - the injected GET face.
104
+ * @returns the resolution, or `null`.
105
+ */
106
+ export declare function resolveLink(url: string, fetch: ResolverFetch): Promise<LinkResolution | null>;
107
+ //# sourceMappingURL=link-resolvers.d.ts.map
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Paper-link resolvers: turn a link that NAMES a paper into the paper itself.
3
+ *
4
+ * A DOI or an OpenReview URL is a reference, not a readable page: doi.org
5
+ * answers a cross-origin redirect the host seam refuses by design, and
6
+ * openreview.net cannot be read server-side at all (measured 2026-09-20: the
7
+ * API sits behind a Cloudflare challenge, the forum is a JS SPA whose static
8
+ * title is always "Forum | OpenReview"). For such links the add flow asks this
9
+ * module BEFORE fetching anything.
10
+ *
11
+ * Table-driven: one resolver per source, each answering an arXiv candidate, an
12
+ * honest link-only verdict, or `null` ("not mine — take the ordinary path").
13
+ * New sources (dblp, Semantic Scholar) are new rows, not new plumbing. The
14
+ * network face is injected, so every branch is testable without a socket.
15
+ *
16
+ * The one rule the module exists to enforce: **a resolution is proven or
17
+ * absent, never guessed.** A candidate that fails the gate degrades to the
18
+ * ordinary path — a wrong paper filed under the reader's link is worse than a
19
+ * card that says "open it in a browser".
20
+ *
21
+ * @module @khorsheed/dsh-reader/link-resolvers
22
+ */
23
+ import { isArxivId } from "./arxiv.js";
24
+ /* ------------------------------------------------------------------ the DOI resolver */
25
+ /** The DOI resolver covers the doi.org alias hosts; publisher URLs are out of scope. */
26
+ const DOI_HOSTS = new Set(['doi.org', 'www.doi.org', 'dx.doi.org']);
27
+ /** A DOI is `10.<registrant>/<suffix>`; the registrant is 4–9 digits. */
28
+ const DOI_PATH = /^\/(10\.\d{4,9}\/\S+)/;
29
+ /** Crossref's filtered list route — see the resolver's doc comment on why not the work route. */
30
+ const CROSSREF_SELECT = 'DOI,title,author';
31
+ /** The arXiv API phrase search silently answers nothing beyond six terms (measured). */
32
+ const TITLE_QUERY_WORDS = 6;
33
+ /** How many candidates one title search is read for. */
34
+ const ARXIV_SEARCH_RESULTS = 5;
35
+ /**
36
+ * The gate a title-search candidate must pass. Normalized edit similarity ≥ the
37
+ * floor, PLUS one shared author family name when both sides name authors;
38
+ * authorless comparisons rise to the strict bar.
39
+ */
40
+ export const TITLE_SIMILARITY_MIN = 0.85;
41
+ export const TITLE_SIMILARITY_STRICT = 0.95;
42
+ /**
43
+ * Lowercase, accent-folded, punctuation-to-space — the form two titles are
44
+ * compared in. CJK characters are letters for this purpose (a Japanese paper
45
+ * title must not collapse to nothing).
46
+ */
47
+ export function normalizeTitle(title) {
48
+ return title
49
+ .normalize('NFKD')
50
+ .replace(/\p{M}/gu, '')
51
+ .toLowerCase()
52
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
53
+ .trim();
54
+ }
55
+ /** Levenshtein distance, two rows, on ALREADY-normalized titles. */
56
+ export function editDistance(a, b) {
57
+ if (a === b)
58
+ return 0;
59
+ if (a.length === 0)
60
+ return b.length;
61
+ if (b.length === 0)
62
+ return a.length;
63
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
64
+ for (let i = 1; i <= a.length; i++) {
65
+ const current = [i];
66
+ for (let j = 1; j <= b.length; j++) {
67
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
68
+ }
69
+ previous = current;
70
+ }
71
+ return previous[b.length];
72
+ }
73
+ /**
74
+ * How alike two titles are: 1 for identical after normalization, 0 for nothing
75
+ * in common. Either side empty is a 0 — there is nothing to compare.
76
+ */
77
+ export function titleSimilarity(a, b) {
78
+ const left = normalizeTitle(a);
79
+ const right = normalizeTitle(b);
80
+ if (left.length === 0 || right.length === 0)
81
+ return 0;
82
+ if (left === right)
83
+ return 1;
84
+ return 1 - editDistance(left, right) / Math.max(left.length, right.length);
85
+ }
86
+ /**
87
+ * The family-name set of an author list, accent-folded and lowercased.
88
+ *
89
+ * Full names ("Nicholas G. Polson", the arXiv spelling) reduce to their last
90
+ * token; structured records (Crossref's `family`) arrive already reduced.
91
+ */
92
+ export function familyNames(authors) {
93
+ const names = new Set();
94
+ for (const author of authors) {
95
+ const last = normalizeTitle(author).split(' ').filter(part => part.length > 0).at(-1);
96
+ if (last !== undefined)
97
+ names.add(last);
98
+ }
99
+ return names;
100
+ }
101
+ /**
102
+ * Whether one arXiv hit may stand in for the paper a DOI names.
103
+ *
104
+ * The author factor is what makes "no match → degrade" honest: short generic
105
+ * titles collide across DIFFERENT papers (measured: Crossref's "Deep learning"
106
+ * — LeCun, Bengio, Hinton — is a perfect title match for arXiv's "Deep
107
+ * Learning" — Polson, Sokolov), so title similarity alone guesses.
108
+ *
109
+ * @param candidate - the arXiv hit.
110
+ * @param wanted - the Crossref record's title and authors.
111
+ * @returns true when the candidate is the same paper beyond a title accident.
112
+ */
113
+ export function gateArxivCandidate(candidate, wanted) {
114
+ const similarity = titleSimilarity(candidate.title, wanted.title);
115
+ const candidateNames = familyNames(candidate.authors);
116
+ const wantedNames = familyNames(wanted.authors);
117
+ if (candidateNames.size === 0 || wantedNames.size === 0)
118
+ return similarity >= TITLE_SIMILARITY_STRICT;
119
+ const overlap = [...candidateNames].some(name => wantedNames.has(name));
120
+ return similarity >= TITLE_SIMILARITY_MIN && overlap;
121
+ }
122
+ /** The title phrase the arXiv API is asked for: the title's first words, quote-safe. */
123
+ export function titleQuery(title) {
124
+ const words = title.replace(/\s+/g, ' ').trim().split(' ');
125
+ return words.slice(0, TITLE_QUERY_WORDS).join(' ').replace(/"+/g, ' ').replace(/\s+/g, ' ').trim();
126
+ }
127
+ /**
128
+ * The entries of an arXiv API Atom answer. A deliberately narrow read — the
129
+ * host runtime has no XML parser, and this document's shape is fixed: each
130
+ * `<entry>` carries its `<id>` (the abs URL) and `<title>` first.
131
+ */
132
+ export function parseArxivSearch(xml) {
133
+ const hits = [];
134
+ for (const block of xml.split('<entry>').slice(1)) {
135
+ const id = /<id>https?:\/\/arxiv\.org\/abs\/([^<]+)<\/id>/.exec(block)?.[1]?.trim();
136
+ const rawTitle = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1];
137
+ if (id === undefined || rawTitle === undefined || !isArxivId(id))
138
+ continue;
139
+ hits.push({
140
+ id,
141
+ title: unescapeXml(rawTitle).replace(/\s+/g, ' ').trim(),
142
+ authors: [...block.matchAll(/<name>([^<]+)<\/name>/g)].map(match => unescapeXml(match[1]).trim()),
143
+ });
144
+ }
145
+ return hits;
146
+ }
147
+ /** The few entities an API title can carry. */
148
+ function unescapeXml(value) {
149
+ return value
150
+ .replace(/&lt;/g, '<')
151
+ .replace(/&gt;/g, '>')
152
+ .replace(/&quot;/g, '"')
153
+ .replace(/&apos;/g, "'")
154
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
155
+ .replace(/&amp;/g, '&');
156
+ }
157
+ /**
158
+ * Ask the arXiv API for papers titled like `title`.
159
+ *
160
+ * The phrase search accepts at most {@link TITLE_QUERY_WORDS} terms (measured:
161
+ * a 7-word phrase answers an empty feed where its 6-word prefix finds the
162
+ * paper), so the query is the title's prefix, and precision is the gate's job,
163
+ * not the query's.
164
+ */
165
+ async function searchArxiv(title, fetch) {
166
+ const phrase = titleQuery(title);
167
+ if (phrase.length === 0)
168
+ return [];
169
+ const url = `https://export.arxiv.org/api/query?${new URLSearchParams({
170
+ search_query: `ti:"${phrase}"`,
171
+ max_results: String(ARXIV_SEARCH_RESULTS),
172
+ }).toString()}`;
173
+ let body;
174
+ try {
175
+ const response = await fetch(url);
176
+ if (response.statusCode !== 200)
177
+ return [];
178
+ body = response.body;
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ return parseArxivSearch(body);
184
+ }
185
+ /**
186
+ * The title and authors Crossref has for one DOI, or `null`.
187
+ *
188
+ * The LIST route with a `doi:` filter is used, not `/works/<doi>`: the
189
+ * single-work route rejects `select` (measured: `parameter-not-allowed`), and
190
+ * an unfiltered work record can carry hundreds of references — past the fetch
191
+ * seam's body cap, which truncates JSON into something unparseable. The
192
+ * filtered route answers a few hundred bytes. A DOI Crossref does not know
193
+ * (404, empty items) is not an error here: it is the ordinary path's business.
194
+ */
195
+ async function crossrefWork(doi, fetch) {
196
+ const url = `https://api.crossref.org/works?${new URLSearchParams({
197
+ filter: `doi:${doi}`,
198
+ select: CROSSREF_SELECT,
199
+ }).toString()}`;
200
+ let parsed;
201
+ try {
202
+ const response = await fetch(url);
203
+ if (response.statusCode !== 200)
204
+ return null;
205
+ parsed = JSON.parse(response.body);
206
+ }
207
+ catch {
208
+ // A truncated or malformed record is "no answer", never a crash.
209
+ return null;
210
+ }
211
+ const items = parsed?.message?.items;
212
+ const work = Array.isArray(items) ? items[0] : undefined;
213
+ if (typeof work !== 'object' || work === null)
214
+ return null;
215
+ const record = work;
216
+ const title = Array.isArray(record.title) ? record.title.find(item => typeof item === 'string' && item.length > 0) : undefined;
217
+ if (typeof title !== 'string')
218
+ return null;
219
+ const authors = Array.isArray(record.author)
220
+ ? record.author.flatMap((author) => {
221
+ const family = author?.family;
222
+ return typeof family === 'string' && family.length > 0 ? [family] : [];
223
+ })
224
+ : [];
225
+ return { title, authors };
226
+ }
227
+ /** doi.org/<doi> → Crossref → gated arXiv candidate. */
228
+ const doiResolver = {
229
+ name: 'doi',
230
+ match: url => DOI_HOSTS.has(url.hostname.toLowerCase()) && DOI_PATH.test(url.pathname),
231
+ resolve: async (url, fetch) => {
232
+ const doi = decodeURIComponent(DOI_PATH.exec(url.pathname)?.[1] ?? '');
233
+ if (doi.length === 0)
234
+ return null;
235
+ const work = await crossrefWork(doi, fetch);
236
+ if (work === null)
237
+ return null;
238
+ const hits = await searchArxiv(work.title, fetch);
239
+ let best;
240
+ for (const hit of hits) {
241
+ const similarity = titleSimilarity(hit.title, work.title);
242
+ if (best === undefined || similarity > best.similarity)
243
+ best = { hit, similarity };
244
+ }
245
+ if (best === undefined || !gateArxivCandidate(best.hit, work))
246
+ return null;
247
+ return { kind: 'arxiv', id: best.hit.id, title: best.hit.title };
248
+ },
249
+ };
250
+ /* ----------------------------------------------------------- the OpenReview resolver */
251
+ /**
252
+ * OpenReview cannot be read server-side, and that is KNOWN without a request:
253
+ * the forum is a JS SPA behind an anti-bot wall (measured 2026-09-20: the API
254
+ * 403s with a Cloudflare challenge; the forum's static HTML always titles
255
+ * itself "Forum | OpenReview"). The resolver answers the honest card instead
256
+ * of spending a fetch to learn it again. A future capture backend (the ingest
257
+ * proposal's M1) is where a rendered read of the forum belongs.
258
+ */
259
+ const openReviewResolver = {
260
+ name: 'openreview',
261
+ match: url => {
262
+ const host = url.hostname.toLowerCase();
263
+ if (host !== 'openreview.net' && host !== 'www.openreview.net')
264
+ return false;
265
+ return (url.pathname === '/forum' || url.pathname === '/pdf') && (url.searchParams.get('id') ?? '').length > 0;
266
+ },
267
+ resolve: () => Promise.resolve({
268
+ kind: 'link-only',
269
+ code: 'unreadable',
270
+ message: 'OpenReview pages are script-rendered behind an anti-bot wall, so the server side cannot read them — if the paper has an arXiv version, paste that link instead',
271
+ }),
272
+ };
273
+ /** The resolver table, in match order. */
274
+ export const LINK_RESOLVERS = [doiResolver, openReviewResolver];
275
+ /**
276
+ * Resolve a pasted link through the table.
277
+ *
278
+ * `null` — not an answer, but the ABSENCE of one — sends the URL down the
279
+ * ordinary fetch-and-classify path. A resolver's own failure is caught by the
280
+ * caller (the service): a DOI lookup that throws must not break an add.
281
+ *
282
+ * @param url - the normalized pasted URL.
283
+ * @param fetch - the injected GET face.
284
+ * @returns the resolution, or `null`.
285
+ */
286
+ export async function resolveLink(url, fetch) {
287
+ let parsed;
288
+ try {
289
+ parsed = new URL(url);
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ for (const resolver of LINK_RESOLVERS) {
295
+ if (resolver.match(parsed))
296
+ return resolver.resolve(parsed, fetch);
297
+ }
298
+ return null;
299
+ }