mcp-libraryofcongress 1.0.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/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1695 -0
- package/dist/index.js.map +1 -0
- package/dist/loc/client.d.ts +312 -0
- package/dist/loc/client.js +987 -0
- package/dist/loc/client.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var LocError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
constructor(code, message, details = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "LocError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.details = details;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var notFound = (message, details) => new LocError("not_found", message, details);
|
|
13
|
+
var invalidInput = (message, hint) => new LocError("invalid_input", message, hint ? { hint } : {});
|
|
14
|
+
var rateLimited = (message, details) => new LocError("rate_limited", message, {
|
|
15
|
+
hint: "Wait a moment and ask again. This says nothing about whether the Library holds what you asked for.",
|
|
16
|
+
...details
|
|
17
|
+
});
|
|
18
|
+
var parseFailure = (message, details) => new LocError("parse_failure", message, {
|
|
19
|
+
hint: "The Library may have changed how it answers. Please report this at https://github.com/smeet666/mcp-libraryofcongress/issues with the arguments you used.",
|
|
20
|
+
...details
|
|
21
|
+
});
|
|
22
|
+
var networkError = (message, details) => new LocError("network_error", message, details);
|
|
23
|
+
var timeout = (message, details) => new LocError("timeout", message, details);
|
|
24
|
+
|
|
25
|
+
// src/version.ts
|
|
26
|
+
var PKG_VERSION = "1.0.0";
|
|
27
|
+
var REPO_URL = "https://github.com/smeet666/mcp-libraryofcongress";
|
|
28
|
+
|
|
29
|
+
// src/config.ts
|
|
30
|
+
var LOG_LEVELS = ["silent", "error", "info", "debug"];
|
|
31
|
+
var MIN_ALLOWED_INTERVAL_MS = 3e3;
|
|
32
|
+
var MAX_ALLOWED_INTERVAL_MS = 6e4;
|
|
33
|
+
var DEFAULT_INTERVAL_MS = 6e3;
|
|
34
|
+
var DEFAULT_NEWSPAPER_TIMEOUT_MS = 9e4;
|
|
35
|
+
var DEFAULT_USER_AGENT = `mcp-libraryofcongress/${PKG_VERSION} (+${REPO_URL})`;
|
|
36
|
+
function createLogger(level) {
|
|
37
|
+
const rank = LOG_LEVELS.indexOf(level);
|
|
38
|
+
const write = (at, message) => {
|
|
39
|
+
if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;
|
|
40
|
+
process.stderr.write(`[mcp-libraryofcongress] ${at}: ${message}
|
|
41
|
+
`);
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
debug: (m) => write("debug", m),
|
|
45
|
+
info: (m) => write("info", m),
|
|
46
|
+
// A warning goes out at the error level so it survives the default
|
|
47
|
+
// setting: a caller has to know that rows were dropped.
|
|
48
|
+
warn: (m) => write("error", m),
|
|
49
|
+
error: (m) => write("error", m)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function readInteger(env, name, fallback, min, max) {
|
|
53
|
+
const raw = env[name];
|
|
54
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
55
|
+
const value = Number(raw);
|
|
56
|
+
if (!Number.isFinite(value) || !Number.isInteger(value)) {
|
|
57
|
+
process.stderr.write(
|
|
58
|
+
`[mcp-libraryofcongress] error: ${name}="${raw}" is not a whole number; using ${fallback}.
|
|
59
|
+
`
|
|
60
|
+
);
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
if (value < min || value > max) {
|
|
64
|
+
process.stderr.write(
|
|
65
|
+
`[mcp-libraryofcongress] error: ${name}=${value} is outside ${min}..${max}; using ${fallback}.
|
|
66
|
+
`
|
|
67
|
+
);
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function loadConfig(env = process.env) {
|
|
73
|
+
const level = env.LOC_LOG_LEVEL;
|
|
74
|
+
const logLevel = level && LOG_LEVELS.includes(level) ? level : "error";
|
|
75
|
+
if (level && !LOG_LEVELS.includes(level)) {
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`[mcp-libraryofcongress] error: LOC_LOG_LEVEL="${level}" is not one of ${LOG_LEVELS.join(", ")}; using error.
|
|
78
|
+
`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const custom = env.LOC_USER_AGENT?.trim();
|
|
82
|
+
return {
|
|
83
|
+
// A caller who wants to be recognised may say who they are, and the
|
|
84
|
+
// contact address stays attached: the Library has to be able to reach a
|
|
85
|
+
// human about traffic it did not expect.
|
|
86
|
+
userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,
|
|
87
|
+
minIntervalMs: readInteger(
|
|
88
|
+
env,
|
|
89
|
+
"LOC_MIN_INTERVAL_MS",
|
|
90
|
+
DEFAULT_INTERVAL_MS,
|
|
91
|
+
MIN_ALLOWED_INTERVAL_MS,
|
|
92
|
+
MAX_ALLOWED_INTERVAL_MS
|
|
93
|
+
),
|
|
94
|
+
// A catalogue page carries a row for every match on it.
|
|
95
|
+
timeoutMs: readInteger(env, "LOC_TIMEOUT_MS", 3e4, 1e3, 12e4),
|
|
96
|
+
newspaperTimeoutMs: readInteger(
|
|
97
|
+
env,
|
|
98
|
+
"LOC_NEWSPAPER_TIMEOUT_MS",
|
|
99
|
+
DEFAULT_NEWSPAPER_TIMEOUT_MS,
|
|
100
|
+
1e3,
|
|
101
|
+
3e5
|
|
102
|
+
),
|
|
103
|
+
maxRetries: readInteger(env, "LOC_MAX_RETRIES", 3, 0, 8),
|
|
104
|
+
cacheTtlMs: readInteger(env, "LOC_CACHE_TTL_MS", 9e5, 0, 864e5),
|
|
105
|
+
cacheMaxEntries: readInteger(env, "LOC_CACHE_MAX_ENTRIES", 200, 1, 5e3),
|
|
106
|
+
logLevel
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/loc/cache.ts
|
|
111
|
+
var Cache = class {
|
|
112
|
+
constructor(ttlMs, maxEntries) {
|
|
113
|
+
this.ttlMs = ttlMs;
|
|
114
|
+
this.maxEntries = maxEntries;
|
|
115
|
+
}
|
|
116
|
+
ttlMs;
|
|
117
|
+
maxEntries;
|
|
118
|
+
entries = /* @__PURE__ */ new Map();
|
|
119
|
+
get(key) {
|
|
120
|
+
const entry = this.entries.get(key);
|
|
121
|
+
if (!entry) return void 0;
|
|
122
|
+
if (entry.expiresAt <= Date.now()) {
|
|
123
|
+
this.entries.delete(key);
|
|
124
|
+
return void 0;
|
|
125
|
+
}
|
|
126
|
+
this.entries.delete(key);
|
|
127
|
+
this.entries.set(key, entry);
|
|
128
|
+
return entry.value;
|
|
129
|
+
}
|
|
130
|
+
set(key, value) {
|
|
131
|
+
if (this.ttlMs <= 0) return;
|
|
132
|
+
this.entries.delete(key);
|
|
133
|
+
this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
134
|
+
while (this.entries.size > this.maxEntries) {
|
|
135
|
+
const oldest = this.entries.keys().next();
|
|
136
|
+
if (oldest.done) break;
|
|
137
|
+
this.entries.delete(oldest.value);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
get size() {
|
|
141
|
+
return this.entries.size;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// src/loc/http.ts
|
|
146
|
+
var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
147
|
+
var PUSH_BACK = /* @__PURE__ */ new Set([429, 503]);
|
|
148
|
+
var LONGEST_WAIT_MS = 3e4;
|
|
149
|
+
var RETRIES_AFTER_SILENCE = 1;
|
|
150
|
+
function parseRetryAfter(value, now = Date.now()) {
|
|
151
|
+
if (!value) return null;
|
|
152
|
+
const trimmed = value.trim();
|
|
153
|
+
const seconds = Number(trimmed);
|
|
154
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
|
|
155
|
+
const at = Date.parse(trimmed);
|
|
156
|
+
if (Number.isNaN(at)) return null;
|
|
157
|
+
return Math.max(0, at - now);
|
|
158
|
+
}
|
|
159
|
+
function backoffMs(attempt) {
|
|
160
|
+
const base2 = Math.min(16e3, 1e3 * 2 ** attempt);
|
|
161
|
+
return base2 + Math.floor(Math.random() * 500);
|
|
162
|
+
}
|
|
163
|
+
async function fetchText(options) {
|
|
164
|
+
const { url, userAgent, timeoutMs, maxRetries, limiter, logger } = options;
|
|
165
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
166
|
+
let lastError = null;
|
|
167
|
+
let askedWaitMs = 0;
|
|
168
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
169
|
+
if (askedWaitMs > 0) {
|
|
170
|
+
logger.debug(`waiting ${askedWaitMs}ms, as asked`);
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, askedWaitMs));
|
|
172
|
+
askedWaitMs = 0;
|
|
173
|
+
}
|
|
174
|
+
await limiter.beforeRequest();
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const deadline = setTimeout(() => controller.abort(), timeoutMs);
|
|
177
|
+
try {
|
|
178
|
+
logger.debug(`GET ${url}`);
|
|
179
|
+
const response = await doFetch(url, {
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
redirect: "follow",
|
|
182
|
+
headers: { "user-agent": userAgent, accept: "application/json, text/plain, */*" }
|
|
183
|
+
});
|
|
184
|
+
if (response.ok) {
|
|
185
|
+
limiter.succeeded();
|
|
186
|
+
return await response.text();
|
|
187
|
+
}
|
|
188
|
+
if (PUSH_BACK.has(response.status)) {
|
|
189
|
+
limiter.pushBack();
|
|
190
|
+
await response.body?.cancel().catch(() => void 0);
|
|
191
|
+
const asked = parseRetryAfter(response.headers.get("retry-after"));
|
|
192
|
+
if (asked !== null && asked > LONGEST_WAIT_MS) {
|
|
193
|
+
throw rateLimited(
|
|
194
|
+
`The Library of Congress asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
|
|
195
|
+
{ url, status: response.status }
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (attempt >= maxRetries) {
|
|
199
|
+
throw rateLimited(
|
|
200
|
+
`The Library of Congress asked this client to slow down (HTTP ${response.status}).`,
|
|
201
|
+
{ url, status: response.status }
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
askedWaitMs = asked ?? backoffMs(attempt);
|
|
205
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (RETRYABLE.has(response.status) && attempt < maxRetries) {
|
|
209
|
+
await response.body?.cancel().catch(() => void 0);
|
|
210
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
211
|
+
askedWaitMs = backoffMs(attempt);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (response.status === 400 || response.status === 422) {
|
|
215
|
+
throw invalidInput(
|
|
216
|
+
"The Library of Congress would not accept this request.",
|
|
217
|
+
"Check the query. A quotation mark or bracket left unbalanced is read as syntax."
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (response.status === 404 || response.status === 410) {
|
|
221
|
+
throw notFound("The Library of Congress holds nothing at this address.", {
|
|
222
|
+
url,
|
|
223
|
+
status: response.status
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
throw networkError(`The Library of Congress answered HTTP ${response.status}.`, {
|
|
227
|
+
url,
|
|
228
|
+
status: response.status
|
|
229
|
+
});
|
|
230
|
+
} catch (error) {
|
|
231
|
+
clearTimeout(deadline);
|
|
232
|
+
if (error instanceof Error && error.name === "LocError") throw error;
|
|
233
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
234
|
+
lastError = error;
|
|
235
|
+
if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
|
|
236
|
+
throw timeout(
|
|
237
|
+
`No answer from the Library of Congress within ${timeoutMs}ms. The newspaper corpus in particular can take tens of seconds.`,
|
|
238
|
+
{ url }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
askedWaitMs = backoffMs(attempt);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
245
|
+
if (attempt >= maxRetries) {
|
|
246
|
+
throw networkError(`Could not reach the Library of Congress: ${lastError.message}`, {
|
|
247
|
+
url
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
askedWaitMs = backoffMs(attempt);
|
|
251
|
+
continue;
|
|
252
|
+
} finally {
|
|
253
|
+
clearTimeout(deadline);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
throw networkError(
|
|
257
|
+
`Could not reach the Library of Congress: ${lastError?.message ?? "no attempt was made"}`,
|
|
258
|
+
{ url }
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
async function fetchJson(options) {
|
|
262
|
+
const body = await fetchText(options);
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(body);
|
|
265
|
+
} catch {
|
|
266
|
+
throw parseFailure("The Library of Congress answered with something that is not JSON.", {
|
|
267
|
+
url: options.url
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/loc/paths.ts
|
|
273
|
+
var HOST = "https://www.loc.gov";
|
|
274
|
+
var FORMAT_ROUTE = {
|
|
275
|
+
books: "/books/",
|
|
276
|
+
photos: "/photos/",
|
|
277
|
+
maps: "/maps/",
|
|
278
|
+
audio: "/audio/",
|
|
279
|
+
"film-and-videos": "/film-and-videos/",
|
|
280
|
+
manuscripts: "/manuscripts/",
|
|
281
|
+
"notated-music": "/notated-music/",
|
|
282
|
+
newspapers: "/newspapers/"
|
|
283
|
+
};
|
|
284
|
+
var FORMAT_ROUTES = Object.keys(FORMAT_ROUTE);
|
|
285
|
+
var NEWSPAPER_PAGES_ROUTE = "/collections/chronicling-america/";
|
|
286
|
+
var COLLECTIONS_ROUTE = "/collections/";
|
|
287
|
+
var ITEM_ROUTE = "/item/";
|
|
288
|
+
var PARAM = {
|
|
289
|
+
/** Asks for JSON rather than a web page. */
|
|
290
|
+
format: "fo",
|
|
291
|
+
/** The words to look for. */
|
|
292
|
+
query: "q",
|
|
293
|
+
/** Results per page. */
|
|
294
|
+
perPage: "c",
|
|
295
|
+
/** Which page, from 1. */
|
|
296
|
+
page: "sp",
|
|
297
|
+
/** A year range, written from/to. */
|
|
298
|
+
dates: "dates",
|
|
299
|
+
/** Facet filters, several of them separated by a vertical bar. */
|
|
300
|
+
facets: "fa",
|
|
301
|
+
/** Sort order. */
|
|
302
|
+
sort: "sb",
|
|
303
|
+
/** Which top-level blocks to return, which is what keeps a page small. */
|
|
304
|
+
attributes: "at",
|
|
305
|
+
/** Widens a search past what the Library has digitised. */
|
|
306
|
+
all: "all"
|
|
307
|
+
};
|
|
308
|
+
var WANTED_BLOCKS = "results,pagination";
|
|
309
|
+
var FACET_FIELD = {
|
|
310
|
+
subject: "subject",
|
|
311
|
+
location: "location",
|
|
312
|
+
language: "language",
|
|
313
|
+
contributor: "contributor",
|
|
314
|
+
partof: "partof",
|
|
315
|
+
/** The state a newspaper was published in, as the newspaper corpus files it. */
|
|
316
|
+
state: "location_state",
|
|
317
|
+
/** A newspaper, named with the town and the years it ran. */
|
|
318
|
+
publication: "partof_title"
|
|
319
|
+
};
|
|
320
|
+
var SORT = {
|
|
321
|
+
relevance: "",
|
|
322
|
+
newest: "date_desc",
|
|
323
|
+
oldest: "date",
|
|
324
|
+
title: "title_s"
|
|
325
|
+
};
|
|
326
|
+
var ROW_FIELD = {
|
|
327
|
+
id: "id",
|
|
328
|
+
url: "url",
|
|
329
|
+
title: "title",
|
|
330
|
+
date: "date",
|
|
331
|
+
contributor: "contributor",
|
|
332
|
+
originalFormat: "original_format",
|
|
333
|
+
onlineFormat: "online_format",
|
|
334
|
+
location: "location",
|
|
335
|
+
subject: "subject",
|
|
336
|
+
description: "description",
|
|
337
|
+
partOf: "partof",
|
|
338
|
+
digitized: "digitized",
|
|
339
|
+
/** Newspaper pages only, from here down. */
|
|
340
|
+
pageNumber: "number_page",
|
|
341
|
+
publicationTitle: "partof_title",
|
|
342
|
+
state: "location_state",
|
|
343
|
+
/** How many records a collection gathers. */
|
|
344
|
+
count: "count",
|
|
345
|
+
/** Lists what a collection holds. */
|
|
346
|
+
items: "items"
|
|
347
|
+
};
|
|
348
|
+
var ITEM_FIELD = {
|
|
349
|
+
id: "id",
|
|
350
|
+
url: "url",
|
|
351
|
+
title: "title",
|
|
352
|
+
date: "date",
|
|
353
|
+
contributorNames: "contributor_names",
|
|
354
|
+
description: "description",
|
|
355
|
+
notes: "notes",
|
|
356
|
+
subjects: "subjects",
|
|
357
|
+
location: "location",
|
|
358
|
+
language: "language",
|
|
359
|
+
partOf: "partof",
|
|
360
|
+
repository: "repository",
|
|
361
|
+
callNumber: "call_number",
|
|
362
|
+
rights: "rights_advisory",
|
|
363
|
+
rightsFallback: "rights_information",
|
|
364
|
+
originalFormat: "original_format"
|
|
365
|
+
};
|
|
366
|
+
var MOST_SUBJECTS = 12;
|
|
367
|
+
var itemUrl = (identifier) => `${HOST}${ITEM_ROUTE}${identifier.split("/").map(encodeURIComponent).join("/")}/`;
|
|
368
|
+
|
|
369
|
+
// src/loc/urls.ts
|
|
370
|
+
var YEAR_BOUND = { earliest: 1e3, latest: 9999 };
|
|
371
|
+
function base(path) {
|
|
372
|
+
const url = new URL(HOST + path);
|
|
373
|
+
url.searchParams.set(PARAM.format, "json");
|
|
374
|
+
url.searchParams.set(PARAM.attributes, WANTED_BLOCKS);
|
|
375
|
+
return url;
|
|
376
|
+
}
|
|
377
|
+
function facetString(facets) {
|
|
378
|
+
return Object.entries(facets).filter(([, value]) => typeof value === "string" && value.trim() !== "").map(([field, value]) => `${FACET_FIELD[field]}:${value.trim().toLowerCase()}`).join("|");
|
|
379
|
+
}
|
|
380
|
+
function catalogueUrl(q) {
|
|
381
|
+
const url = base(FORMAT_ROUTE[q.format]);
|
|
382
|
+
url.searchParams.set(PARAM.query, q.query);
|
|
383
|
+
url.searchParams.set(PARAM.perPage, String(q.limit));
|
|
384
|
+
url.searchParams.set(PARAM.page, String(q.page));
|
|
385
|
+
if (q.yearFrom !== void 0 || q.yearTo !== void 0) {
|
|
386
|
+
const from = q.yearFrom ?? YEAR_BOUND.earliest;
|
|
387
|
+
const to = q.yearTo ?? YEAR_BOUND.latest;
|
|
388
|
+
url.searchParams.set(PARAM.dates, `${from}/${to}`);
|
|
389
|
+
}
|
|
390
|
+
const facets = facetString(q.facets ?? {});
|
|
391
|
+
if (facets !== "") url.searchParams.set(PARAM.facets, facets);
|
|
392
|
+
const sort = q.sort ? SORT[q.sort] : "";
|
|
393
|
+
if (sort !== "") url.searchParams.set(PARAM.sort, sort);
|
|
394
|
+
if (!q.onlineOnly) url.searchParams.set(PARAM.all, "true");
|
|
395
|
+
return url.toString();
|
|
396
|
+
}
|
|
397
|
+
function newspaperPagesUrl(query, limit, page, filters = {}) {
|
|
398
|
+
const url = base(NEWSPAPER_PAGES_ROUTE);
|
|
399
|
+
url.searchParams.set(PARAM.query, query);
|
|
400
|
+
url.searchParams.set(PARAM.perPage, String(limit));
|
|
401
|
+
url.searchParams.set(PARAM.page, String(page));
|
|
402
|
+
if (filters.yearFrom !== void 0 || filters.yearTo !== void 0) {
|
|
403
|
+
const from = filters.yearFrom ?? YEAR_BOUND.earliest;
|
|
404
|
+
const to = filters.yearTo ?? YEAR_BOUND.latest;
|
|
405
|
+
url.searchParams.set(PARAM.dates, `${from}/${to}`);
|
|
406
|
+
}
|
|
407
|
+
const facets = facetString(filters.facets ?? {});
|
|
408
|
+
if (facets !== "") url.searchParams.set(PARAM.facets, facets);
|
|
409
|
+
return url.toString();
|
|
410
|
+
}
|
|
411
|
+
function collectionsUrl(limit, page) {
|
|
412
|
+
const url = base(COLLECTIONS_ROUTE);
|
|
413
|
+
url.searchParams.set(PARAM.perPage, String(limit));
|
|
414
|
+
url.searchParams.set(PARAM.page, String(page));
|
|
415
|
+
return url.toString();
|
|
416
|
+
}
|
|
417
|
+
function itemDocumentUrl(identifier) {
|
|
418
|
+
const segments = identifier.trim().replace(/^\/+|\/+$/g, "").split("/").filter((segment) => segment !== "");
|
|
419
|
+
if (segments.some((segment) => /^\.+$/.test(segment))) {
|
|
420
|
+
throw invalidInput(
|
|
421
|
+
`"${identifier}" is not an identifier: it carries a relative path segment.`,
|
|
422
|
+
"Take the identifier from a search result rather than building one."
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const path = segments.map(encodeURIComponent).join("/");
|
|
426
|
+
const url = new URL(`${HOST}${ITEM_ROUTE}${path}/`);
|
|
427
|
+
url.searchParams.set(PARAM.format, "json");
|
|
428
|
+
return url.toString();
|
|
429
|
+
}
|
|
430
|
+
function identifierFrom(address) {
|
|
431
|
+
if (!address) return null;
|
|
432
|
+
const trimmed = address.trim();
|
|
433
|
+
if (trimmed === "") return null;
|
|
434
|
+
const absolute = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
|
|
435
|
+
let parsed;
|
|
436
|
+
try {
|
|
437
|
+
parsed = new URL(absolute);
|
|
438
|
+
} catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
const path = parsed.pathname.replace(/^\/+|\/+$/g, "");
|
|
442
|
+
if (path === "") return null;
|
|
443
|
+
const segments = path.split("/");
|
|
444
|
+
if (parsed.hostname === "lccn.loc.gov") return segments[0] ?? null;
|
|
445
|
+
const first = segments[0];
|
|
446
|
+
if (first === "item" || first === "resource") {
|
|
447
|
+
const rest = segments.slice(1);
|
|
448
|
+
return rest.length > 0 ? rest.join("/") : null;
|
|
449
|
+
}
|
|
450
|
+
if (first === "collections") return segments[1] ?? null;
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/loc/parse.ts
|
|
455
|
+
var asObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
456
|
+
function asStrings(value) {
|
|
457
|
+
if (typeof value === "string") return value.trim() === "" ? [] : [value];
|
|
458
|
+
if (Array.isArray(value))
|
|
459
|
+
return value.filter((v) => typeof v === "string" && v.trim() !== "");
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
var asString = (value) => asStrings(value)[0] ?? null;
|
|
463
|
+
function asLabels(value) {
|
|
464
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
465
|
+
const labels = [];
|
|
466
|
+
for (const entry of entries) {
|
|
467
|
+
if (typeof entry === "string") {
|
|
468
|
+
if (entry.trim() !== "") labels.push(entry);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const object = asObject(entry);
|
|
472
|
+
if (!object) continue;
|
|
473
|
+
const titled = asString(object.title);
|
|
474
|
+
if (titled !== null) {
|
|
475
|
+
labels.push(titled);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
for (const key of Object.keys(object)) {
|
|
479
|
+
if (key.trim() !== "") labels.push(key);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return labels;
|
|
483
|
+
}
|
|
484
|
+
function asNumber(value) {
|
|
485
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
486
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
487
|
+
const n = Number(value);
|
|
488
|
+
if (Number.isFinite(n)) return n;
|
|
489
|
+
}
|
|
490
|
+
if (Array.isArray(value)) return asNumber(value[0]);
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
function asPageNumber(value) {
|
|
494
|
+
const text = asString(value);
|
|
495
|
+
if (text === null) return null;
|
|
496
|
+
const digits = text.trim();
|
|
497
|
+
if (!/^\d+$/.test(digits)) return null;
|
|
498
|
+
const n = Number(digits);
|
|
499
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
500
|
+
}
|
|
501
|
+
var PLAUSIBLE_YEAR = { first: 1e3, last: 2200 };
|
|
502
|
+
function asYear(value) {
|
|
503
|
+
const plausible = (year) => Number.isFinite(year) && year >= PLAUSIBLE_YEAR.first && year <= PLAUSIBLE_YEAR.last ? Math.trunc(year) : null;
|
|
504
|
+
const text = asString(value);
|
|
505
|
+
if (text !== null) {
|
|
506
|
+
const m = /(\d{4})/.exec(text);
|
|
507
|
+
if (m) return plausible(Number(m[1]));
|
|
508
|
+
}
|
|
509
|
+
const direct = asNumber(value);
|
|
510
|
+
return direct === null ? null : plausible(direct);
|
|
511
|
+
}
|
|
512
|
+
function plainText(value) {
|
|
513
|
+
return value.replace(/<[^>]*>/g, "").replace(/&/g, "&").replace(/"/g, '"').replace(/�?39;|'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
514
|
+
}
|
|
515
|
+
function toPaging(payload, url) {
|
|
516
|
+
const root = asObject(payload);
|
|
517
|
+
const pagination = root ? asObject(root.pagination) : null;
|
|
518
|
+
if (!pagination) {
|
|
519
|
+
throw parseFailure(
|
|
520
|
+
"The answer carried no pagination block, so nothing states how many results match.",
|
|
521
|
+
{
|
|
522
|
+
url
|
|
523
|
+
}
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const resultCount = asNumber(pagination.of);
|
|
527
|
+
if (resultCount === null) {
|
|
528
|
+
throw parseFailure("The answer carried no readable count of results.", { url });
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
resultCount,
|
|
532
|
+
pageCount: asNumber(pagination.total),
|
|
533
|
+
currentPage: asNumber(pagination.current),
|
|
534
|
+
perPage: asNumber(pagination.perpage)
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
function rowsOf(payload, url) {
|
|
538
|
+
const root = asObject(payload);
|
|
539
|
+
if (!root || !Array.isArray(root.results)) {
|
|
540
|
+
throw parseFailure("The answer carried its rows in a shape this server cannot read.", { url });
|
|
541
|
+
}
|
|
542
|
+
return root.results.map(asObject).filter((r) => r !== null);
|
|
543
|
+
}
|
|
544
|
+
var creatorOf = (row) => asStrings(row[ROW_FIELD.contributor]).map(plainText).join(", ") || null;
|
|
545
|
+
function sourceUrlOf(row, identifier) {
|
|
546
|
+
const raw = asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]);
|
|
547
|
+
if (raw) {
|
|
548
|
+
const trimmed = raw.trim();
|
|
549
|
+
if (trimmed.startsWith("//")) return `https:${trimmed}`;
|
|
550
|
+
if (trimmed.startsWith("http://")) return `https://${trimmed.slice("http://".length)}`;
|
|
551
|
+
if (trimmed.startsWith("https://")) return trimmed;
|
|
552
|
+
}
|
|
553
|
+
return identifier === null ? "" : itemUrl(identifier);
|
|
554
|
+
}
|
|
555
|
+
function toSearchResults(payload, url, onSkip) {
|
|
556
|
+
const rows = rowsOf(payload, url);
|
|
557
|
+
const records = [];
|
|
558
|
+
let skipped = 0;
|
|
559
|
+
for (const row of rows) {
|
|
560
|
+
const title = asString(row[ROW_FIELD.title]);
|
|
561
|
+
const identifier = identifierFrom(asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]));
|
|
562
|
+
const source = sourceUrlOf(row, identifier);
|
|
563
|
+
if (title === null && source === "") {
|
|
564
|
+
skipped += 1;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
records.push({
|
|
568
|
+
identifier,
|
|
569
|
+
title: title === null ? null : plainText(title),
|
|
570
|
+
creator: creatorOf(row),
|
|
571
|
+
year: asYear(row[ROW_FIELD.date]),
|
|
572
|
+
date: asString(row[ROW_FIELD.date]),
|
|
573
|
+
format: asString(row[ROW_FIELD.originalFormat]),
|
|
574
|
+
location: asStrings(row[ROW_FIELD.location]),
|
|
575
|
+
subjects: asStrings(row[ROW_FIELD.subject]).slice(0, MOST_SUBJECTS),
|
|
576
|
+
online: row[ROW_FIELD.digitized] === true,
|
|
577
|
+
sourceUrl: source
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
if (skipped > 0) onSkip(skipped);
|
|
581
|
+
if (rows.length > 0 && records.length === 0) {
|
|
582
|
+
throw parseFailure(`${rows.length} rows came back and none could be read.`, { url });
|
|
583
|
+
}
|
|
584
|
+
return { paging: toPaging(payload, url), records };
|
|
585
|
+
}
|
|
586
|
+
var JOINING_WORDS = /* @__PURE__ */ new Set([
|
|
587
|
+
"the",
|
|
588
|
+
"and",
|
|
589
|
+
"for",
|
|
590
|
+
"with",
|
|
591
|
+
"that",
|
|
592
|
+
"this",
|
|
593
|
+
"from",
|
|
594
|
+
"was",
|
|
595
|
+
"were",
|
|
596
|
+
"are",
|
|
597
|
+
"his",
|
|
598
|
+
"her",
|
|
599
|
+
"its",
|
|
600
|
+
"not",
|
|
601
|
+
"but",
|
|
602
|
+
"had",
|
|
603
|
+
"has",
|
|
604
|
+
"you",
|
|
605
|
+
"all",
|
|
606
|
+
"any",
|
|
607
|
+
"who"
|
|
608
|
+
]);
|
|
609
|
+
function queryTerms(query) {
|
|
610
|
+
const terms = [];
|
|
611
|
+
const phrases = query.match(/"([^"]+)"/g) ?? [];
|
|
612
|
+
for (const phrase of phrases) {
|
|
613
|
+
const inner = phrase.slice(1, -1).trim().toLowerCase();
|
|
614
|
+
if (inner !== "") terms.push(inner);
|
|
615
|
+
}
|
|
616
|
+
const rest = query.replace(/"[^"]*"/g, " ");
|
|
617
|
+
for (const word of rest.split(/[^\p{L}\p{N}'-]+/u)) {
|
|
618
|
+
const lower = word.trim().toLowerCase();
|
|
619
|
+
if (lower.length < 3 || JOINING_WORDS.has(lower)) continue;
|
|
620
|
+
terms.push(lower);
|
|
621
|
+
}
|
|
622
|
+
return [...new Set(terms)];
|
|
623
|
+
}
|
|
624
|
+
function toWordBoundary(text, index, direction) {
|
|
625
|
+
const limit = direction === -1 ? 0 : text.length;
|
|
626
|
+
let at = index;
|
|
627
|
+
for (let step = 0; step < 30 && at !== limit; step += 1) {
|
|
628
|
+
if (text[at] === " ") return at;
|
|
629
|
+
at += direction;
|
|
630
|
+
}
|
|
631
|
+
return index;
|
|
632
|
+
}
|
|
633
|
+
function excerptsFor(text, terms, budget) {
|
|
634
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
635
|
+
if (clean === "") return { passages: [], located: false };
|
|
636
|
+
const haystack = clean.toLowerCase();
|
|
637
|
+
const found = [];
|
|
638
|
+
for (const term of terms) {
|
|
639
|
+
let from = 0;
|
|
640
|
+
while (from <= haystack.length - term.length) {
|
|
641
|
+
const at = haystack.indexOf(term, from);
|
|
642
|
+
if (at === -1) break;
|
|
643
|
+
found.push({ at, length: term.length });
|
|
644
|
+
from = at + term.length;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (found.length === 0) {
|
|
648
|
+
const opening = clean.slice(0, budget.maxChars);
|
|
649
|
+
const cut = opening.length < clean.length;
|
|
650
|
+
return {
|
|
651
|
+
passages: [cut ? `${opening.trimEnd()}\u2026` : opening],
|
|
652
|
+
located: false
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
found.sort((a, b) => a.at - b.at);
|
|
656
|
+
const windows = [];
|
|
657
|
+
for (const hit of found) {
|
|
658
|
+
const half = Math.max(0, Math.floor((budget.maxChars - hit.length) / 2));
|
|
659
|
+
const start = toWordBoundary(clean, Math.max(0, hit.at - half), -1);
|
|
660
|
+
const end = toWordBoundary(clean, Math.min(clean.length, hit.at + hit.length + half), 1);
|
|
661
|
+
const previous = windows[windows.length - 1];
|
|
662
|
+
if (previous && start <= previous.end) {
|
|
663
|
+
previous.end = Math.max(previous.end, end);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
windows.push({ start, end });
|
|
667
|
+
}
|
|
668
|
+
const passages = windows.slice(0, budget.maxCount).map(({ start, end }) => {
|
|
669
|
+
const body = clean.slice(start, Math.min(end, start + budget.maxChars)).trim();
|
|
670
|
+
const opensMidway = start > 0;
|
|
671
|
+
const closesEarly = Math.min(end, start + budget.maxChars) < clean.length;
|
|
672
|
+
return `${opensMidway ? "\u2026" : ""}${body}${closesEarly ? "\u2026" : ""}`;
|
|
673
|
+
});
|
|
674
|
+
return { passages, located: true };
|
|
675
|
+
}
|
|
676
|
+
function toNewspaperResults(payload, url, query, budget, onSkip) {
|
|
677
|
+
const rows = rowsOf(payload, url);
|
|
678
|
+
const terms = queryTerms(query);
|
|
679
|
+
const hits = [];
|
|
680
|
+
let skipped = 0;
|
|
681
|
+
for (const row of rows) {
|
|
682
|
+
const address = asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]);
|
|
683
|
+
const source = sourceUrlOf(row, identifierFrom(address));
|
|
684
|
+
if (source === "") {
|
|
685
|
+
skipped += 1;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const pageText = asStrings(row[ROW_FIELD.description]).join(" ");
|
|
689
|
+
const { passages, located } = excerptsFor(pageText, terms, budget);
|
|
690
|
+
const publication = asString(row[ROW_FIELD.publicationTitle]);
|
|
691
|
+
const title = asString(row[ROW_FIELD.title]);
|
|
692
|
+
hits.push({
|
|
693
|
+
identifier: identifierFrom(address),
|
|
694
|
+
title: title === null ? null : plainText(title),
|
|
695
|
+
// A newspaper page is credited to whoever contributed the scan, which is
|
|
696
|
+
// a library rather than a writer.
|
|
697
|
+
creator: creatorOf(row),
|
|
698
|
+
year: asYear(row[ROW_FIELD.date]),
|
|
699
|
+
pageNumber: asPageNumber(row[ROW_FIELD.pageNumber]),
|
|
700
|
+
publishedOn: asString(row[ROW_FIELD.date]),
|
|
701
|
+
publication,
|
|
702
|
+
state: asString(row[ROW_FIELD.state]),
|
|
703
|
+
wordsLocated: located,
|
|
704
|
+
excerpts: passages,
|
|
705
|
+
sourceUrl: source
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
if (skipped > 0) onSkip(skipped);
|
|
709
|
+
if (rows.length > 0 && hits.length === 0) {
|
|
710
|
+
throw parseFailure(`${rows.length} matches came back and none could be read.`, { url });
|
|
711
|
+
}
|
|
712
|
+
return { paging: toPaging(payload, url), hits };
|
|
713
|
+
}
|
|
714
|
+
function toResources(value) {
|
|
715
|
+
if (!Array.isArray(value)) return [];
|
|
716
|
+
const resources = [];
|
|
717
|
+
for (const entry of value) {
|
|
718
|
+
const resource = asObject(entry);
|
|
719
|
+
if (!resource) continue;
|
|
720
|
+
const files = resource.files;
|
|
721
|
+
const fileCount = Array.isArray(files) ? files.reduce((sum, group) => sum + (Array.isArray(group) ? group.length : 1), 0) : asNumber(files);
|
|
722
|
+
resources.push({
|
|
723
|
+
caption: asString(resource.caption),
|
|
724
|
+
fileCount,
|
|
725
|
+
url: asString(resource.url),
|
|
726
|
+
imageUrl: asString(resource.image)
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return resources;
|
|
730
|
+
}
|
|
731
|
+
function toCitations(value) {
|
|
732
|
+
const block = asObject(value);
|
|
733
|
+
if (!block) return {};
|
|
734
|
+
const citations = {};
|
|
735
|
+
for (const [style, text] of Object.entries(block)) {
|
|
736
|
+
if (typeof text !== "string" || text.trim() === "") continue;
|
|
737
|
+
citations[style] = plainText(text);
|
|
738
|
+
}
|
|
739
|
+
return citations;
|
|
740
|
+
}
|
|
741
|
+
function toItemDetail(payload, identifier, url) {
|
|
742
|
+
const root = asObject(payload);
|
|
743
|
+
if (!root) throw parseFailure("The item answer was not an object.", { url });
|
|
744
|
+
if (asNumber(root.status) === 404) {
|
|
745
|
+
throw notFound(`The Library of Congress has no record called "${identifier}".`, { url });
|
|
746
|
+
}
|
|
747
|
+
const item = asObject(root.item);
|
|
748
|
+
if (!item) {
|
|
749
|
+
throw parseFailure(
|
|
750
|
+
`The record for "${identifier}" came back without the item block this server reads.`,
|
|
751
|
+
{ url }
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
if (Object.keys(item).length === 0) {
|
|
755
|
+
throw notFound(`The Library of Congress has no record called "${identifier}".`, { url });
|
|
756
|
+
}
|
|
757
|
+
const description = asStrings(item[ITEM_FIELD.description]).map(plainText).join("\n\n");
|
|
758
|
+
const title = asString(item[ITEM_FIELD.title]);
|
|
759
|
+
const source = asString(item[ITEM_FIELD.url]) ?? itemUrl(identifier);
|
|
760
|
+
return {
|
|
761
|
+
identifier,
|
|
762
|
+
title: title === null ? null : plainText(title),
|
|
763
|
+
creator: asStrings(item[ITEM_FIELD.contributorNames]).map(plainText).join(", ") || null,
|
|
764
|
+
year: asYear(item[ITEM_FIELD.date]),
|
|
765
|
+
date: asString(item[ITEM_FIELD.date]),
|
|
766
|
+
format: asString(item[ITEM_FIELD.originalFormat]),
|
|
767
|
+
description: description === "" ? null : description,
|
|
768
|
+
notes: asStrings(item[ITEM_FIELD.notes]).map(plainText),
|
|
769
|
+
subjects: asLabels(item[ITEM_FIELD.subjects]).slice(0, MOST_SUBJECTS),
|
|
770
|
+
location: asLabels(item[ITEM_FIELD.location]),
|
|
771
|
+
language: asLabels(item[ITEM_FIELD.language]),
|
|
772
|
+
partOf: asLabels(item[ITEM_FIELD.partOf]),
|
|
773
|
+
repository: asStrings(item[ITEM_FIELD.repository]).map(plainText).join(" ") || null,
|
|
774
|
+
callNumber: asString(item[ITEM_FIELD.callNumber]),
|
|
775
|
+
rights: asString(item[ITEM_FIELD.rights]) ?? asString(item[ITEM_FIELD.rightsFallback]),
|
|
776
|
+
citations: toCitations(root.cite_this),
|
|
777
|
+
resources: toResources(root.resources),
|
|
778
|
+
sourceUrl: source.startsWith("//") ? `https:${source}` : source,
|
|
779
|
+
raw: item
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function toCollections(payload, url, onSkip) {
|
|
783
|
+
const rows = rowsOf(payload, url);
|
|
784
|
+
const collections = [];
|
|
785
|
+
let skipped = 0;
|
|
786
|
+
for (const row of rows) {
|
|
787
|
+
const title = asString(row[ROW_FIELD.title]);
|
|
788
|
+
if (title === null) {
|
|
789
|
+
skipped += 1;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
const address = asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]);
|
|
793
|
+
const inner = asObject(row.item);
|
|
794
|
+
const description = asStrings(row[ROW_FIELD.description]).map(plainText).join("\n\n");
|
|
795
|
+
collections.push({
|
|
796
|
+
identifier: identifierFrom(address),
|
|
797
|
+
title: plainText(title),
|
|
798
|
+
description: description === "" ? null : description,
|
|
799
|
+
itemCount: asNumber(row[ROW_FIELD.count]),
|
|
800
|
+
subjects: asStrings(row[ROW_FIELD.subject]).slice(0, MOST_SUBJECTS),
|
|
801
|
+
formats: inner ? asStrings(inner.formats) : [],
|
|
802
|
+
sourceUrl: sourceUrlOf(row, null),
|
|
803
|
+
itemsUrl: asString(row[ROW_FIELD.items])
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
if (skipped > 0) onSkip(skipped);
|
|
807
|
+
if (rows.length > 0 && collections.length === 0) {
|
|
808
|
+
throw parseFailure(`${rows.length} collections came back and none could be read.`, { url });
|
|
809
|
+
}
|
|
810
|
+
return { paging: toPaging(payload, url), collections };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/loc/rateLimiter.ts
|
|
814
|
+
var RateLimiter = class {
|
|
815
|
+
baseIntervalMs;
|
|
816
|
+
maxIntervalMs;
|
|
817
|
+
intervalMs;
|
|
818
|
+
lastStartedAt = 0;
|
|
819
|
+
queue = Promise.resolve();
|
|
820
|
+
calmStreak = 0;
|
|
821
|
+
constructor(options) {
|
|
822
|
+
this.baseIntervalMs = options.intervalMs;
|
|
823
|
+
this.maxIntervalMs = options.maxIntervalMs ?? options.intervalMs * 8;
|
|
824
|
+
this.intervalMs = options.intervalMs;
|
|
825
|
+
}
|
|
826
|
+
/** The spacing in force, which callers report rather than guess. */
|
|
827
|
+
get currentIntervalMs() {
|
|
828
|
+
return this.intervalMs;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Run `task` with nothing else in flight.
|
|
832
|
+
*
|
|
833
|
+
* This only serialises. Spacing is claimed per attempt through
|
|
834
|
+
* `beforeRequest`, because a task that retries makes several requests and
|
|
835
|
+
* each of them owes the site the same gap.
|
|
836
|
+
*/
|
|
837
|
+
schedule(task) {
|
|
838
|
+
const result = this.queue.then(task, task);
|
|
839
|
+
this.queue = result.then(
|
|
840
|
+
() => void 0,
|
|
841
|
+
() => void 0
|
|
842
|
+
);
|
|
843
|
+
return result;
|
|
844
|
+
}
|
|
845
|
+
/** Wait until enough time has passed since the previous request began. */
|
|
846
|
+
async beforeRequest() {
|
|
847
|
+
const elapsed = Date.now() - this.lastStartedAt;
|
|
848
|
+
const wait = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
849
|
+
if (wait > 0) await sleep(wait);
|
|
850
|
+
this.lastStartedAt = Date.now();
|
|
851
|
+
}
|
|
852
|
+
/** Called when the site asks for room: double the gap, up to the ceiling. */
|
|
853
|
+
pushBack() {
|
|
854
|
+
this.calmStreak = 0;
|
|
855
|
+
this.intervalMs = Math.min(this.maxIntervalMs, this.intervalMs * 2);
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Called on a clean answer. Recovery takes several in a row, so one lucky
|
|
859
|
+
* response after a rough patch does not undo the caution that earned it.
|
|
860
|
+
*/
|
|
861
|
+
succeeded() {
|
|
862
|
+
if (this.intervalMs === this.baseIntervalMs) return;
|
|
863
|
+
this.calmStreak += 1;
|
|
864
|
+
if (this.calmStreak < 3) return;
|
|
865
|
+
this.calmStreak = 0;
|
|
866
|
+
this.intervalMs = Math.max(this.baseIntervalMs, Math.round(this.intervalMs / 2));
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
function sleep(ms) {
|
|
870
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/loc/client.ts
|
|
874
|
+
function withGuarantees(config) {
|
|
875
|
+
const defaults = loadConfig({});
|
|
876
|
+
const number = (value, fallback) => {
|
|
877
|
+
const parsed = Number(value);
|
|
878
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
879
|
+
};
|
|
880
|
+
const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
|
|
881
|
+
const identifier = defaults.userAgent;
|
|
882
|
+
return {
|
|
883
|
+
...config,
|
|
884
|
+
// A caller may say who they are. Appending rather than replacing means the
|
|
885
|
+
// Library can always tell which software is calling, and reach someone.
|
|
886
|
+
userAgent: claimed === "" || claimed.includes(REPO_URL) ? identifier : `${claimed} ${identifier}`,
|
|
887
|
+
minIntervalMs: Math.max(
|
|
888
|
+
MIN_ALLOWED_INTERVAL_MS,
|
|
889
|
+
number(config.minIntervalMs, defaults.minIntervalMs)
|
|
890
|
+
),
|
|
891
|
+
timeoutMs: number(config.timeoutMs, defaults.timeoutMs),
|
|
892
|
+
newspaperTimeoutMs: number(config.newspaperTimeoutMs, defaults.newspaperTimeoutMs),
|
|
893
|
+
maxRetries: number(config.maxRetries, defaults.maxRetries),
|
|
894
|
+
cacheTtlMs: number(config.cacheTtlMs, defaults.cacheTtlMs),
|
|
895
|
+
cacheMaxEntries: number(config.cacheMaxEntries, defaults.cacheMaxEntries)
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
var LocClient = class {
|
|
899
|
+
config;
|
|
900
|
+
logger;
|
|
901
|
+
limiter;
|
|
902
|
+
cache;
|
|
903
|
+
fetchImpl;
|
|
904
|
+
constructor(options = {}) {
|
|
905
|
+
const base2 = { ...loadConfig(), ...options.config };
|
|
906
|
+
this.config = withGuarantees(base2);
|
|
907
|
+
this.logger = options.logger ?? createLogger(this.config.logLevel);
|
|
908
|
+
this.limiter = new RateLimiter({ intervalMs: this.config.minIntervalMs });
|
|
909
|
+
this.cache = new Cache(this.config.cacheTtlMs, this.config.cacheMaxEntries);
|
|
910
|
+
this.fetchImpl = options.fetchImpl;
|
|
911
|
+
}
|
|
912
|
+
/** The pacing in force, which widens when the Library pushes back. */
|
|
913
|
+
get intervalMs() {
|
|
914
|
+
return this.limiter.currentIntervalMs;
|
|
915
|
+
}
|
|
916
|
+
/** What the Library sees this client call itself. */
|
|
917
|
+
get userAgent() {
|
|
918
|
+
return this.config.userAgent;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* `cacheKey` is stated rather than taken from the address, because two reads
|
|
922
|
+
* of one address can produce different values: a passage cut to three hundred
|
|
923
|
+
* characters is not the same value as the same passage cut to a thousand, and
|
|
924
|
+
* serving one for the other would answer a budget the caller never set.
|
|
925
|
+
*/
|
|
926
|
+
async read(cacheKey, url, parse, timeoutMs = this.config.timeoutMs) {
|
|
927
|
+
const cached = this.cache.get(cacheKey);
|
|
928
|
+
if (cached !== void 0) {
|
|
929
|
+
this.logger.debug(`cache hit ${cacheKey}`);
|
|
930
|
+
return { data: cached, cached: true };
|
|
931
|
+
}
|
|
932
|
+
let skipped = 0;
|
|
933
|
+
const payload = await this.limiter.schedule(
|
|
934
|
+
() => fetchJson({
|
|
935
|
+
url,
|
|
936
|
+
userAgent: this.config.userAgent,
|
|
937
|
+
timeoutMs,
|
|
938
|
+
maxRetries: this.config.maxRetries,
|
|
939
|
+
limiter: this.limiter,
|
|
940
|
+
logger: this.logger,
|
|
941
|
+
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
942
|
+
})
|
|
943
|
+
);
|
|
944
|
+
const data = parse(payload, (n) => {
|
|
945
|
+
skipped += n;
|
|
946
|
+
this.logger.warn(`skipped ${n} unreadable row(s) from ${url}`);
|
|
947
|
+
});
|
|
948
|
+
this.cache.set(cacheKey, data);
|
|
949
|
+
return skipped > 0 ? { data, cached: false, skipped } : { data, cached: false };
|
|
950
|
+
}
|
|
951
|
+
searchItems(query) {
|
|
952
|
+
const url = catalogueUrl(query);
|
|
953
|
+
return this.read(url, url, (payload, onSkip) => toSearchResults(payload, url, onSkip));
|
|
954
|
+
}
|
|
955
|
+
searchNewspapers(query, limit, page, budget, filters = {}) {
|
|
956
|
+
const trimmed = query.trim();
|
|
957
|
+
if (trimmed === "") {
|
|
958
|
+
return Promise.reject(invalidInput("Words to look for are required."));
|
|
959
|
+
}
|
|
960
|
+
const url = newspaperPagesUrl(trimmed, limit, page, filters);
|
|
961
|
+
const key = `${url}#${budget.maxChars}x${budget.maxCount}`;
|
|
962
|
+
return this.read(
|
|
963
|
+
key,
|
|
964
|
+
url,
|
|
965
|
+
(payload, onSkip) => toNewspaperResults(payload, url, trimmed, budget, onSkip),
|
|
966
|
+
// Reading the text of millions of pages takes the site far longer than
|
|
967
|
+
// answering from the catalogue, so this route carries a budget of its own.
|
|
968
|
+
this.config.newspaperTimeoutMs
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
getItem(identifier) {
|
|
972
|
+
const trimmed = identifier.trim();
|
|
973
|
+
if (trimmed === "") {
|
|
974
|
+
return Promise.reject(invalidInput("A record identifier is required."));
|
|
975
|
+
}
|
|
976
|
+
const url = itemDocumentUrl(trimmed);
|
|
977
|
+
return this.read(url, url, (payload) => toItemDetail(payload, trimmed, url));
|
|
978
|
+
}
|
|
979
|
+
listCollections(limit, page) {
|
|
980
|
+
const url = collectionsUrl(limit, page);
|
|
981
|
+
return this.read(url, url, (payload, onSkip) => toCollections(payload, url, onSkip));
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
export {
|
|
985
|
+
LocClient
|
|
986
|
+
};
|
|
987
|
+
//# sourceMappingURL=client.js.map
|