mcp-libraryofcongress 2.1.2 → 3.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 +14 -0
- package/README.md +39 -3
- package/dist/index.js +536 -254
- package/dist/index.js.map +1 -1
- package/dist/loc/client.d.ts +4 -4
- package/dist/loc/client.js +281 -129
- package/dist/loc/client.js.map +1 -1
- package/package.json +18 -9
- package/server.json +68 -0
package/dist/index.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import process2 from "process";
|
|
4
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
6
|
|
|
6
7
|
// src/server.ts
|
|
7
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
9
|
|
|
10
|
+
// src/config.ts
|
|
11
|
+
import process from "process";
|
|
12
|
+
|
|
9
13
|
// src/version.ts
|
|
10
|
-
var PKG_VERSION = "
|
|
14
|
+
var PKG_VERSION = "3.0.0";
|
|
11
15
|
var REPO_URL = "https://github.com/smeet666/mcp-libraryofcongress";
|
|
12
16
|
|
|
13
17
|
// src/config.ts
|
|
@@ -20,7 +24,9 @@ var DEFAULT_USER_AGENT = `mcp-libraryofcongress/${PKG_VERSION} (+${REPO_URL})`;
|
|
|
20
24
|
function createLogger(level) {
|
|
21
25
|
const rank = LOG_LEVELS.indexOf(level);
|
|
22
26
|
const write = (at, message) => {
|
|
23
|
-
if (rank === 0 || LOG_LEVELS.indexOf(at) > rank)
|
|
27
|
+
if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
24
30
|
process.stderr.write(`[mcp-libraryofcongress] ${at}: ${message}
|
|
25
31
|
`);
|
|
26
32
|
};
|
|
@@ -35,9 +41,11 @@ function createLogger(level) {
|
|
|
35
41
|
}
|
|
36
42
|
function readInteger(env, name, fallback, min, max) {
|
|
37
43
|
const raw = env[name];
|
|
38
|
-
if (raw === void 0 || raw.trim() === "")
|
|
44
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
39
47
|
const value = Number(raw);
|
|
40
|
-
if (!Number.isFinite(value)
|
|
48
|
+
if (!(Number.isFinite(value) && Number.isInteger(value))) {
|
|
41
49
|
process.stderr.write(
|
|
42
50
|
`[mcp-libraryofcongress] error: ${name}="${raw}" is not a whole number; using ${fallback}.
|
|
43
51
|
`
|
|
@@ -96,7 +104,7 @@ var LocError = class extends Error {
|
|
|
96
104
|
code;
|
|
97
105
|
details;
|
|
98
106
|
constructor(code, message, details = {}) {
|
|
99
|
-
super(message);
|
|
107
|
+
super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
|
|
100
108
|
this.name = "LocError";
|
|
101
109
|
this.code = code;
|
|
102
110
|
this.details = details;
|
|
@@ -126,7 +134,9 @@ var Cache = class {
|
|
|
126
134
|
entries = /* @__PURE__ */ new Map();
|
|
127
135
|
get(key) {
|
|
128
136
|
const entry = this.entries.get(key);
|
|
129
|
-
if (!entry)
|
|
137
|
+
if (!entry) {
|
|
138
|
+
return void 0;
|
|
139
|
+
}
|
|
130
140
|
if (entry.expiresAt <= Date.now()) {
|
|
131
141
|
this.entries.delete(key);
|
|
132
142
|
return void 0;
|
|
@@ -136,12 +146,16 @@ var Cache = class {
|
|
|
136
146
|
return entry.value;
|
|
137
147
|
}
|
|
138
148
|
set(key, value) {
|
|
139
|
-
if (this.ttlMs <= 0)
|
|
149
|
+
if (this.ttlMs <= 0) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
140
152
|
this.entries.delete(key);
|
|
141
153
|
this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
142
154
|
while (this.entries.size > this.maxEntries) {
|
|
143
155
|
const oldest = this.entries.keys().next();
|
|
144
|
-
if (oldest.done)
|
|
156
|
+
if (oldest.done) {
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
145
159
|
this.entries.delete(oldest.value);
|
|
146
160
|
}
|
|
147
161
|
}
|
|
@@ -151,28 +165,118 @@ var Cache = class {
|
|
|
151
165
|
};
|
|
152
166
|
|
|
153
167
|
// src/loc/http.ts
|
|
168
|
+
var ZERO_MAX_AGE = /^(?:max-age|s-maxage)\s*=\s*0$/;
|
|
154
169
|
var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
155
170
|
var PUSH_BACK = /* @__PURE__ */ new Set([429, 503]);
|
|
156
171
|
var LONGEST_WAIT_MS = 3e4;
|
|
157
172
|
var RETRIES_AFTER_SILENCE = 1;
|
|
158
173
|
function parseRetryAfter(value, now = Date.now()) {
|
|
159
|
-
if (!value)
|
|
174
|
+
if (!value) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
160
177
|
const trimmed = value.trim();
|
|
161
178
|
const seconds = Number(trimmed);
|
|
162
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
179
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
180
|
+
return Math.round(seconds * 1e3);
|
|
181
|
+
}
|
|
163
182
|
const at = Date.parse(trimmed);
|
|
164
|
-
if (Number.isNaN(at))
|
|
183
|
+
if (Number.isNaN(at)) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
165
186
|
return Math.max(0, at - now);
|
|
166
187
|
}
|
|
167
188
|
function backoffMs(attempt) {
|
|
168
189
|
const base2 = Math.min(16e3, 1e3 * 2 ** attempt);
|
|
169
190
|
return base2 + Math.floor(Math.random() * 500);
|
|
170
191
|
}
|
|
192
|
+
function readFailure(error, attempts) {
|
|
193
|
+
const { url, attempt, maxRetries, timeoutMs } = attempts;
|
|
194
|
+
if (error instanceof Error && error.name === "LocError") {
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
198
|
+
if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
|
|
199
|
+
throw timeout(
|
|
200
|
+
`No answer from the Library of Congress within ${timeoutMs}ms. The newspaper corpus in particular can take tens of seconds.`,
|
|
201
|
+
{ url }
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return error;
|
|
205
|
+
}
|
|
206
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
207
|
+
if (attempt >= maxRetries) {
|
|
208
|
+
throw networkError(`Could not reach the Library of Congress: ${failure.message}`, { url });
|
|
209
|
+
}
|
|
210
|
+
return failure;
|
|
211
|
+
}
|
|
212
|
+
async function readRefusal(response, url, attempt, maxRetries) {
|
|
213
|
+
if (PUSH_BACK.has(response.status)) {
|
|
214
|
+
await response.body?.cancel().catch(() => void 0);
|
|
215
|
+
const asked = parseRetryAfter(response.headers.get("retry-after"));
|
|
216
|
+
if (asked !== null && asked > LONGEST_WAIT_MS) {
|
|
217
|
+
return {
|
|
218
|
+
kind: "refused",
|
|
219
|
+
pushBack: true,
|
|
220
|
+
error: rateLimited(
|
|
221
|
+
`The Library of Congress asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
|
|
222
|
+
{ url, status: response.status }
|
|
223
|
+
)
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (attempt >= maxRetries) {
|
|
227
|
+
return {
|
|
228
|
+
kind: "refused",
|
|
229
|
+
pushBack: true,
|
|
230
|
+
error: rateLimited(
|
|
231
|
+
`The Library of Congress asked this client to slow down (HTTP ${response.status}).`,
|
|
232
|
+
{ url, status: response.status }
|
|
233
|
+
)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return { kind: "again", pushBack: true, waitMs: asked ?? backoffMs(attempt) };
|
|
237
|
+
}
|
|
238
|
+
if (RETRYABLE.has(response.status) && attempt < maxRetries) {
|
|
239
|
+
await response.body?.cancel().catch(() => void 0);
|
|
240
|
+
return { kind: "again", pushBack: false, waitMs: backoffMs(attempt) };
|
|
241
|
+
}
|
|
242
|
+
if (response.status === 400 || response.status === 422) {
|
|
243
|
+
return {
|
|
244
|
+
kind: "refused",
|
|
245
|
+
pushBack: false,
|
|
246
|
+
error: invalidInput(
|
|
247
|
+
"The Library of Congress would not accept this request.",
|
|
248
|
+
"Check the query. A quotation mark or bracket left unbalanced is read as syntax."
|
|
249
|
+
)
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (response.status === 404 || response.status === 410) {
|
|
253
|
+
return {
|
|
254
|
+
kind: "refused",
|
|
255
|
+
pushBack: false,
|
|
256
|
+
error: notFound("The Library of Congress holds nothing at this address.", {
|
|
257
|
+
url,
|
|
258
|
+
status: response.status
|
|
259
|
+
})
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
kind: "refused",
|
|
264
|
+
pushBack: false,
|
|
265
|
+
error: networkError(`The Library of Congress answered HTTP ${response.status}.`, {
|
|
266
|
+
url,
|
|
267
|
+
status: response.status
|
|
268
|
+
})
|
|
269
|
+
};
|
|
270
|
+
}
|
|
171
271
|
function statesASettledAnswer(cacheControl) {
|
|
172
|
-
if (cacheControl === null)
|
|
272
|
+
if (cacheControl === null) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
173
275
|
const directives = cacheControl.toLowerCase().split(",").map((directive) => directive.trim());
|
|
174
|
-
if (directives.includes("no-cache") || directives.includes("no-store"))
|
|
175
|
-
|
|
276
|
+
if (directives.includes("no-cache") || directives.includes("no-store")) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return !directives.some((directive) => ZERO_MAX_AGE.test(directive));
|
|
176
280
|
}
|
|
177
281
|
async function fetchText(options) {
|
|
178
282
|
const { url, userAgent, timeoutMs, maxRetries, limiter, logger } = options;
|
|
@@ -182,7 +286,8 @@ async function fetchText(options) {
|
|
|
182
286
|
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
183
287
|
if (askedWaitMs > 0) {
|
|
184
288
|
logger.debug(`waiting ${askedWaitMs}ms, as asked`);
|
|
185
|
-
|
|
289
|
+
const asked = askedWaitMs;
|
|
290
|
+
await new Promise((resolve) => setTimeout(resolve, asked));
|
|
186
291
|
askedWaitMs = 0;
|
|
187
292
|
}
|
|
188
293
|
await limiter.beforeRequest();
|
|
@@ -200,70 +305,19 @@ async function fetchText(options) {
|
|
|
200
305
|
const settled = statesASettledAnswer(response.headers.get("cache-control"));
|
|
201
306
|
return { payload: await response.text(), settled };
|
|
202
307
|
}
|
|
203
|
-
|
|
308
|
+
const verdict = await readRefusal(response, url, attempt, maxRetries);
|
|
309
|
+
if (verdict.pushBack) {
|
|
204
310
|
limiter.pushBack();
|
|
205
|
-
await response.body?.cancel().catch(() => void 0);
|
|
206
|
-
const asked = parseRetryAfter(response.headers.get("retry-after"));
|
|
207
|
-
if (asked !== null && asked > LONGEST_WAIT_MS) {
|
|
208
|
-
throw rateLimited(
|
|
209
|
-
`The Library of Congress asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
|
|
210
|
-
{ url, status: response.status }
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
if (attempt >= maxRetries) {
|
|
214
|
-
throw rateLimited(
|
|
215
|
-
`The Library of Congress asked this client to slow down (HTTP ${response.status}).`,
|
|
216
|
-
{ url, status: response.status }
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
askedWaitMs = asked ?? backoffMs(attempt);
|
|
220
|
-
lastError = new Error(`HTTP ${response.status}`);
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
if (RETRYABLE.has(response.status) && attempt < maxRetries) {
|
|
224
|
-
await response.body?.cancel().catch(() => void 0);
|
|
225
|
-
lastError = new Error(`HTTP ${response.status}`);
|
|
226
|
-
askedWaitMs = backoffMs(attempt);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
if (response.status === 400 || response.status === 422) {
|
|
230
|
-
throw invalidInput(
|
|
231
|
-
"The Library of Congress would not accept this request.",
|
|
232
|
-
"Check the query. A quotation mark or bracket left unbalanced is read as syntax."
|
|
233
|
-
);
|
|
234
311
|
}
|
|
235
|
-
if (
|
|
236
|
-
throw
|
|
237
|
-
url,
|
|
238
|
-
status: response.status
|
|
239
|
-
});
|
|
312
|
+
if (verdict.kind === "refused") {
|
|
313
|
+
throw verdict.error;
|
|
240
314
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
status: response.status
|
|
244
|
-
});
|
|
315
|
+
askedWaitMs = verdict.waitMs;
|
|
316
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
245
317
|
} catch (error) {
|
|
246
318
|
clearTimeout(deadline);
|
|
247
|
-
|
|
248
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
249
|
-
lastError = error;
|
|
250
|
-
if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
|
|
251
|
-
throw timeout(
|
|
252
|
-
`No answer from the Library of Congress within ${timeoutMs}ms. The newspaper corpus in particular can take tens of seconds.`,
|
|
253
|
-
{ url }
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
askedWaitMs = backoffMs(attempt);
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
260
|
-
if (attempt >= maxRetries) {
|
|
261
|
-
throw networkError(`Could not reach the Library of Congress: ${lastError.message}`, {
|
|
262
|
-
url
|
|
263
|
-
});
|
|
264
|
-
}
|
|
319
|
+
lastError = readFailure(error, { url, attempt, maxRetries, timeoutMs });
|
|
265
320
|
askedWaitMs = backoffMs(attempt);
|
|
266
|
-
continue;
|
|
267
321
|
} finally {
|
|
268
322
|
clearTimeout(deadline);
|
|
269
323
|
}
|
|
@@ -277,9 +331,10 @@ async function fetchJson(options) {
|
|
|
277
331
|
const { payload, settled } = await fetchText(options);
|
|
278
332
|
try {
|
|
279
333
|
return { payload: JSON.parse(payload), settled };
|
|
280
|
-
} catch {
|
|
334
|
+
} catch (cause) {
|
|
281
335
|
throw parseFailure("The Library of Congress answered with something that is not JSON.", {
|
|
282
|
-
url: options.url
|
|
336
|
+
url: options.url,
|
|
337
|
+
cause
|
|
283
338
|
});
|
|
284
339
|
}
|
|
285
340
|
}
|
|
@@ -309,7 +364,9 @@ function routesNamedBy(formats) {
|
|
|
309
364
|
const named = /* @__PURE__ */ new Set();
|
|
310
365
|
for (const format of formats) {
|
|
311
366
|
const route = COLLECTION_FORMAT_ROUTE[format.trim().toLowerCase()];
|
|
312
|
-
if (route)
|
|
367
|
+
if (route) {
|
|
368
|
+
named.add(route);
|
|
369
|
+
}
|
|
313
370
|
}
|
|
314
371
|
return FORMAT_ROUTES.filter((route) => named.has(route));
|
|
315
372
|
}
|
|
@@ -407,6 +464,7 @@ var MOST_SUBJECTS = 12;
|
|
|
407
464
|
var itemUrl = (identifier) => `${HOST}${ITEM_ROUTE}${identifier.split("/").map(encodeURIComponent).join("/")}/`;
|
|
408
465
|
|
|
409
466
|
// src/loc/urls.ts
|
|
467
|
+
var DOTS_ONLY = /^\.+$/;
|
|
410
468
|
var YEAR_BOUND = { earliest: 1e3, latest: 9999 };
|
|
411
469
|
var CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
|
|
412
470
|
function base(path) {
|
|
@@ -429,10 +487,16 @@ function catalogueUrl(q) {
|
|
|
429
487
|
url.searchParams.set(PARAM.dates, `${from}/${to}`);
|
|
430
488
|
}
|
|
431
489
|
const facets = facetString(q.facets ?? {});
|
|
432
|
-
if (facets !== "")
|
|
490
|
+
if (facets !== "") {
|
|
491
|
+
url.searchParams.set(PARAM.facets, facets);
|
|
492
|
+
}
|
|
433
493
|
const sort = q.sort ? SORT[q.sort] : "";
|
|
434
|
-
if (sort !== "")
|
|
435
|
-
|
|
494
|
+
if (sort !== "") {
|
|
495
|
+
url.searchParams.set(PARAM.sort, sort);
|
|
496
|
+
}
|
|
497
|
+
if (!q.onlineOnly) {
|
|
498
|
+
url.searchParams.set(PARAM.all, "true");
|
|
499
|
+
}
|
|
436
500
|
return url.toString();
|
|
437
501
|
}
|
|
438
502
|
function newspaperPagesUrl(query, limit, page, filters = {}) {
|
|
@@ -446,7 +510,9 @@ function newspaperPagesUrl(query, limit, page, filters = {}) {
|
|
|
446
510
|
url.searchParams.set(PARAM.dates, `${from}/${to}`);
|
|
447
511
|
}
|
|
448
512
|
const facets = facetString(filters.facets ?? {});
|
|
449
|
-
if (facets !== "")
|
|
513
|
+
if (facets !== "") {
|
|
514
|
+
url.searchParams.set(PARAM.facets, facets);
|
|
515
|
+
}
|
|
450
516
|
return url.toString();
|
|
451
517
|
}
|
|
452
518
|
function collectionsUrl(limit, page) {
|
|
@@ -463,7 +529,7 @@ function itemDocumentUrl(identifier) {
|
|
|
463
529
|
);
|
|
464
530
|
}
|
|
465
531
|
const segments = identifier.trim().replace(/^\/+|\/+$/g, "").split("/").filter((segment) => segment !== "");
|
|
466
|
-
if (segments.some((segment) =>
|
|
532
|
+
if (segments.some((segment) => DOTS_ONLY.test(segment))) {
|
|
467
533
|
throw invalidInput(
|
|
468
534
|
`"${identifier}" is not an identifier: it carries a relative path segment.`,
|
|
469
535
|
"Take the identifier from a search result rather than building one."
|
|
@@ -475,9 +541,13 @@ function itemDocumentUrl(identifier) {
|
|
|
475
541
|
return url.toString();
|
|
476
542
|
}
|
|
477
543
|
function segmentsOf(address) {
|
|
478
|
-
if (!address)
|
|
544
|
+
if (!address) {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
479
547
|
const trimmed = address.trim();
|
|
480
|
-
if (trimmed === "")
|
|
548
|
+
if (trimmed === "") {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
481
551
|
const absolute = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
|
|
482
552
|
let parsed;
|
|
483
553
|
try {
|
|
@@ -486,14 +556,20 @@ function segmentsOf(address) {
|
|
|
486
556
|
return null;
|
|
487
557
|
}
|
|
488
558
|
const path = parsed.pathname.replace(/^\/+|\/+$/g, "");
|
|
489
|
-
if (path === "")
|
|
559
|
+
if (path === "") {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
490
562
|
return { host: parsed.hostname, segments: path.split("/") };
|
|
491
563
|
}
|
|
492
564
|
function identifierFrom(address) {
|
|
493
565
|
const parsed = segmentsOf(address);
|
|
494
|
-
if (!parsed)
|
|
566
|
+
if (!parsed) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
495
569
|
const { host, segments } = parsed;
|
|
496
|
-
if (host === "lccn.loc.gov")
|
|
570
|
+
if (host === "lccn.loc.gov") {
|
|
571
|
+
return segments[0] ?? null;
|
|
572
|
+
}
|
|
497
573
|
const first = segments[0];
|
|
498
574
|
if (first === "item" || first === "resource") {
|
|
499
575
|
const rest = segments.slice(1);
|
|
@@ -503,16 +579,24 @@ function identifierFrom(address) {
|
|
|
503
579
|
}
|
|
504
580
|
function collectionSlugFrom(address) {
|
|
505
581
|
const parsed = segmentsOf(address);
|
|
506
|
-
if (!parsed)
|
|
582
|
+
if (!parsed) {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
507
585
|
return parsed.segments[0] === "collections" ? parsed.segments[1] ?? null : null;
|
|
508
586
|
}
|
|
509
587
|
|
|
510
588
|
// src/loc/parse.ts
|
|
589
|
+
var DIGITS_ONLY = /^\d+$/;
|
|
590
|
+
var FOUR_DIGITS = /(\d{4})/;
|
|
591
|
+
var WORD_SEPARATORS = /[^\p{L}\p{N}'-]+/u;
|
|
511
592
|
var asObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
512
593
|
function asStrings(value) {
|
|
513
|
-
if (typeof value === "string")
|
|
514
|
-
|
|
594
|
+
if (typeof value === "string") {
|
|
595
|
+
return value.trim() === "" ? [] : [value];
|
|
596
|
+
}
|
|
597
|
+
if (Array.isArray(value)) {
|
|
515
598
|
return value.filter((v) => typeof v === "string" && v.trim() !== "");
|
|
599
|
+
}
|
|
516
600
|
return [];
|
|
517
601
|
}
|
|
518
602
|
var asString = (value) => asStrings(value)[0] ?? null;
|
|
@@ -521,36 +605,52 @@ function asLabels(value) {
|
|
|
521
605
|
const labels = [];
|
|
522
606
|
for (const entry of entries) {
|
|
523
607
|
if (typeof entry === "string") {
|
|
524
|
-
if (entry.trim() !== "")
|
|
608
|
+
if (entry.trim() !== "") {
|
|
609
|
+
labels.push(entry);
|
|
610
|
+
}
|
|
525
611
|
continue;
|
|
526
612
|
}
|
|
527
613
|
const object = asObject(entry);
|
|
528
|
-
if (!object)
|
|
614
|
+
if (!object) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
529
617
|
const titled = asString(object.title);
|
|
530
618
|
if (titled !== null) {
|
|
531
619
|
labels.push(titled);
|
|
532
620
|
continue;
|
|
533
621
|
}
|
|
534
622
|
for (const key of Object.keys(object)) {
|
|
535
|
-
if (key.trim() !== "")
|
|
623
|
+
if (key.trim() !== "") {
|
|
624
|
+
labels.push(key);
|
|
625
|
+
}
|
|
536
626
|
}
|
|
537
627
|
}
|
|
538
628
|
return labels;
|
|
539
629
|
}
|
|
540
630
|
function asNumber(value) {
|
|
541
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
631
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
632
|
+
return value;
|
|
633
|
+
}
|
|
542
634
|
if (typeof value === "string" && value.trim() !== "") {
|
|
543
635
|
const n = Number(value);
|
|
544
|
-
if (Number.isFinite(n))
|
|
636
|
+
if (Number.isFinite(n)) {
|
|
637
|
+
return n;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (Array.isArray(value)) {
|
|
641
|
+
return asNumber(value[0]);
|
|
545
642
|
}
|
|
546
|
-
if (Array.isArray(value)) return asNumber(value[0]);
|
|
547
643
|
return null;
|
|
548
644
|
}
|
|
549
645
|
function asPageNumber(value) {
|
|
550
646
|
const text = asString(value);
|
|
551
|
-
if (text === null)
|
|
647
|
+
if (text === null) {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
552
650
|
const digits = text.trim();
|
|
553
|
-
if (
|
|
651
|
+
if (!DIGITS_ONLY.test(digits)) {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
554
654
|
const n = Number(digits);
|
|
555
655
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
556
656
|
}
|
|
@@ -559,8 +659,10 @@ function asYear(value) {
|
|
|
559
659
|
const plausible = (year) => Number.isFinite(year) && year >= PLAUSIBLE_YEAR.first && year <= PLAUSIBLE_YEAR.last ? Math.trunc(year) : null;
|
|
560
660
|
const text = asString(value);
|
|
561
661
|
if (text !== null) {
|
|
562
|
-
const m =
|
|
563
|
-
if (m)
|
|
662
|
+
const m = FOUR_DIGITS.exec(text);
|
|
663
|
+
if (m) {
|
|
664
|
+
return plausible(Number(m[1]));
|
|
665
|
+
}
|
|
564
666
|
}
|
|
565
667
|
const direct = asNumber(value);
|
|
566
668
|
return direct === null ? null : plausible(direct);
|
|
@@ -607,10 +709,14 @@ function monthsNamed(text, filedYear) {
|
|
|
607
709
|
for (const match of text.matchAll(MONTH_BESIDE_A_NUMBER)) {
|
|
608
710
|
const word = (match[2] ?? match[3] ?? "").toLowerCase();
|
|
609
711
|
const month = MONTH_NUMBER[word];
|
|
610
|
-
if (month !== void 0)
|
|
712
|
+
if (month !== void 0) {
|
|
713
|
+
months.add(month);
|
|
714
|
+
}
|
|
611
715
|
}
|
|
612
716
|
for (const match of text.matchAll(YEAR_AND_TWO_DIGITS)) {
|
|
613
|
-
if (match[1] === filedYear)
|
|
717
|
+
if (match[1] === filedYear) {
|
|
718
|
+
months.add(Number(match[2]));
|
|
719
|
+
}
|
|
614
720
|
}
|
|
615
721
|
for (const match of text.matchAll(SLASHED_DATE)) {
|
|
616
722
|
months.add(Number(match[1]));
|
|
@@ -633,22 +739,32 @@ function rangeOpenings(text) {
|
|
|
633
739
|
}
|
|
634
740
|
function statedDate(item) {
|
|
635
741
|
const words = asStrings(item[ITEM_FIELD.createdPublished]).map(plainText).filter((line) => line !== "");
|
|
636
|
-
if (words.length > 0)
|
|
742
|
+
if (words.length > 0) {
|
|
743
|
+
return words.join("; ");
|
|
744
|
+
}
|
|
637
745
|
const spans = asLabels(item[ITEM_FIELD.dateSpans]);
|
|
638
746
|
return spans.length > 0 ? spans.join("; ") : null;
|
|
639
747
|
}
|
|
640
748
|
function datePublished(filed, stated) {
|
|
641
|
-
if (filed === null)
|
|
749
|
+
if (filed === null) {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
642
752
|
const trimmed = filed.trim();
|
|
643
|
-
if (trimmed === "" || isCataloguingCode(trimmed))
|
|
753
|
+
if (trimmed === "" || isCataloguingCode(trimmed)) {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
644
756
|
const filled = FILED_TO_THE_DAY.exec(trimmed) ?? FILED_TO_THE_MONTH.exec(trimmed);
|
|
645
|
-
if (filled === null || stated === null)
|
|
757
|
+
if (filled === null || stated === null) {
|
|
758
|
+
return trimmed;
|
|
759
|
+
}
|
|
646
760
|
const year = filled[1];
|
|
647
761
|
const month = Number(filled[2]);
|
|
648
762
|
return monthsNamed(stated, year).has(month) ? trimmed : year;
|
|
649
763
|
}
|
|
650
764
|
function dateCode(filed) {
|
|
651
|
-
if (filed === null)
|
|
765
|
+
if (filed === null) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
652
768
|
const trimmed = filed.trim();
|
|
653
769
|
return isCataloguingCode(trimmed) ? trimmed : null;
|
|
654
770
|
}
|
|
@@ -685,7 +801,7 @@ function toPaging(payload, url, settled = true) {
|
|
|
685
801
|
}
|
|
686
802
|
function rowsOf(payload, url) {
|
|
687
803
|
const root = asObject(payload);
|
|
688
|
-
if (!root
|
|
804
|
+
if (!(root && Array.isArray(root.results))) {
|
|
689
805
|
throw parseFailure("The answer carried its rows in a shape this server cannot read.", { url });
|
|
690
806
|
}
|
|
691
807
|
return root.results.map(asObject).filter((r) => r !== null);
|
|
@@ -695,9 +811,15 @@ function sourceUrlOf(row, identifier) {
|
|
|
695
811
|
const raw = asString(row[ROW_FIELD.url]) ?? asString(row[ROW_FIELD.id]);
|
|
696
812
|
if (raw) {
|
|
697
813
|
const trimmed = raw.trim();
|
|
698
|
-
if (trimmed.startsWith("//"))
|
|
699
|
-
|
|
700
|
-
|
|
814
|
+
if (trimmed.startsWith("//")) {
|
|
815
|
+
return `https:${trimmed}`;
|
|
816
|
+
}
|
|
817
|
+
if (trimmed.startsWith("http://")) {
|
|
818
|
+
return `https://${trimmed.slice("http://".length)}`;
|
|
819
|
+
}
|
|
820
|
+
if (trimmed.startsWith("https://")) {
|
|
821
|
+
return trimmed;
|
|
822
|
+
}
|
|
701
823
|
}
|
|
702
824
|
return identifier === null ? "" : itemUrl(identifier);
|
|
703
825
|
}
|
|
@@ -739,7 +861,9 @@ function toSearchResults(payload, url, onSkip, settled = true) {
|
|
|
739
861
|
sourceUrl: source
|
|
740
862
|
});
|
|
741
863
|
}
|
|
742
|
-
if (skipped > 0)
|
|
864
|
+
if (skipped > 0) {
|
|
865
|
+
onSkip(skipped);
|
|
866
|
+
}
|
|
743
867
|
if (rows.length > 0 && records.length === 0) {
|
|
744
868
|
throw parseFailure(`${rows.length} rows came back and none could be read.`, { url });
|
|
745
869
|
}
|
|
@@ -773,12 +897,16 @@ function queryTerms(query) {
|
|
|
773
897
|
const phrases = query.match(/"([^"]+)"/g) ?? [];
|
|
774
898
|
for (const phrase of phrases) {
|
|
775
899
|
const inner = phrase.slice(1, -1).trim().toLowerCase();
|
|
776
|
-
if (inner !== "")
|
|
900
|
+
if (inner !== "") {
|
|
901
|
+
terms.push(inner);
|
|
902
|
+
}
|
|
777
903
|
}
|
|
778
904
|
const rest = query.replace(/"[^"]*"/g, " ");
|
|
779
|
-
for (const word of rest.split(
|
|
905
|
+
for (const word of rest.split(WORD_SEPARATORS)) {
|
|
780
906
|
const lower = word.trim().toLowerCase();
|
|
781
|
-
if (lower.length < 3 || JOINING_WORDS.has(lower))
|
|
907
|
+
if (lower.length < 3 || JOINING_WORDS.has(lower)) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
782
910
|
terms.push(lower);
|
|
783
911
|
}
|
|
784
912
|
return [...new Set(terms)];
|
|
@@ -792,21 +920,27 @@ function toWordBoundary(text, index, direction) {
|
|
|
792
920
|
const limit = direction === -1 ? 0 : text.length;
|
|
793
921
|
let at = index;
|
|
794
922
|
for (let step = 0; step < 30 && at !== limit; step += 1) {
|
|
795
|
-
if (text[at] === " ")
|
|
923
|
+
if (text[at] === " ") {
|
|
924
|
+
return at;
|
|
925
|
+
}
|
|
796
926
|
at += direction;
|
|
797
927
|
}
|
|
798
928
|
return index;
|
|
799
929
|
}
|
|
800
930
|
function excerptsFor(text, terms, budget) {
|
|
801
931
|
const clean = text.replace(/\s+/g, " ").trim();
|
|
802
|
-
if (clean === "")
|
|
932
|
+
if (clean === "") {
|
|
933
|
+
return { passages: [], located: false };
|
|
934
|
+
}
|
|
803
935
|
const found = [];
|
|
804
936
|
for (const term of terms) {
|
|
805
937
|
const pattern = wholeWord(term);
|
|
806
938
|
let match = pattern.exec(clean);
|
|
807
939
|
while (match !== null) {
|
|
808
940
|
found.push({ at: match.index, length: match[0].length });
|
|
809
|
-
if (match[0].length === 0)
|
|
941
|
+
if (match[0].length === 0) {
|
|
942
|
+
pattern.lastIndex += 1;
|
|
943
|
+
}
|
|
810
944
|
match = pattern.exec(clean);
|
|
811
945
|
}
|
|
812
946
|
}
|
|
@@ -824,7 +958,7 @@ function excerptsFor(text, terms, budget) {
|
|
|
824
958
|
const half = Math.max(0, Math.floor((budget.maxChars - hit.length) / 2));
|
|
825
959
|
const start = toWordBoundary(clean, Math.max(0, hit.at - half), -1);
|
|
826
960
|
const end = toWordBoundary(clean, Math.min(clean.length, hit.at + hit.length + half), 1);
|
|
827
|
-
const previous = windows
|
|
961
|
+
const previous = windows.at(-1);
|
|
828
962
|
if (previous && start <= previous.end) {
|
|
829
963
|
previous.end = Math.max(previous.end, end);
|
|
830
964
|
continue;
|
|
@@ -839,7 +973,8 @@ function excerptsFor(text, terms, budget) {
|
|
|
839
973
|
});
|
|
840
974
|
return { passages, located: true };
|
|
841
975
|
}
|
|
842
|
-
function toNewspaperResults(payload, url, query,
|
|
976
|
+
function toNewspaperResults(payload, url, query, reading) {
|
|
977
|
+
const { budget, onSkip, settled = true } = reading;
|
|
843
978
|
const rows = rowsOf(payload, url);
|
|
844
979
|
const terms = queryTerms(query);
|
|
845
980
|
const hits = [];
|
|
@@ -871,18 +1006,24 @@ function toNewspaperResults(payload, url, query, budget, onSkip, settled = true)
|
|
|
871
1006
|
sourceUrl: source
|
|
872
1007
|
});
|
|
873
1008
|
}
|
|
874
|
-
if (skipped > 0)
|
|
1009
|
+
if (skipped > 0) {
|
|
1010
|
+
onSkip(skipped);
|
|
1011
|
+
}
|
|
875
1012
|
if (rows.length > 0 && hits.length === 0) {
|
|
876
1013
|
throw parseFailure(`${rows.length} matches came back and none could be read.`, { url });
|
|
877
1014
|
}
|
|
878
1015
|
return { paging: toPaging(payload, url, settled), hits };
|
|
879
1016
|
}
|
|
880
1017
|
function toResources(value) {
|
|
881
|
-
if (!Array.isArray(value))
|
|
1018
|
+
if (!Array.isArray(value)) {
|
|
1019
|
+
return [];
|
|
1020
|
+
}
|
|
882
1021
|
const resources = [];
|
|
883
1022
|
for (const entry of value) {
|
|
884
1023
|
const resource = asObject(entry);
|
|
885
|
-
if (!resource)
|
|
1024
|
+
if (!resource) {
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
886
1027
|
const files = resource.files;
|
|
887
1028
|
const fileCount = Array.isArray(files) ? files.reduce((sum, group) => sum + (Array.isArray(group) ? group.length : 1), 0) : asNumber(files);
|
|
888
1029
|
resources.push({
|
|
@@ -896,17 +1037,23 @@ function toResources(value) {
|
|
|
896
1037
|
}
|
|
897
1038
|
function toCitations(value) {
|
|
898
1039
|
const block = asObject(value);
|
|
899
|
-
if (!block)
|
|
1040
|
+
if (!block) {
|
|
1041
|
+
return {};
|
|
1042
|
+
}
|
|
900
1043
|
const citations = {};
|
|
901
1044
|
for (const [style, text] of Object.entries(block)) {
|
|
902
|
-
if (typeof text !== "string" || text.trim() === "")
|
|
1045
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
903
1048
|
citations[style] = plainText(text);
|
|
904
1049
|
}
|
|
905
1050
|
return citations;
|
|
906
1051
|
}
|
|
907
1052
|
function toItemDetail(payload, identifier, url) {
|
|
908
1053
|
const root = asObject(payload);
|
|
909
|
-
if (!root)
|
|
1054
|
+
if (!root) {
|
|
1055
|
+
throw parseFailure("The item answer was not an object.", { url });
|
|
1056
|
+
}
|
|
910
1057
|
if (asNumber(root.status) === 404) {
|
|
911
1058
|
throw notFound(`The Library of Congress has no record called "${identifier}".`, { url });
|
|
912
1059
|
}
|
|
@@ -977,7 +1124,9 @@ function toCollections(payload, url, onSkip, settled = true) {
|
|
|
977
1124
|
itemsUrl: asString(row[ROW_FIELD.items])
|
|
978
1125
|
});
|
|
979
1126
|
}
|
|
980
|
-
if (skipped > 0)
|
|
1127
|
+
if (skipped > 0) {
|
|
1128
|
+
onSkip(skipped);
|
|
1129
|
+
}
|
|
981
1130
|
if (rows.length > 0 && collections.length === 0) {
|
|
982
1131
|
throw parseFailure(`${rows.length} collections came back and none could be read.`, { url });
|
|
983
1132
|
}
|
|
@@ -1020,7 +1169,9 @@ var RateLimiter = class {
|
|
|
1020
1169
|
async beforeRequest() {
|
|
1021
1170
|
const elapsed = Date.now() - this.lastStartedAt;
|
|
1022
1171
|
const wait = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
1023
|
-
if (wait > 0)
|
|
1172
|
+
if (wait > 0) {
|
|
1173
|
+
await sleep(wait);
|
|
1174
|
+
}
|
|
1024
1175
|
this.lastStartedAt = Date.now();
|
|
1025
1176
|
}
|
|
1026
1177
|
/** Called when the site asks for room: double the gap, up to the ceiling. */
|
|
@@ -1033,9 +1184,13 @@ var RateLimiter = class {
|
|
|
1033
1184
|
* response after a rough patch does not undo the caution that earned it.
|
|
1034
1185
|
*/
|
|
1035
1186
|
succeeded() {
|
|
1036
|
-
if (this.intervalMs === this.baseIntervalMs)
|
|
1187
|
+
if (this.intervalMs === this.baseIntervalMs) {
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1037
1190
|
this.calmStreak += 1;
|
|
1038
|
-
if (this.calmStreak < 3)
|
|
1191
|
+
if (this.calmStreak < 3) {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1039
1194
|
this.calmStreak = 0;
|
|
1040
1195
|
this.intervalMs = Math.max(this.baseIntervalMs, Math.round(this.intervalMs / 2));
|
|
1041
1196
|
}
|
|
@@ -1144,7 +1299,7 @@ var LocClient = class {
|
|
|
1144
1299
|
return this.read(
|
|
1145
1300
|
key,
|
|
1146
1301
|
url,
|
|
1147
|
-
(payload, onSkip, settled) => toNewspaperResults(payload, url, trimmed, budget, onSkip, settled),
|
|
1302
|
+
(payload, onSkip, settled) => toNewspaperResults(payload, url, trimmed, { budget, onSkip, settled }),
|
|
1148
1303
|
// Reading the text of millions of pages takes the site far longer than
|
|
1149
1304
|
// answering from the catalogue, so this route carries a budget of its own.
|
|
1150
1305
|
this.config.newspaperTimeoutMs
|
|
@@ -1174,11 +1329,24 @@ import { z as z3 } from "zod";
|
|
|
1174
1329
|
// src/tools/arguments.ts
|
|
1175
1330
|
import { z } from "zod";
|
|
1176
1331
|
var INVALID_INPUT = "invalid_input";
|
|
1332
|
+
var defaultMessage = z.core.locales.en().localeError;
|
|
1177
1333
|
function strictInput(shape) {
|
|
1178
1334
|
const declared = Object.keys(shape);
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1335
|
+
const refuse = (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : `[${INVALID_INPUT}] ${defaultMessage(issue)}`;
|
|
1336
|
+
for (const argument of Object.values(shape)) {
|
|
1337
|
+
carryTheCode(argument, refuse);
|
|
1338
|
+
}
|
|
1339
|
+
return z.strictObject(shape, { error: refuse });
|
|
1340
|
+
}
|
|
1341
|
+
function carryTheCode(schema, refuse) {
|
|
1342
|
+
const definition = schema._zod.def;
|
|
1343
|
+
definition.error = refuse;
|
|
1344
|
+
for (const check of definition.checks ?? []) {
|
|
1345
|
+
check._zod.def.error = refuse;
|
|
1346
|
+
}
|
|
1347
|
+
if (definition.innerType) {
|
|
1348
|
+
carryTheCode(definition.innerType, refuse);
|
|
1349
|
+
}
|
|
1182
1350
|
}
|
|
1183
1351
|
function unknownArgumentMessage(keys, declared) {
|
|
1184
1352
|
const named = keys.map((key) => {
|
|
@@ -1190,15 +1358,21 @@ function unknownArgumentMessage(keys, declared) {
|
|
|
1190
1358
|
function nearestArgument(key, declared) {
|
|
1191
1359
|
const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1192
1360
|
const flat = flatten(key);
|
|
1193
|
-
if (flat.length === 0)
|
|
1361
|
+
if (flat.length === 0) {
|
|
1362
|
+
return void 0;
|
|
1363
|
+
}
|
|
1194
1364
|
const sameName = declared.find((name) => flatten(name) === flat);
|
|
1195
|
-
if (sameName)
|
|
1365
|
+
if (sameName) {
|
|
1366
|
+
return sameName;
|
|
1367
|
+
}
|
|
1196
1368
|
const overlapping = declared.find((name) => {
|
|
1197
1369
|
const other = flatten(name);
|
|
1198
1370
|
const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
|
|
1199
1371
|
return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
|
|
1200
1372
|
});
|
|
1201
|
-
if (overlapping)
|
|
1373
|
+
if (overlapping) {
|
|
1374
|
+
return overlapping;
|
|
1375
|
+
}
|
|
1202
1376
|
let closest;
|
|
1203
1377
|
let shortest = Number.POSITIVE_INFINITY;
|
|
1204
1378
|
for (const name of declared) {
|
|
@@ -1212,19 +1386,38 @@ function nearestArgument(key, declared) {
|
|
|
1212
1386
|
}
|
|
1213
1387
|
function editDistance(left, right) {
|
|
1214
1388
|
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
1389
|
+
let distance = right.length;
|
|
1215
1390
|
for (let row = 1; row <= left.length; row += 1) {
|
|
1216
1391
|
const current = [row];
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1392
|
+
let diagonal = row - 1;
|
|
1393
|
+
let leftward = row;
|
|
1394
|
+
for (const [column, above] of previous.entries()) {
|
|
1395
|
+
if (column === 0) {
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
const substitution = diagonal + (left[row - 1] === right[column - 1] ? 0 : 1);
|
|
1399
|
+
const cell = Math.min(substitution, above + 1, leftward + 1);
|
|
1400
|
+
current.push(cell);
|
|
1401
|
+
diagonal = above;
|
|
1402
|
+
leftward = cell;
|
|
1220
1403
|
}
|
|
1221
1404
|
previous = current;
|
|
1405
|
+
distance = leftward;
|
|
1222
1406
|
}
|
|
1223
|
-
return
|
|
1407
|
+
return distance;
|
|
1224
1408
|
}
|
|
1225
1409
|
|
|
1226
1410
|
// src/tools/shared.ts
|
|
1227
1411
|
import { z as z2 } from "zod";
|
|
1412
|
+
function identifierLine(record) {
|
|
1413
|
+
if (record.identifier) {
|
|
1414
|
+
return `\xB7 id: ${record.identifier}`;
|
|
1415
|
+
}
|
|
1416
|
+
if (record.is_collection) {
|
|
1417
|
+
return "\xB7 a collection, no identifier";
|
|
1418
|
+
}
|
|
1419
|
+
return "\xB7 no identifier";
|
|
1420
|
+
}
|
|
1228
1421
|
var MAX_TEXT_CHARS = 2200;
|
|
1229
1422
|
var ATTRIBUTION = "Source: Library of Congress";
|
|
1230
1423
|
var recordSchema = z2.object({
|
|
@@ -1253,7 +1446,9 @@ function indentMarkerLines(body) {
|
|
|
1253
1446
|
function ok(structured, body, options = {}) {
|
|
1254
1447
|
const credit = options.sourceUrl ? `${ATTRIBUTION} \u2014 ${options.sourceUrl}` : ATTRIBUTION;
|
|
1255
1448
|
const noteLines = (options.notes ?? []).map((note) => `Note: ${note}`);
|
|
1256
|
-
while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_CHARS / 2)
|
|
1449
|
+
while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_CHARS / 2) {
|
|
1450
|
+
noteLines.pop();
|
|
1451
|
+
}
|
|
1257
1452
|
const trailer = [...noteLines, credit].join("\n");
|
|
1258
1453
|
const cut = "\n\n[shortened; the full result is in the structured output]";
|
|
1259
1454
|
const budget = MAX_TEXT_CHARS - `
|
|
@@ -1270,7 +1465,9 @@ ${trailer}`;
|
|
|
1270
1465
|
function toToolError(error) {
|
|
1271
1466
|
const known = error instanceof LocError ? error : new LocError("network_error", error instanceof Error ? error.message : String(error));
|
|
1272
1467
|
const lines = [`[${known.code}] ${known.message}`];
|
|
1273
|
-
if (known.details.hint)
|
|
1468
|
+
if (known.details.hint) {
|
|
1469
|
+
lines.push(`Hint: ${known.details.hint}`);
|
|
1470
|
+
}
|
|
1274
1471
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
|
|
1275
1472
|
}
|
|
1276
1473
|
function agrees(n, singular, plural) {
|
|
@@ -1280,17 +1477,23 @@ function counted(n, singular, plural = `${singular}s`) {
|
|
|
1280
1477
|
return `${n} ${agrees(n, singular, plural)}`;
|
|
1281
1478
|
}
|
|
1282
1479
|
function truncate(text, maxChars) {
|
|
1283
|
-
if (text.length <= maxChars)
|
|
1480
|
+
if (text.length <= maxChars) {
|
|
1481
|
+
return text;
|
|
1482
|
+
}
|
|
1284
1483
|
return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
|
|
1285
1484
|
}
|
|
1286
1485
|
function sliceAtLineBoundary(text, offset, maxChars) {
|
|
1287
1486
|
const rest = text.slice(offset);
|
|
1288
|
-
if (rest.length <= maxChars)
|
|
1487
|
+
if (rest.length <= maxChars) {
|
|
1488
|
+
return { slice: rest, nextOffset: null };
|
|
1489
|
+
}
|
|
1289
1490
|
const window = rest.slice(0, maxChars);
|
|
1290
1491
|
const lastBreak = window.lastIndexOf("\n");
|
|
1291
1492
|
let cut = lastBreak > 0 ? lastBreak : maxChars;
|
|
1292
1493
|
const code = rest.charCodeAt(cut - 1);
|
|
1293
|
-
if (code >= 55296 && code <= 56319)
|
|
1494
|
+
if (code >= 55296 && code <= 56319) {
|
|
1495
|
+
cut -= 1;
|
|
1496
|
+
}
|
|
1294
1497
|
return { slice: rest.slice(0, cut), nextOffset: offset + cut };
|
|
1295
1498
|
}
|
|
1296
1499
|
function renderRecords(records) {
|
|
@@ -1303,7 +1506,7 @@ function renderRecords(records) {
|
|
|
1303
1506
|
// An identifier a row does not carry is stated rather than left out: a
|
|
1304
1507
|
// line with nothing where the others carry an id reads as a line whose
|
|
1305
1508
|
// id went unprinted.
|
|
1306
|
-
record
|
|
1509
|
+
identifierLine(record)
|
|
1307
1510
|
];
|
|
1308
1511
|
return `${bits.filter(Boolean).join(" ")}
|
|
1309
1512
|
${record.source_url}`;
|
|
@@ -1313,6 +1516,45 @@ var OCR_CAVEAT = "Excerpts are the text a machine read off the scanned page, so
|
|
|
1313
1516
|
var RIGHTS_CAVEAT = "The Library states no terms of use on this record. Silence is not permission: check the record's page before republishing.";
|
|
1314
1517
|
|
|
1315
1518
|
// src/tools/getItem.ts
|
|
1519
|
+
function attachAskedSections(structured, data, wanted, notes) {
|
|
1520
|
+
if (wanted.has("citations")) {
|
|
1521
|
+
structured.citations = data.citations;
|
|
1522
|
+
if (Object.keys(data.citations).length === 0) {
|
|
1523
|
+
notes.push("The Library publishes no ready-made citation for this record.");
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (wanted.has("resources")) {
|
|
1527
|
+
structured.resources = data.resources.map((resource) => ({
|
|
1528
|
+
caption: resource.caption,
|
|
1529
|
+
file_count: resource.fileCount,
|
|
1530
|
+
url: resource.url,
|
|
1531
|
+
image_url: resource.imageUrl
|
|
1532
|
+
}));
|
|
1533
|
+
if (data.resources.length === 0) {
|
|
1534
|
+
notes.push("The Library serves no copy of this record online.");
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
if (wanted.has("full_metadata")) {
|
|
1538
|
+
structured.full_metadata = data.raw ?? {};
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
function notesOnWhatTheRecordStates(data) {
|
|
1542
|
+
const notes = [];
|
|
1543
|
+
if (data.dateIsSpanOpening) {
|
|
1544
|
+
notes.push(
|
|
1545
|
+
`The record states its date as "${data.dateStated}". ${data.date} is the opening of that span and where the Library files the record, and 'year' reads the same value, so neither is a date the record carries.`
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
if (data.dateCode !== null) {
|
|
1549
|
+
notes.push(
|
|
1550
|
+
`The Library files this record under "${data.dateCode}", a cataloguing code standing for digits it has not established rather than a date, so 'date' and 'year' are null. 'date_stated' repeats what the record itself says about when it was made.`
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
if (!data.rights) {
|
|
1554
|
+
notes.push(RIGHTS_CAVEAT);
|
|
1555
|
+
}
|
|
1556
|
+
return notes;
|
|
1557
|
+
}
|
|
1316
1558
|
var SECTIONS = ["basic", "citations", "resources", "full_metadata"];
|
|
1317
1559
|
var getItemDescription = [
|
|
1318
1560
|
"Read one Library of Congress record by its identifier, as returned by search_items or search_newspapers.",
|
|
@@ -1380,7 +1622,9 @@ async function runGetItem(client, args) {
|
|
|
1380
1622
|
const wanted = new Set(args.sections);
|
|
1381
1623
|
const { data, cached } = await client.getItem(args.identifier);
|
|
1382
1624
|
const notes = [];
|
|
1383
|
-
if (cached)
|
|
1625
|
+
if (cached) {
|
|
1626
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1627
|
+
}
|
|
1384
1628
|
const full = data.description ?? "";
|
|
1385
1629
|
const notesOnRecord = data.notes.filter((note) => !full.includes(note));
|
|
1386
1630
|
const { slice, nextOffset } = sliceAtLineBoundary(
|
|
@@ -1423,35 +1667,8 @@ async function runGetItem(client, args) {
|
|
|
1423
1667
|
rights: data.rights,
|
|
1424
1668
|
notes
|
|
1425
1669
|
};
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if (Object.keys(data.citations).length === 0) {
|
|
1429
|
-
notes.push("The Library publishes no ready-made citation for this record.");
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
if (wanted.has("resources")) {
|
|
1433
|
-
structured.resources = data.resources.map((resource) => ({
|
|
1434
|
-
caption: resource.caption,
|
|
1435
|
-
file_count: resource.fileCount,
|
|
1436
|
-
url: resource.url,
|
|
1437
|
-
image_url: resource.imageUrl
|
|
1438
|
-
}));
|
|
1439
|
-
if (data.resources.length === 0) {
|
|
1440
|
-
notes.push("The Library serves no copy of this record online.");
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
if (wanted.has("full_metadata")) structured.full_metadata = data.raw ?? {};
|
|
1444
|
-
if (data.dateIsSpanOpening) {
|
|
1445
|
-
notes.push(
|
|
1446
|
-
`The record states its date as "${data.dateStated}". ${data.date} is the opening of that span and where the Library files the record, and 'year' reads the same value, so neither is a date the record carries.`
|
|
1447
|
-
);
|
|
1448
|
-
}
|
|
1449
|
-
if (data.dateCode !== null) {
|
|
1450
|
-
notes.push(
|
|
1451
|
-
`The Library files this record under "${data.dateCode}", a cataloguing code standing for digits it has not established rather than a date, so 'date' and 'year' are null. 'date_stated' repeats what the record itself says about when it was made.`
|
|
1452
|
-
);
|
|
1453
|
-
}
|
|
1454
|
-
if (!data.rights) notes.push(RIGHTS_CAVEAT);
|
|
1670
|
+
attachAskedSections(structured, data, wanted, notes);
|
|
1671
|
+
notes.push(...notesOnWhatTheRecordStates(data));
|
|
1455
1672
|
const lines = [
|
|
1456
1673
|
[data.title ?? data.identifier, data.date ? `(${data.date})` : ""].filter(Boolean).join(" "),
|
|
1457
1674
|
data.creator ? `By ${data.creator}` : "",
|
|
@@ -1461,7 +1678,9 @@ async function runGetItem(client, args) {
|
|
|
1461
1678
|
data.repository ? `Held at: ${data.repository}` : "",
|
|
1462
1679
|
data.rights ? `Rights: ${data.rights}` : ""
|
|
1463
1680
|
].filter(Boolean);
|
|
1464
|
-
if (slice !== "")
|
|
1681
|
+
if (slice !== "") {
|
|
1682
|
+
lines.push("", slice);
|
|
1683
|
+
}
|
|
1465
1684
|
const resources = structured.resources;
|
|
1466
1685
|
if (resources && resources.length > 0) {
|
|
1467
1686
|
lines.push("", "Copies served online:");
|
|
@@ -1477,6 +1696,15 @@ async function runGetItem(client, args) {
|
|
|
1477
1696
|
|
|
1478
1697
|
// src/tools/listCollections.ts
|
|
1479
1698
|
import { z as z4 } from "zod";
|
|
1699
|
+
function nothingOnThisPage(read, total, page) {
|
|
1700
|
+
if (read > 0) {
|
|
1701
|
+
return `None of the ${counted(read, "collection")} on page ${page} publishes a format naming a media_type.`;
|
|
1702
|
+
}
|
|
1703
|
+
if (total > 0) {
|
|
1704
|
+
return `Page ${page} is past the last of ${counted(total, "collection")}.`;
|
|
1705
|
+
}
|
|
1706
|
+
return "The Library published no collection here.";
|
|
1707
|
+
}
|
|
1480
1708
|
var MAX_COLLECTION_PAGE = 100;
|
|
1481
1709
|
var listCollectionsDescription = [
|
|
1482
1710
|
"List the digital collections of the Library of Congress: bodies of material a curator chose, described and published together.",
|
|
@@ -1520,7 +1748,9 @@ async function askCollections(client, limit, page) {
|
|
|
1520
1748
|
try {
|
|
1521
1749
|
return await client.listCollections(limit, page);
|
|
1522
1750
|
} catch (error) {
|
|
1523
|
-
if (!(error instanceof LocError) || error.code !== "not_found" || page <= 1)
|
|
1751
|
+
if (!(error instanceof LocError) || error.code !== "not_found" || page <= 1) {
|
|
1752
|
+
throw error;
|
|
1753
|
+
}
|
|
1524
1754
|
const first = await client.listCollections(limit, 1);
|
|
1525
1755
|
return { data: { paging: first.data.paging, collections: [] }, cached: first.cached };
|
|
1526
1756
|
}
|
|
@@ -1529,7 +1759,9 @@ async function runListCollections(client, args) {
|
|
|
1529
1759
|
try {
|
|
1530
1760
|
const { data, cached, skipped } = await askCollections(client, args.limit, args.page);
|
|
1531
1761
|
const notes = [];
|
|
1532
|
-
if (cached)
|
|
1762
|
+
if (cached) {
|
|
1763
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1764
|
+
}
|
|
1533
1765
|
if (skipped) {
|
|
1534
1766
|
notes.push(
|
|
1535
1767
|
`${counted(skipped, "row")} came back in a shape this server could not read and ${agrees(skipped, "was", "were")} left out.`
|
|
@@ -1582,7 +1814,7 @@ async function runListCollections(client, args) {
|
|
|
1582
1814
|
`Page ${args.page} is past the last collection. ${total} ${agrees(total, "exists", "exist")}, so ask for a lower page.`
|
|
1583
1815
|
);
|
|
1584
1816
|
}
|
|
1585
|
-
const body = collections.length === 0 ?
|
|
1817
|
+
const body = collections.length === 0 ? nothingOnThisPage(read.length, total, args.page) : `${collections.length} of ${counted(total, "collection")}:
|
|
1586
1818
|
` + collections.map((collection, index) => {
|
|
1587
1819
|
const head = [
|
|
1588
1820
|
`${index + 1}. ${collection.title}`,
|
|
@@ -1600,6 +1832,61 @@ async function runListCollections(client, args) {
|
|
|
1600
1832
|
|
|
1601
1833
|
// src/tools/searchItems.ts
|
|
1602
1834
|
import { z as z5 } from "zod";
|
|
1835
|
+
function notesOnWhatTheSearchReached(data, args, items, total, narrowing) {
|
|
1836
|
+
const notes = [];
|
|
1837
|
+
if (total > items.length) {
|
|
1838
|
+
notes.push(
|
|
1839
|
+
narrowing.setAside ? `${counted(total, "record")} match the search${narrowing.without}, and ${items.length} ${agrees(items.length, "is", "are")} shown.` : `${counted(total, "record")} match and ${items.length} ${agrees(items.length, "is", "are")} shown.`
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
const gathered = items.filter((item) => item.is_collection).length;
|
|
1843
|
+
if (gathered > 0) {
|
|
1844
|
+
notes.push(
|
|
1845
|
+
`${gathered} of the ${counted(items.length, "row")} shown ${agrees(gathered, "is a collection", "are collections")} the Library gathered and named rather than ${agrees(gathered, "a record", "records")} of the ${args.media_type} catalogue: 'identifier' is null there, get_item has nothing to take, and 'source_url' opens the collection. The count beside the rows is the catalogue's own, and it counts them in.`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
const anonymous = items.filter((item) => item.identifier === null && !item.is_collection).length;
|
|
1849
|
+
if (anonymous > 0) {
|
|
1850
|
+
notes.push(
|
|
1851
|
+
`${counted(anonymous, "row")} shown ${agrees(anonymous, "names", "name")} an address that is not a record: 'identifier' is null there and get_item has nothing to take, so ${agrees(anonymous, "read it at its", "read them at their")} 'source_url'.`
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
const coded = items.filter((item) => item.date_code !== null);
|
|
1855
|
+
if (coded.length > 0) {
|
|
1856
|
+
const codes = [...new Set(coded.map((item) => item.date_code))].join(", ");
|
|
1857
|
+
notes.push(
|
|
1858
|
+
`${counted(coded.length, "row")} shown ${agrees(coded.length, "carries", "carry")} no date: the Library files ${agrees(coded.length, "it", "them")} under a cataloguing code standing for digits it has not established, so 'date' and 'year' are null there while 'date_code' holds the code it filed ${agrees(coded.length, "it", "them")} under: ${codes}.`
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
if (total === 0) {
|
|
1862
|
+
notes.push(
|
|
1863
|
+
`Nothing in the ${args.media_type} catalogue matches. A search here reads titles and catalogue descriptions only, so a phrase printed inside a newspaper belongs in search_newspapers.`
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
if (items.length === 0 && total > 0) {
|
|
1867
|
+
const pages = data.paging.pageCount;
|
|
1868
|
+
notes.push(
|
|
1869
|
+
`Page ${args.page} is past the last row. ${counted(total, "record")} match${pages === null ? "" : ` across ${counted(pages, "page")} at this page size`}, so ask for a lower page.`
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
if (!narrowing.setAside && (args.year_from !== void 0 || args.year_to !== void 0)) {
|
|
1873
|
+
notes.push(
|
|
1874
|
+
"A record spanning several years matches on any of them, so a row can carry a date outside the range asked for while still belonging to it."
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
if (!args.online_only && items.length > 0) {
|
|
1878
|
+
notes.push(
|
|
1879
|
+
"This search took in records with no digitised copy. Read 'online' on a row before promising something can be read from a browser."
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
return notes;
|
|
1883
|
+
}
|
|
1884
|
+
function nothingOnThisPage2(total, page, mediaType, query) {
|
|
1885
|
+
if (total > 0) {
|
|
1886
|
+
return `Page ${page} is past the last of ${counted(total, "record")} the ${mediaType} catalogue holds for "${query}".`;
|
|
1887
|
+
}
|
|
1888
|
+
return `Nothing in the ${mediaType} catalogue for "${query}".`;
|
|
1889
|
+
}
|
|
1603
1890
|
var searchItemsDescription = [
|
|
1604
1891
|
"Search the Library of Congress catalogue: books, photographs, maps, recordings, films, manuscripts, sheet music and newspaper titles.",
|
|
1605
1892
|
"'media_type' is required, because the Library keeps a separate catalogue for each kind of thing.",
|
|
@@ -1641,10 +1928,18 @@ var searchItemsOutput = z5.object({
|
|
|
1641
1928
|
});
|
|
1642
1929
|
function describeNarrowing(args) {
|
|
1643
1930
|
const written = [];
|
|
1644
|
-
if (args.subject)
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
if (args.
|
|
1931
|
+
if (args.subject) {
|
|
1932
|
+
written.push(`subject="${args.subject}"`);
|
|
1933
|
+
}
|
|
1934
|
+
if (args.location) {
|
|
1935
|
+
written.push(`location="${args.location}"`);
|
|
1936
|
+
}
|
|
1937
|
+
if (args.language) {
|
|
1938
|
+
written.push(`language="${args.language}"`);
|
|
1939
|
+
}
|
|
1940
|
+
if (args.collection) {
|
|
1941
|
+
written.push(`collection="${args.collection}"`);
|
|
1942
|
+
}
|
|
1648
1943
|
if (args.year_from !== void 0 || args.year_to !== void 0) {
|
|
1649
1944
|
written.push(`years ${args.year_from ?? "any"} to ${args.year_to ?? "any"}`);
|
|
1650
1945
|
}
|
|
@@ -1652,9 +1947,15 @@ function describeNarrowing(args) {
|
|
|
1652
1947
|
}
|
|
1653
1948
|
function whereTheWordingIs(args) {
|
|
1654
1949
|
const advice = [];
|
|
1655
|
-
if (args.subject)
|
|
1656
|
-
|
|
1657
|
-
|
|
1950
|
+
if (args.subject) {
|
|
1951
|
+
advice.push("a subject is spelled as 'subjects' spells it on a row");
|
|
1952
|
+
}
|
|
1953
|
+
if (args.location) {
|
|
1954
|
+
advice.push("a place is spelled as 'location' spells it on a row");
|
|
1955
|
+
}
|
|
1956
|
+
if (args.language) {
|
|
1957
|
+
advice.push('a language is written in English, as in "english"');
|
|
1958
|
+
}
|
|
1658
1959
|
if (args.collection) {
|
|
1659
1960
|
advice.push("a collection is spelled as list_collections reports it under 'collection_filter'");
|
|
1660
1961
|
}
|
|
@@ -1666,14 +1967,16 @@ async function askCatalogue(client, query, page) {
|
|
|
1666
1967
|
try {
|
|
1667
1968
|
return await client.searchItems(query);
|
|
1668
1969
|
} catch (error) {
|
|
1669
|
-
if (!(error instanceof LocError) || error.code !== "not_found" || page <= 1)
|
|
1970
|
+
if (!(error instanceof LocError) || error.code !== "not_found" || page <= 1) {
|
|
1971
|
+
throw error;
|
|
1972
|
+
}
|
|
1670
1973
|
const first = await client.searchItems({ ...query, page: 1 });
|
|
1671
1974
|
return { data: { paging: first.data.paging, records: [] }, cached: first.cached };
|
|
1672
1975
|
}
|
|
1673
1976
|
}
|
|
1674
1977
|
async function runSearchItems(client, args) {
|
|
1675
1978
|
try {
|
|
1676
|
-
if (!INDEXED_WORD.test(args.query)
|
|
1979
|
+
if (!(INDEXED_WORD.test(args.query) || ONE_CHARACTER_WORD.test(args.query))) {
|
|
1677
1980
|
return toToolError(
|
|
1678
1981
|
invalidInput(
|
|
1679
1982
|
`Every word of "${args.query}" is a single letter, and the catalogue index holds no such word, so this query is one the index cannot be asked.`,
|
|
@@ -1691,7 +1994,9 @@ async function runSearchItems(client, args) {
|
|
|
1691
1994
|
}
|
|
1692
1995
|
const facets = {};
|
|
1693
1996
|
const put = (field, value) => {
|
|
1694
|
-
if (value && value.trim() !== "")
|
|
1997
|
+
if (value && value.trim() !== "") {
|
|
1998
|
+
facets[field] = value;
|
|
1999
|
+
}
|
|
1695
2000
|
};
|
|
1696
2001
|
put("subject", args.subject);
|
|
1697
2002
|
put("location", args.location);
|
|
@@ -1709,8 +2014,8 @@ async function runSearchItems(client, args) {
|
|
|
1709
2014
|
const narrowed = {
|
|
1710
2015
|
...wide,
|
|
1711
2016
|
facets,
|
|
1712
|
-
...args.year_from
|
|
1713
|
-
...args.year_to
|
|
2017
|
+
...args.year_from === void 0 ? {} : { yearFrom: args.year_from },
|
|
2018
|
+
...args.year_to === void 0 ? {} : { yearTo: args.year_to }
|
|
1714
2019
|
};
|
|
1715
2020
|
const notes = [];
|
|
1716
2021
|
let result = await askCatalogue(client, narrowed, args.page);
|
|
@@ -1727,7 +2032,9 @@ async function runSearchItems(client, args) {
|
|
|
1727
2032
|
}
|
|
1728
2033
|
}
|
|
1729
2034
|
const { data, cached, skipped } = result;
|
|
1730
|
-
if (cached)
|
|
2035
|
+
if (cached) {
|
|
2036
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
2037
|
+
}
|
|
1731
2038
|
if (skipped) {
|
|
1732
2039
|
notes.push(
|
|
1733
2040
|
`${counted(skipped, "row")} came back in a shape this server could not read and ${agrees(skipped, "was", "were")} left out.`
|
|
@@ -1749,54 +2056,13 @@ async function runSearchItems(client, args) {
|
|
|
1749
2056
|
}));
|
|
1750
2057
|
const total = data.paging.resultCount;
|
|
1751
2058
|
const withoutNarrowing = narrowingSetAside ? ` without ${narrowing.join(", ")}` : "";
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
notes.push(
|
|
1760
|
-
`${gathered} of the ${counted(items.length, "row")} shown ${agrees(gathered, "is a collection", "are collections")} the Library gathered and named rather than ${agrees(gathered, "a record", "records")} of the ${args.media_type} catalogue: 'identifier' is null there, get_item has nothing to take, and 'source_url' opens the collection. The count beside the rows is the catalogue's own, and it counts them in.`
|
|
1761
|
-
);
|
|
1762
|
-
}
|
|
1763
|
-
const anonymous = items.filter(
|
|
1764
|
-
(item) => item.identifier === null && !item.is_collection
|
|
1765
|
-
).length;
|
|
1766
|
-
if (anonymous > 0) {
|
|
1767
|
-
notes.push(
|
|
1768
|
-
`${counted(anonymous, "row")} shown ${agrees(anonymous, "names", "name")} an address that is not a record: 'identifier' is null there and get_item has nothing to take, so ${agrees(anonymous, "read it at its", "read them at their")} 'source_url'.`
|
|
1769
|
-
);
|
|
1770
|
-
}
|
|
1771
|
-
const coded = items.filter((item) => item.date_code !== null);
|
|
1772
|
-
if (coded.length > 0) {
|
|
1773
|
-
const codes = [...new Set(coded.map((item) => item.date_code))].join(", ");
|
|
1774
|
-
notes.push(
|
|
1775
|
-
`${counted(coded.length, "row")} shown ${agrees(coded.length, "carries", "carry")} no date: the Library files ${agrees(coded.length, "it", "them")} under a cataloguing code standing for digits it has not established, so 'date' and 'year' are null there while 'date_code' holds the code it filed ${agrees(coded.length, "it", "them")} under: ${codes}.`
|
|
1776
|
-
);
|
|
1777
|
-
}
|
|
1778
|
-
if (total === 0) {
|
|
1779
|
-
notes.push(
|
|
1780
|
-
`Nothing in the ${args.media_type} catalogue matches. A search here reads titles and catalogue descriptions only, so a phrase printed inside a newspaper belongs in search_newspapers.`
|
|
1781
|
-
);
|
|
1782
|
-
}
|
|
1783
|
-
if (items.length === 0 && total > 0) {
|
|
1784
|
-
const pages = data.paging.pageCount;
|
|
1785
|
-
notes.push(
|
|
1786
|
-
`Page ${args.page} is past the last row. ${counted(total, "record")} match${pages === null ? "" : ` across ${counted(pages, "page")} at this page size`}, so ask for a lower page.`
|
|
1787
|
-
);
|
|
1788
|
-
}
|
|
1789
|
-
if (!narrowingSetAside && (args.year_from !== void 0 || args.year_to !== void 0)) {
|
|
1790
|
-
notes.push(
|
|
1791
|
-
"A record spanning several years matches on any of them, so a row can carry a date outside the range asked for while still belonging to it."
|
|
1792
|
-
);
|
|
1793
|
-
}
|
|
1794
|
-
if (!args.online_only && items.length > 0) {
|
|
1795
|
-
notes.push(
|
|
1796
|
-
"This search took in records with no digitised copy. Read 'online' on a row before promising something can be read from a browser."
|
|
1797
|
-
);
|
|
1798
|
-
}
|
|
1799
|
-
const body = items.length === 0 ? total > 0 ? `Page ${args.page} is past the last of ${counted(total, "record")} the ${args.media_type} catalogue holds for "${args.query}".` : `Nothing in the ${args.media_type} catalogue for "${args.query}".` : `${items.length} of ${counted(total, "record")} for "${args.query}"${withoutNarrowing}:
|
|
2059
|
+
notes.push(
|
|
2060
|
+
...notesOnWhatTheSearchReached(data, args, items, total, {
|
|
2061
|
+
setAside: narrowingSetAside,
|
|
2062
|
+
without: withoutNarrowing
|
|
2063
|
+
})
|
|
2064
|
+
);
|
|
2065
|
+
const body = items.length === 0 ? nothingOnThisPage2(total, args.page, args.media_type, args.query) : `${items.length} of ${counted(total, "record")} for "${args.query}"${withoutNarrowing}:
|
|
1800
2066
|
${renderRecords(items)}`;
|
|
1801
2067
|
return ok({ query: args.query, total, page: args.page, items, notes }, body, { notes });
|
|
1802
2068
|
} catch (error) {
|
|
@@ -1806,6 +2072,12 @@ ${renderRecords(items)}`;
|
|
|
1806
2072
|
|
|
1807
2073
|
// src/tools/searchNewspapers.ts
|
|
1808
2074
|
import { z as z6 } from "zod";
|
|
2075
|
+
function nothingOnThisPage3(total, page, query) {
|
|
2076
|
+
if (total > 0) {
|
|
2077
|
+
return `Page ${page} is past the last of ${counted(total, "newspaper page")} the Library matched for ${query}.`;
|
|
2078
|
+
}
|
|
2079
|
+
return `Nothing found in the scanned newspapers for ${query}.`;
|
|
2080
|
+
}
|
|
1809
2081
|
var searchNewspapersDescription = [
|
|
1810
2082
|
"Search the text inside digitised American newspaper pages held by the Library of Congress.",
|
|
1811
2083
|
"This reads what optical recognition took off the scanned pages, so it finds a phrase that appears nowhere in a title or a catalogue record.",
|
|
@@ -1865,8 +2137,12 @@ var searchNewspapersOutput = z6.object({
|
|
|
1865
2137
|
var QUOTED_PHRASE = /"[^"]+"/;
|
|
1866
2138
|
function describeNarrowing2(args) {
|
|
1867
2139
|
const written = [];
|
|
1868
|
-
if (args.location)
|
|
1869
|
-
|
|
2140
|
+
if (args.location) {
|
|
2141
|
+
written.push(`location="${args.location}"`);
|
|
2142
|
+
}
|
|
2143
|
+
if (args.publication) {
|
|
2144
|
+
written.push(`publication="${args.publication}"`);
|
|
2145
|
+
}
|
|
1870
2146
|
if (args.year_from !== void 0 || args.year_to !== void 0) {
|
|
1871
2147
|
written.push(`years ${args.year_from ?? "any"} to ${args.year_to ?? "any"}`);
|
|
1872
2148
|
}
|
|
@@ -1885,15 +2161,17 @@ async function runSearchNewspapers(client, args) {
|
|
|
1885
2161
|
const budget = { maxChars: args.max_excerpt_chars, maxCount: args.max_excerpts_per_match };
|
|
1886
2162
|
const facets = {};
|
|
1887
2163
|
const put = (field, value) => {
|
|
1888
|
-
if (value && value.trim() !== "")
|
|
2164
|
+
if (value && value.trim() !== "") {
|
|
2165
|
+
facets[field] = value;
|
|
2166
|
+
}
|
|
1889
2167
|
};
|
|
1890
2168
|
put("state", args.location);
|
|
1891
2169
|
put("publication", args.publication);
|
|
1892
2170
|
const narrowing = describeNarrowing2(args);
|
|
1893
2171
|
const filters = {
|
|
1894
2172
|
facets,
|
|
1895
|
-
...args.year_from
|
|
1896
|
-
...args.year_to
|
|
2173
|
+
...args.year_from === void 0 ? {} : { yearFrom: args.year_from },
|
|
2174
|
+
...args.year_to === void 0 ? {} : { yearTo: args.year_to }
|
|
1897
2175
|
};
|
|
1898
2176
|
const notes = [];
|
|
1899
2177
|
let result = await client.searchNewspapers(args.query, args.limit, args.page, budget, filters);
|
|
@@ -1907,7 +2185,9 @@ async function runSearchNewspapers(client, args) {
|
|
|
1907
2185
|
}
|
|
1908
2186
|
}
|
|
1909
2187
|
const { data, cached, skipped } = result;
|
|
1910
|
-
if (cached)
|
|
2188
|
+
if (cached) {
|
|
2189
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
2190
|
+
}
|
|
1911
2191
|
if (skipped) {
|
|
1912
2192
|
notes.push(
|
|
1913
2193
|
`${counted(skipped, "match", "matches")} came back in a shape this server could not read and ${agrees(skipped, "was", "were")} left out. The count above is what the Library reported.`
|
|
@@ -1927,7 +2207,9 @@ async function runSearchNewspapers(client, args) {
|
|
|
1927
2207
|
source_url: hit.sourceUrl
|
|
1928
2208
|
}));
|
|
1929
2209
|
const total = data.paging.resultCount;
|
|
1930
|
-
if (hits.length > 0)
|
|
2210
|
+
if (hits.length > 0) {
|
|
2211
|
+
notes.push(OCR_CAVEAT);
|
|
2212
|
+
}
|
|
1931
2213
|
const openings = hits.filter(
|
|
1932
2214
|
(hit) => hit.excerpt_kind === "page_opening" && hit.excerpts.length > 0
|
|
1933
2215
|
).length;
|
|
@@ -1956,7 +2238,7 @@ async function runSearchNewspapers(client, args) {
|
|
|
1956
2238
|
`Page ${args.page} is past the last match. ${counted(total, "page")} match, so ask for a lower page.`
|
|
1957
2239
|
);
|
|
1958
2240
|
}
|
|
1959
|
-
const body = hits.length === 0 ? total
|
|
2241
|
+
const body = hits.length === 0 ? nothingOnThisPage3(total, args.page, args.query) : `${hits.length} of ${counted(total, "newspaper page")} the Library matched for ${args.query}:
|
|
1960
2242
|
` + hits.map((hit, index) => {
|
|
1961
2243
|
const where = [
|
|
1962
2244
|
`${index + 1}. ${hit.publication ?? hit.title ?? "untitled"}`,
|
|
@@ -2067,15 +2349,15 @@ async function main() {
|
|
|
2067
2349
|
await server.connect(transport);
|
|
2068
2350
|
const shutdown = async () => {
|
|
2069
2351
|
await server.close().catch(() => void 0);
|
|
2070
|
-
|
|
2352
|
+
process2.exit(0);
|
|
2071
2353
|
};
|
|
2072
|
-
|
|
2073
|
-
|
|
2354
|
+
process2.on("SIGINT", shutdown);
|
|
2355
|
+
process2.on("SIGTERM", shutdown);
|
|
2074
2356
|
}
|
|
2075
2357
|
main().catch((error) => {
|
|
2076
2358
|
const message = error instanceof Error ? error.message : String(error);
|
|
2077
|
-
|
|
2359
|
+
process2.stderr.write(`[mcp-libraryofcongress] fatal: ${message}
|
|
2078
2360
|
`);
|
|
2079
|
-
|
|
2361
|
+
process2.exit(1);
|
|
2080
2362
|
});
|
|
2081
2363
|
//# sourceMappingURL=index.js.map
|