mcp-databnf 1.1.0 → 2.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 +21 -0
- package/README.md +44 -6
- package/dist/bnf/client.d.ts +4 -33
- package/dist/bnf/client.js +260 -232
- package/dist/bnf/client.js.map +1 -1
- package/dist/index.js +551 -307
- package/dist/index.js.map +1 -1
- package/package.json +18 -10
- package/server.json +73 -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 = "2.0.0";
|
|
11
15
|
var REPO_URL = "https://github.com/smeet666/mcp-databnf";
|
|
12
16
|
|
|
13
17
|
// src/config.ts
|
|
@@ -20,7 +24,9 @@ var DEFAULT_USER_AGENT = `mcp-databnf/${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-databnf] ${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-databnf] error: ${name}="${raw}" is not a whole number; using ${fallback}.
|
|
43
51
|
`
|
|
@@ -88,7 +96,7 @@ var BnfError = class extends Error {
|
|
|
88
96
|
code;
|
|
89
97
|
details;
|
|
90
98
|
constructor(code, message, details = {}) {
|
|
91
|
-
super(message);
|
|
99
|
+
super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
|
|
92
100
|
this.name = "BnfError";
|
|
93
101
|
this.code = code;
|
|
94
102
|
this.details = details;
|
|
@@ -118,7 +126,9 @@ var Cache = class {
|
|
|
118
126
|
entries = /* @__PURE__ */ new Map();
|
|
119
127
|
get(key) {
|
|
120
128
|
const entry = this.entries.get(key);
|
|
121
|
-
if (!entry)
|
|
129
|
+
if (!entry) {
|
|
130
|
+
return void 0;
|
|
131
|
+
}
|
|
122
132
|
if (entry.expiresAt <= Date.now()) {
|
|
123
133
|
this.entries.delete(key);
|
|
124
134
|
return void 0;
|
|
@@ -128,12 +138,16 @@ var Cache = class {
|
|
|
128
138
|
return { value: entry.value, retrievedAt: entry.retrievedAt };
|
|
129
139
|
}
|
|
130
140
|
set(key, value, retrievedAt) {
|
|
131
|
-
if (this.ttlMs <= 0)
|
|
141
|
+
if (this.ttlMs <= 0) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
132
144
|
this.entries.delete(key);
|
|
133
145
|
this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs, retrievedAt });
|
|
134
146
|
while (this.entries.size > this.maxEntries) {
|
|
135
147
|
const oldest = this.entries.keys().next();
|
|
136
|
-
if (oldest.done)
|
|
148
|
+
if (oldest.done) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
137
151
|
this.entries.delete(oldest.value);
|
|
138
152
|
}
|
|
139
153
|
}
|
|
@@ -143,6 +157,9 @@ var Cache = class {
|
|
|
143
157
|
};
|
|
144
158
|
|
|
145
159
|
// src/bnf/endpoint.ts
|
|
160
|
+
var TRAILING_DOT = /\.$/;
|
|
161
|
+
var FRAGMENT = /#.*$/;
|
|
162
|
+
var HTTP_SCHEME = /^http:/;
|
|
146
163
|
var SPARQL_ENDPOINT = "https://data.bnf.fr/sparql";
|
|
147
164
|
var ALLOWED_HOST = "data.bnf.fr";
|
|
148
165
|
var GALLICA_HOST = "gallica.bnf.fr";
|
|
@@ -150,22 +167,23 @@ function assertRequestable(url) {
|
|
|
150
167
|
let parsed;
|
|
151
168
|
try {
|
|
152
169
|
parsed = new URL(url);
|
|
153
|
-
} catch {
|
|
154
|
-
throw refusal(`mcp-databnf refuses to request "${url}": it is not an address
|
|
170
|
+
} catch (cause) {
|
|
171
|
+
throw refusal(`mcp-databnf refuses to request "${url}": it is not an address.`, cause);
|
|
155
172
|
}
|
|
156
|
-
const host = parsed.hostname.toLowerCase().replace(
|
|
173
|
+
const host = parsed.hostname.toLowerCase().replace(TRAILING_DOT, "");
|
|
157
174
|
if (host !== ALLOWED_HOST) {
|
|
158
175
|
throw refusal(
|
|
159
176
|
`mcp-databnf requests ${ALLOWED_HOST} and nothing else; "${host}" was refused. Gallica in particular is described from the catalogue and never read.`
|
|
160
177
|
);
|
|
161
178
|
}
|
|
162
179
|
}
|
|
163
|
-
var refusal = (message) => new BnfError("network_error", message, {
|
|
164
|
-
hint: "This server reads the BnF catalogue at data.bnf.fr. It holds no licence to read the digitised contents, so it does not follow an address that leaves that host."
|
|
180
|
+
var refusal = (message, cause) => new BnfError("network_error", message, {
|
|
181
|
+
hint: "This server reads the BnF catalogue at data.bnf.fr. It holds no licence to read the digitised contents, so it does not follow an address that leaves that host.",
|
|
182
|
+
cause
|
|
165
183
|
});
|
|
166
184
|
function isGallicaAddress(address) {
|
|
167
185
|
try {
|
|
168
|
-
const host = new URL(address).hostname.toLowerCase().replace(
|
|
186
|
+
const host = new URL(address).hostname.toLowerCase().replace(TRAILING_DOT, "");
|
|
169
187
|
return host === GALLICA_HOST || host.endsWith(`.${GALLICA_HOST}`);
|
|
170
188
|
} catch {
|
|
171
189
|
return false;
|
|
@@ -192,21 +210,28 @@ var PREFIXES = {
|
|
|
192
210
|
rdafrbr: "http://rdvocab.info/uri/schema/FRBRentitiesRDA/"
|
|
193
211
|
};
|
|
194
212
|
var PREFIX_BLOCK = Object.entries(PREFIXES).map(([name, iri]) => `PREFIX ${name}: <${iri}>`).join("\n");
|
|
195
|
-
var publicPageFor = (entityIri) => entityIri.replace(
|
|
213
|
+
var publicPageFor = (entityIri) => entityIri.replace(FRAGMENT, "").replace(HTTP_SCHEME, "https:");
|
|
196
214
|
|
|
197
215
|
// src/bnf/http.ts
|
|
216
|
+
var VIRTUOSO_ERROR = /^Virtuoso\s+(\w+)\s+Error\s+([\s\S]*)$/;
|
|
198
217
|
var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
199
218
|
var PUSH_BACK = /* @__PURE__ */ new Set([429, 503]);
|
|
200
219
|
var LONGEST_WAIT_MS = 3e4;
|
|
201
220
|
var RETRIES_AFTER_SILENCE = 1;
|
|
202
221
|
var MAX_REDIRECTS = 2;
|
|
203
222
|
function parseRetryAfter(value, now = Date.now()) {
|
|
204
|
-
if (!value)
|
|
223
|
+
if (!value) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
205
226
|
const trimmed = value.trim();
|
|
206
227
|
const seconds = Number(trimmed);
|
|
207
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
228
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
229
|
+
return Math.round(seconds * 1e3);
|
|
230
|
+
}
|
|
208
231
|
const at = Date.parse(trimmed);
|
|
209
|
-
if (Number.isNaN(at))
|
|
232
|
+
if (Number.isNaN(at)) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
210
235
|
return Math.max(0, at - now);
|
|
211
236
|
}
|
|
212
237
|
function backoffMs(attempt) {
|
|
@@ -214,8 +239,10 @@ function backoffMs(attempt) {
|
|
|
214
239
|
return base + Math.floor(Math.random() * 500);
|
|
215
240
|
}
|
|
216
241
|
function readEngineError(body) {
|
|
217
|
-
const match =
|
|
218
|
-
if (!match)
|
|
242
|
+
const match = VIRTUOSO_ERROR.exec(body.trim());
|
|
243
|
+
if (!match) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
219
246
|
const state = match[1] ?? "";
|
|
220
247
|
const text2 = (match[2] ?? "").split("\n")[0]?.trim() ?? "";
|
|
221
248
|
return { kind: state === "37000" ? "compile" : "runtime", text: text2 };
|
|
@@ -257,6 +284,78 @@ async function postOnce(options, signal) {
|
|
|
257
284
|
url: SPARQL_ENDPOINT
|
|
258
285
|
});
|
|
259
286
|
}
|
|
287
|
+
function readRefusal(response, attempt, maxRetries) {
|
|
288
|
+
const pushBack = RETRYABLE.has(response.status);
|
|
289
|
+
if (PUSH_BACK.has(response.status)) {
|
|
290
|
+
const asked = parseRetryAfter(response.headers.get("retry-after"));
|
|
291
|
+
if (asked !== null && asked > LONGEST_WAIT_MS) {
|
|
292
|
+
return {
|
|
293
|
+
kind: "refused",
|
|
294
|
+
pushBack,
|
|
295
|
+
error: rateLimited(
|
|
296
|
+
`data.bnf.fr asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
|
|
297
|
+
{ url: SPARQL_ENDPOINT, status: response.status }
|
|
298
|
+
)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (attempt >= maxRetries) {
|
|
302
|
+
return {
|
|
303
|
+
kind: "refused",
|
|
304
|
+
pushBack,
|
|
305
|
+
error: rateLimited(
|
|
306
|
+
`data.bnf.fr asked this client to slow down (HTTP ${response.status}).`,
|
|
307
|
+
{
|
|
308
|
+
url: SPARQL_ENDPOINT,
|
|
309
|
+
status: response.status
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return { kind: "again", pushBack, waitMs: asked ?? backoffMs(attempt) };
|
|
315
|
+
}
|
|
316
|
+
if (RETRYABLE.has(response.status) && attempt < maxRetries) {
|
|
317
|
+
return { kind: "again", pushBack, waitMs: backoffMs(attempt) };
|
|
318
|
+
}
|
|
319
|
+
if (response.status === 400 || response.status === 422) {
|
|
320
|
+
const engine = readEngineError(response.body);
|
|
321
|
+
return {
|
|
322
|
+
kind: "refused",
|
|
323
|
+
pushBack,
|
|
324
|
+
error: invalidInput(
|
|
325
|
+
`data.bnf.fr would not run this query${engine?.text ? `: ${engine.text}` : "."}`,
|
|
326
|
+
"Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
|
|
327
|
+
)
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
kind: "refused",
|
|
332
|
+
pushBack,
|
|
333
|
+
error: networkError(`data.bnf.fr answered HTTP ${response.status}.`, {
|
|
334
|
+
url: SPARQL_ENDPOINT,
|
|
335
|
+
status: response.status
|
|
336
|
+
})
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function readFailure(error, attempts) {
|
|
340
|
+
const { attempt, maxRetries, timeoutMs } = attempts;
|
|
341
|
+
if (error instanceof Error && error.name === "BnfError") {
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
345
|
+
if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
|
|
346
|
+
throw timeout(
|
|
347
|
+
`No answer from data.bnf.fr within ${timeoutMs}ms. A query spanning the whole catalogue can take longer than the service will spend on it.`,
|
|
348
|
+
{ url: SPARQL_ENDPOINT }
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return error;
|
|
352
|
+
}
|
|
353
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
354
|
+
if (attempt >= maxRetries) {
|
|
355
|
+
throw networkError(`Could not reach data.bnf.fr: ${failure.message}`, { url: SPARQL_ENDPOINT });
|
|
356
|
+
}
|
|
357
|
+
return failure;
|
|
358
|
+
}
|
|
260
359
|
async function runQuery(options) {
|
|
261
360
|
const { timeoutMs, maxRetries, limiter, logger } = options;
|
|
262
361
|
let lastError = null;
|
|
@@ -264,7 +363,8 @@ async function runQuery(options) {
|
|
|
264
363
|
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
265
364
|
if (askedWaitMs > 0) {
|
|
266
365
|
logger.debug(`waiting ${askedWaitMs}ms, as asked`);
|
|
267
|
-
|
|
366
|
+
const asked = askedWaitMs;
|
|
367
|
+
await new Promise((resolve) => setTimeout(resolve, asked));
|
|
268
368
|
askedWaitMs = 0;
|
|
269
369
|
}
|
|
270
370
|
await limiter.beforeRequest();
|
|
@@ -284,66 +384,19 @@ async function runQuery(options) {
|
|
|
284
384
|
limiter.succeeded();
|
|
285
385
|
return parsed;
|
|
286
386
|
}
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
if (asked !== null && asked > LONGEST_WAIT_MS) {
|
|
291
|
-
throw rateLimited(
|
|
292
|
-
`data.bnf.fr asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
|
|
293
|
-
{ url: SPARQL_ENDPOINT, status: response.status }
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
if (attempt >= maxRetries) {
|
|
297
|
-
throw rateLimited(
|
|
298
|
-
`data.bnf.fr asked this client to slow down (HTTP ${response.status}).`,
|
|
299
|
-
{
|
|
300
|
-
url: SPARQL_ENDPOINT,
|
|
301
|
-
status: response.status
|
|
302
|
-
}
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
askedWaitMs = asked ?? backoffMs(attempt);
|
|
306
|
-
lastError = new Error(`HTTP ${response.status}`);
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
if (RETRYABLE.has(response.status) && attempt < maxRetries) {
|
|
310
|
-
lastError = new Error(`HTTP ${response.status}`);
|
|
311
|
-
askedWaitMs = backoffMs(attempt);
|
|
312
|
-
continue;
|
|
387
|
+
const verdict = readRefusal(response, attempt, maxRetries);
|
|
388
|
+
if (verdict.pushBack) {
|
|
389
|
+
limiter.pushBack();
|
|
313
390
|
}
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
throw invalidInput(
|
|
317
|
-
`data.bnf.fr would not run this query${engine?.text ? `: ${engine.text}` : "."}`,
|
|
318
|
-
"Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
|
|
319
|
-
);
|
|
391
|
+
if (verdict.kind === "refused") {
|
|
392
|
+
throw verdict.error;
|
|
320
393
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
status: response.status
|
|
324
|
-
});
|
|
394
|
+
askedWaitMs = verdict.waitMs;
|
|
395
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
325
396
|
} catch (error) {
|
|
326
397
|
clearTimeout(deadline);
|
|
327
|
-
|
|
328
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
329
|
-
lastError = error;
|
|
330
|
-
if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
|
|
331
|
-
throw timeout(
|
|
332
|
-
`No answer from data.bnf.fr within ${timeoutMs}ms. A query spanning the whole catalogue can take longer than the service will spend on it.`,
|
|
333
|
-
{ url: SPARQL_ENDPOINT }
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
askedWaitMs = backoffMs(attempt);
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
340
|
-
if (attempt >= maxRetries) {
|
|
341
|
-
throw networkError(`Could not reach data.bnf.fr: ${lastError.message}`, {
|
|
342
|
-
url: SPARQL_ENDPOINT
|
|
343
|
-
});
|
|
344
|
-
}
|
|
398
|
+
lastError = readFailure(error, { attempt, maxRetries, timeoutMs });
|
|
345
399
|
askedWaitMs = backoffMs(attempt);
|
|
346
|
-
continue;
|
|
347
400
|
} finally {
|
|
348
401
|
clearTimeout(deadline);
|
|
349
402
|
}
|
|
@@ -377,9 +430,10 @@ function parseResults(body) {
|
|
|
377
430
|
let payload;
|
|
378
431
|
try {
|
|
379
432
|
payload = JSON.parse(body);
|
|
380
|
-
} catch {
|
|
433
|
+
} catch (cause) {
|
|
381
434
|
throw parseFailure("data.bnf.fr answered with something that is not JSON.", {
|
|
382
|
-
url: SPARQL_ENDPOINT
|
|
435
|
+
url: SPARQL_ENDPOINT,
|
|
436
|
+
cause
|
|
383
437
|
});
|
|
384
438
|
}
|
|
385
439
|
if (typeof payload !== "object" || payload === null || !("results" in payload) || typeof payload.results !== "object" || !Array.isArray(payload.results?.bindings)) {
|
|
@@ -391,6 +445,16 @@ function parseResults(body) {
|
|
|
391
445
|
}
|
|
392
446
|
|
|
393
447
|
// src/bnf/sparql.ts
|
|
448
|
+
var KEPT_CHARACTER = /[\p{L}\p{N}'\-\s]/u;
|
|
449
|
+
var TYPOGRAPHIC_APOSTROPHE = /[‘’ʼ]/u;
|
|
450
|
+
var LEADING_JOINERS = /^['-]+/u;
|
|
451
|
+
var TRAILING_JOINERS = /['-]+$/u;
|
|
452
|
+
var JOINERS = /['-]+/u;
|
|
453
|
+
var ARK_IN_INPUT = /(?:^|\/)ark:\/12148\/(c[bc][0-9a-z]+)/i;
|
|
454
|
+
var LEADING_HASH = /^#/;
|
|
455
|
+
var FRAGMENT_OR_QUERY = /[#?]/;
|
|
456
|
+
var TEMP_WORK_IN_INPUT = /temp-work\/([0-9a-f]{32})/i;
|
|
457
|
+
var GALLICA_ARK = /ark:\/12148\/([0-9a-z]+)/i;
|
|
394
458
|
function escapeLiteral(value) {
|
|
395
459
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "");
|
|
396
460
|
}
|
|
@@ -402,15 +466,21 @@ function withoutInvisibles(input) {
|
|
|
402
466
|
function invisibleCharacters(input) {
|
|
403
467
|
const found = [];
|
|
404
468
|
for (const character of input.match(INVISIBLE) ?? []) {
|
|
405
|
-
if (!found.includes(character))
|
|
469
|
+
if (!found.includes(character)) {
|
|
470
|
+
found.push(character);
|
|
471
|
+
}
|
|
406
472
|
}
|
|
407
473
|
return found;
|
|
408
474
|
}
|
|
409
475
|
function charactersSetAside(input) {
|
|
410
476
|
const found = [];
|
|
411
477
|
for (const character of withoutInvisibles(input).normalize("NFC")) {
|
|
412
|
-
if (
|
|
413
|
-
|
|
478
|
+
if (KEPT_CHARACTER.test(character) || TYPOGRAPHIC_APOSTROPHE.test(character)) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (!found.includes(character)) {
|
|
482
|
+
found.push(character);
|
|
483
|
+
}
|
|
414
484
|
}
|
|
415
485
|
return found;
|
|
416
486
|
}
|
|
@@ -427,18 +497,24 @@ function toSearchWords(input) {
|
|
|
427
497
|
const cleaned = withoutInvisibles(input).normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
|
|
428
498
|
const words = [];
|
|
429
499
|
for (const raw of cleaned.split(" ")) {
|
|
430
|
-
const word = raw.replace(
|
|
431
|
-
if (word === "")
|
|
500
|
+
const word = raw.replace(LEADING_JOINERS, "").replace(TRAILING_JOINERS, "");
|
|
501
|
+
if (word === "") {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
432
504
|
words.push(word.length > 60 ? word.slice(0, 60) : word);
|
|
433
|
-
if (words.length >= 12)
|
|
505
|
+
if (words.length >= 12) {
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
434
508
|
}
|
|
435
509
|
return words;
|
|
436
510
|
}
|
|
437
511
|
function toIndexTerms(words) {
|
|
438
512
|
const terms = [];
|
|
439
513
|
for (const word of words) {
|
|
440
|
-
for (const piece of word.split(
|
|
441
|
-
if (piece !== "" && !terms.includes(piece))
|
|
514
|
+
for (const piece of word.split(JOINERS)) {
|
|
515
|
+
if (piece !== "" && !terms.includes(piece)) {
|
|
516
|
+
terms.push(piece);
|
|
517
|
+
}
|
|
442
518
|
}
|
|
443
519
|
}
|
|
444
520
|
return terms;
|
|
@@ -460,8 +536,8 @@ function parseEntityId(input) {
|
|
|
460
536
|
if (trimmed === "") {
|
|
461
537
|
throw invalidInput("An identifier is required.");
|
|
462
538
|
}
|
|
463
|
-
const arkMatch =
|
|
464
|
-
const bare = arkMatch ? arkMatch[1] : trimmed.replace(
|
|
539
|
+
const arkMatch = ARK_IN_INPUT.exec(trimmed);
|
|
540
|
+
const bare = arkMatch ? arkMatch[1] : trimmed.replace(LEADING_HASH, "").split(FRAGMENT_OR_QUERY)[0];
|
|
465
541
|
const candidate = (bare ?? "").toLowerCase();
|
|
466
542
|
if (ARK_ID.test(candidate)) {
|
|
467
543
|
return {
|
|
@@ -471,7 +547,7 @@ function parseEntityId(input) {
|
|
|
471
547
|
pageUrl: `https://data.bnf.fr/ark:/12148/${candidate}`
|
|
472
548
|
};
|
|
473
549
|
}
|
|
474
|
-
const tempMatch =
|
|
550
|
+
const tempMatch = TEMP_WORK_IN_INPUT.exec(trimmed);
|
|
475
551
|
const digest = (tempMatch?.[1] ?? candidate).toLowerCase();
|
|
476
552
|
if (TEMP_WORK_ID.test(digest)) {
|
|
477
553
|
return {
|
|
@@ -492,7 +568,7 @@ function hintFor(input) {
|
|
|
492
568
|
const name = (page2[2] ?? "").replace(/_/g, " ");
|
|
493
569
|
return `That is the readable address of a data.bnf.fr page, and ${page2[1]} in it is the record's FRBNF number rather than the identifier these tools take. Search for "${name}" with search_authors or search_works, and use the identifier the row carries.`;
|
|
494
570
|
}
|
|
495
|
-
const gallicaArk =
|
|
571
|
+
const gallicaArk = GALLICA_ARK.exec(input)?.[1] ?? input;
|
|
496
572
|
if (GALLICA_DOCUMENT.test(gallicaArk)) {
|
|
497
573
|
return "That is a Gallica identifier: it names a digitised document rather than a record in the catalogue, and these tools read the catalogue. Search for the work with search_works, then list_editions will give you the Gallica link alongside the edition it belongs to.";
|
|
498
574
|
}
|
|
@@ -500,7 +576,7 @@ function hintFor(input) {
|
|
|
500
576
|
}
|
|
501
577
|
var iriOf = (id) => `<${id.iri}>`;
|
|
502
578
|
function boundedInteger(value, min, max, name) {
|
|
503
|
-
if (!Number.isFinite(value)
|
|
579
|
+
if (!(Number.isFinite(value) && Number.isInteger(value))) {
|
|
504
580
|
throw invalidInput(`'${name}' has to be a whole number.`);
|
|
505
581
|
}
|
|
506
582
|
if (value < min || value > max) {
|
|
@@ -721,6 +797,14 @@ LIMIT 20`);
|
|
|
721
797
|
}
|
|
722
798
|
|
|
723
799
|
// src/bnf/parse.ts
|
|
800
|
+
var ARK_IN_IRI = /ark:\/12148\/(c[bc][0-9a-z]+)/i;
|
|
801
|
+
var TEMP_WORK_IN_IRI = /temp-work\/([0-9a-f]{32})/i;
|
|
802
|
+
var TRAILING_SEPARATOR = /[/#]$/;
|
|
803
|
+
var PATH_SEPARATOR = /[/#]/;
|
|
804
|
+
var ARK_SEGMENT = /ark:\/12148\/([^/?#]+)/;
|
|
805
|
+
var QUERY_START = /[?#]/;
|
|
806
|
+
var LEADING_DOTS = /^[./]+/;
|
|
807
|
+
var TRAILING_SLASHES = /\/+$/;
|
|
724
808
|
var P = {
|
|
725
809
|
type: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
|
726
810
|
label: "http://www.w3.org/2000/01/rdf-schema#label",
|
|
@@ -776,36 +860,50 @@ var ALIGNMENT_HOSTS = [
|
|
|
776
860
|
{ host: "catalogue.bnf.fr", name: "catalogue" }
|
|
777
861
|
];
|
|
778
862
|
var text = (term) => {
|
|
779
|
-
if (!term)
|
|
863
|
+
if (!term) {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
780
866
|
const value = term.value.trim();
|
|
781
867
|
return value === "" ? null : value;
|
|
782
868
|
};
|
|
783
869
|
var integer = (term) => {
|
|
784
870
|
const raw = text(term);
|
|
785
|
-
if (raw === null)
|
|
871
|
+
if (raw === null) {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
786
874
|
const value = Number(raw);
|
|
787
875
|
return Number.isInteger(value) ? value : null;
|
|
788
876
|
};
|
|
789
877
|
function idFromIri(iri) {
|
|
790
|
-
const ark =
|
|
791
|
-
if (ark?.[1])
|
|
792
|
-
|
|
793
|
-
|
|
878
|
+
const ark = ARK_IN_IRI.exec(iri);
|
|
879
|
+
if (ark?.[1]) {
|
|
880
|
+
return ark[1].toLowerCase();
|
|
881
|
+
}
|
|
882
|
+
const temp = TEMP_WORK_IN_IRI.exec(iri);
|
|
883
|
+
if (temp?.[1]) {
|
|
884
|
+
return `temp-work/${temp[1].toLowerCase()}`;
|
|
885
|
+
}
|
|
794
886
|
return null;
|
|
795
887
|
}
|
|
796
|
-
var vocabularyTerm = (iri) => decodeURIComponent(iri.replace(
|
|
888
|
+
var vocabularyTerm = (iri) => decodeURIComponent(iri.replace(TRAILING_SEPARATOR, "").split(PATH_SEPARATOR).pop() ?? iri);
|
|
797
889
|
function toDigitisedLink(address, role, fromId, fromTitle) {
|
|
798
|
-
if (!address
|
|
799
|
-
|
|
800
|
-
|
|
890
|
+
if (!(address && isGallicaAddress(address))) {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
const segment = ARK_SEGMENT.exec(address)?.[1] ?? null;
|
|
894
|
+
if (!segment) {
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
801
897
|
const ark = segment.split(".")[0] ?? segment;
|
|
802
|
-
if (ark === "")
|
|
898
|
+
if (ark === "") {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
803
901
|
return { ark, url: address, rendering: renderingOf(address, ark), role, fromId, fromTitle };
|
|
804
902
|
}
|
|
805
903
|
function renderingOf(address, ark) {
|
|
806
|
-
const path = address.split(
|
|
904
|
+
const path = address.split(QUERY_START)[0] ?? address;
|
|
807
905
|
const after = path.slice(path.indexOf(`ark:/12148/${ark}`) + `ark:/12148/${ark}`.length);
|
|
808
|
-
const rendering = after.replace(
|
|
906
|
+
const rendering = after.replace(LEADING_DOTS, "").replace(TRAILING_SLASHES, "");
|
|
809
907
|
return rendering === "" ? null : rendering;
|
|
810
908
|
}
|
|
811
909
|
function page(rows, limit, skipped) {
|
|
@@ -815,9 +913,13 @@ function page(rows, limit, skipped) {
|
|
|
815
913
|
function readWindowOccupancy(results) {
|
|
816
914
|
for (const row of results.results.bindings) {
|
|
817
915
|
const raw = text(row.windowRows);
|
|
818
|
-
if (raw === null)
|
|
916
|
+
if (raw === null) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
819
919
|
const count = Number(raw);
|
|
820
|
-
if (Number.isInteger(count))
|
|
920
|
+
if (Number.isInteger(count)) {
|
|
921
|
+
return count >= TEXT_WINDOW;
|
|
922
|
+
}
|
|
821
923
|
}
|
|
822
924
|
return null;
|
|
823
925
|
}
|
|
@@ -826,10 +928,15 @@ function byPredicate(results) {
|
|
|
826
928
|
const index = /* @__PURE__ */ new Map();
|
|
827
929
|
for (const row of results.results.bindings) {
|
|
828
930
|
const predicate = text(row.p);
|
|
829
|
-
if (predicate === null)
|
|
931
|
+
if (predicate === null) {
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
830
934
|
const bucket = index.get(predicate);
|
|
831
|
-
if (bucket)
|
|
832
|
-
|
|
935
|
+
if (bucket) {
|
|
936
|
+
bucket.push(row);
|
|
937
|
+
} else {
|
|
938
|
+
index.set(predicate, [row]);
|
|
939
|
+
}
|
|
833
940
|
}
|
|
834
941
|
return index;
|
|
835
942
|
}
|
|
@@ -838,7 +945,9 @@ var allValues = (index, predicate) => {
|
|
|
838
945
|
const seen = /* @__PURE__ */ new Set();
|
|
839
946
|
for (const row of index.get(predicate) ?? []) {
|
|
840
947
|
const value = text(row.o);
|
|
841
|
-
if (value !== null)
|
|
948
|
+
if (value !== null) {
|
|
949
|
+
seen.add(value);
|
|
950
|
+
}
|
|
842
951
|
}
|
|
843
952
|
return [...seen];
|
|
844
953
|
};
|
|
@@ -849,8 +958,12 @@ function alignments(index, selfId) {
|
|
|
849
958
|
const found = {};
|
|
850
959
|
for (const predicate of [P.sameAs, P.exactMatch, P.closeMatch, P.seeAlso]) {
|
|
851
960
|
for (const address of allValues(index, predicate)) {
|
|
852
|
-
if (!address.startsWith("http"))
|
|
853
|
-
|
|
961
|
+
if (!address.startsWith("http")) {
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (selfId && address.includes(selfId)) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
854
967
|
let host;
|
|
855
968
|
try {
|
|
856
969
|
host = new URL(address).hostname.toLowerCase();
|
|
@@ -860,8 +973,12 @@ function alignments(index, selfId) {
|
|
|
860
973
|
const known = ALIGNMENT_HOSTS.find(
|
|
861
974
|
(entry) => host === entry.host || host.endsWith(`.${entry.host}`)
|
|
862
975
|
);
|
|
863
|
-
if (!known)
|
|
864
|
-
|
|
976
|
+
if (!known) {
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const addresses = found[known.name] ?? /* @__PURE__ */ new Set();
|
|
980
|
+
found[known.name] = addresses;
|
|
981
|
+
addresses.add(address);
|
|
865
982
|
}
|
|
866
983
|
}
|
|
867
984
|
return Object.fromEntries(Object.entries(found).map(([name, set]) => [name, [...set]]));
|
|
@@ -876,14 +993,18 @@ function toAuthorSummaries(results, limit) {
|
|
|
876
993
|
const seen = /* @__PURE__ */ new Set();
|
|
877
994
|
let skipped = 0;
|
|
878
995
|
for (const row of results.results.bindings) {
|
|
879
|
-
if (isOccupancyRow(row, "person"))
|
|
996
|
+
if (isOccupancyRow(row, "person")) {
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
880
999
|
const iri = text(row.person);
|
|
881
1000
|
const id = iri === null ? null : idFromIri(iri);
|
|
882
1001
|
if (iri === null || id === null) {
|
|
883
1002
|
skipped += 1;
|
|
884
1003
|
continue;
|
|
885
1004
|
}
|
|
886
|
-
if (seen.has(id))
|
|
1005
|
+
if (seen.has(id)) {
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
887
1008
|
seen.add(id);
|
|
888
1009
|
rows.push({
|
|
889
1010
|
id,
|
|
@@ -904,18 +1025,24 @@ function toAuthorDetail(results, id, pageUrl) {
|
|
|
904
1025
|
const seenLabels = /* @__PURE__ */ new Set();
|
|
905
1026
|
for (const row of index.get(P.altLabel) ?? []) {
|
|
906
1027
|
const label = text(row.o);
|
|
907
|
-
if (label === null || seenLabels.has(label))
|
|
1028
|
+
if (label === null || seenLabels.has(label)) {
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
908
1031
|
seenLabels.add(label);
|
|
909
1032
|
otherNames.push({ label, language: text(row.lang) });
|
|
910
1033
|
}
|
|
911
1034
|
const sameAs = alignments(index, id);
|
|
912
1035
|
const isni = isniAddresses(index);
|
|
913
|
-
if (isni.length > 0)
|
|
1036
|
+
if (isni.length > 0) {
|
|
1037
|
+
sameAs.isni = [.../* @__PURE__ */ new Set([...sameAs.isni ?? [], ...isni])];
|
|
1038
|
+
}
|
|
914
1039
|
const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
|
|
915
1040
|
const depictions = [];
|
|
916
1041
|
for (const address of allValues(index, P.depiction)) {
|
|
917
1042
|
const link = toDigitisedLink(address, "depiction", id, null);
|
|
918
|
-
if (link)
|
|
1043
|
+
if (link) {
|
|
1044
|
+
depictions.push(link);
|
|
1045
|
+
}
|
|
919
1046
|
}
|
|
920
1047
|
return {
|
|
921
1048
|
id,
|
|
@@ -949,8 +1076,12 @@ function toAuthorDetail(results, id, pageUrl) {
|
|
|
949
1076
|
function readStatus(statement, iri) {
|
|
950
1077
|
if (statement !== null) {
|
|
951
1078
|
const said = statement.toLowerCase();
|
|
952
|
-
if (said.includes("provisional"))
|
|
953
|
-
|
|
1079
|
+
if (said.includes("provisional")) {
|
|
1080
|
+
return "provisional";
|
|
1081
|
+
}
|
|
1082
|
+
if (said.includes("established")) {
|
|
1083
|
+
return "established";
|
|
1084
|
+
}
|
|
954
1085
|
}
|
|
955
1086
|
return iri.includes("/temp-work/") ? "provisional" : "established";
|
|
956
1087
|
}
|
|
@@ -959,7 +1090,9 @@ function toWorkSummaries(results, limit) {
|
|
|
959
1090
|
const byId = /* @__PURE__ */ new Map();
|
|
960
1091
|
let skipped = 0;
|
|
961
1092
|
for (const row of results.results.bindings) {
|
|
962
|
-
if (isOccupancyRow(row, "work"))
|
|
1093
|
+
if (isOccupancyRow(row, "work")) {
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
963
1096
|
const iri = text(row.work);
|
|
964
1097
|
const id = iri === null ? null : idFromIri(iri);
|
|
965
1098
|
if (iri === null || id === null) {
|
|
@@ -987,7 +1120,7 @@ function toWorkSummaries(results, limit) {
|
|
|
987
1120
|
}
|
|
988
1121
|
return {
|
|
989
1122
|
...page(
|
|
990
|
-
order.map((id) => byId.get(id)),
|
|
1123
|
+
order.map((id) => byId.get(id)).filter((one) => one !== void 0),
|
|
991
1124
|
limit,
|
|
992
1125
|
skipped
|
|
993
1126
|
),
|
|
@@ -1020,13 +1153,15 @@ function toAuthoredWorks(results, limit) {
|
|
|
1020
1153
|
order.push(id);
|
|
1021
1154
|
}
|
|
1022
1155
|
const form = text(row.form);
|
|
1023
|
-
if (form
|
|
1156
|
+
if (form?.startsWith("http")) {
|
|
1024
1157
|
const term = vocabularyTerm(form);
|
|
1025
|
-
if (!work.forms.includes(term))
|
|
1158
|
+
if (!work.forms.includes(term)) {
|
|
1159
|
+
work.forms.push(term);
|
|
1160
|
+
}
|
|
1026
1161
|
}
|
|
1027
1162
|
}
|
|
1028
1163
|
return page(
|
|
1029
|
-
order.map((id) => byId.get(id)),
|
|
1164
|
+
order.map((id) => byId.get(id)).filter((one) => one !== void 0),
|
|
1030
1165
|
limit,
|
|
1031
1166
|
skipped
|
|
1032
1167
|
);
|
|
@@ -1039,13 +1174,17 @@ function toWorkDetail(results, id, iri, pageUrl) {
|
|
|
1039
1174
|
for (const row of index.get(P.creator) ?? []) {
|
|
1040
1175
|
const creatorIri = text(row.o);
|
|
1041
1176
|
const creatorId = creatorIri ? idFromIri(creatorIri) : null;
|
|
1042
|
-
if (!creatorId || creators.some((c) => c.id === creatorId))
|
|
1177
|
+
if (!creatorId || creators.some((c) => c.id === creatorId)) {
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1043
1180
|
creators.push({ id: creatorId, name: text(row.name) });
|
|
1044
1181
|
}
|
|
1045
1182
|
const depictions = [];
|
|
1046
1183
|
for (const address of allValues(index, P.depiction)) {
|
|
1047
1184
|
const link = toDigitisedLink(address, "depiction", id, null);
|
|
1048
|
-
if (link)
|
|
1185
|
+
if (link) {
|
|
1186
|
+
depictions.push(link);
|
|
1187
|
+
}
|
|
1049
1188
|
}
|
|
1050
1189
|
const statusStatement = firstValue(index, P.statusOfIdentification);
|
|
1051
1190
|
const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
|
|
@@ -1115,7 +1254,7 @@ function toEditions(results, limit) {
|
|
|
1115
1254
|
}
|
|
1116
1255
|
}
|
|
1117
1256
|
return page(
|
|
1118
|
-
order.map((id) => byId.get(id)),
|
|
1257
|
+
order.map((id) => byId.get(id)).filter((one) => one !== void 0),
|
|
1119
1258
|
limit,
|
|
1120
1259
|
skipped
|
|
1121
1260
|
);
|
|
@@ -1134,7 +1273,9 @@ function toDigitisedLinks(results, limit) {
|
|
|
1134
1273
|
["depiction", "depiction"]
|
|
1135
1274
|
]) {
|
|
1136
1275
|
const link = toDigitisedLink(text(row[column]), role, fromId, title);
|
|
1137
|
-
if (!link || seen.has(link.url))
|
|
1276
|
+
if (!link || seen.has(link.url)) {
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1138
1279
|
seen.add(link.url);
|
|
1139
1280
|
links.push(link);
|
|
1140
1281
|
}
|
|
@@ -1145,7 +1286,9 @@ function toTypes(results) {
|
|
|
1145
1286
|
const types = /* @__PURE__ */ new Set();
|
|
1146
1287
|
for (const row of results.results.bindings) {
|
|
1147
1288
|
const iri = text(row.type);
|
|
1148
|
-
if (iri !== null)
|
|
1289
|
+
if (iri !== null) {
|
|
1290
|
+
types.add(iri);
|
|
1291
|
+
}
|
|
1149
1292
|
}
|
|
1150
1293
|
return [...types];
|
|
1151
1294
|
}
|
|
@@ -1186,7 +1329,9 @@ var RateLimiter = class {
|
|
|
1186
1329
|
async beforeRequest() {
|
|
1187
1330
|
const elapsed = Date.now() - this.lastStartedAt;
|
|
1188
1331
|
const wait = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
1189
|
-
if (wait > 0)
|
|
1332
|
+
if (wait > 0) {
|
|
1333
|
+
await sleep(wait);
|
|
1334
|
+
}
|
|
1190
1335
|
this.lastStartedAt = Date.now();
|
|
1191
1336
|
}
|
|
1192
1337
|
/** Called when the service asks for room: double the gap, up to the ceiling. */
|
|
@@ -1199,9 +1344,13 @@ var RateLimiter = class {
|
|
|
1199
1344
|
* response after a rough patch does not undo the caution that earned it.
|
|
1200
1345
|
*/
|
|
1201
1346
|
succeeded() {
|
|
1202
|
-
if (this.intervalMs === this.baseIntervalMs)
|
|
1347
|
+
if (this.intervalMs === this.baseIntervalMs) {
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1203
1350
|
this.calmStreak += 1;
|
|
1204
|
-
if (this.calmStreak < 3)
|
|
1351
|
+
if (this.calmStreak < 3) {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1205
1354
|
this.calmStreak = 0;
|
|
1206
1355
|
this.intervalMs = Math.max(this.baseIntervalMs, Math.round(this.intervalMs / 2));
|
|
1207
1356
|
}
|
|
@@ -1220,7 +1369,9 @@ function withGuarantees(config) {
|
|
|
1220
1369
|
const defaults = loadConfig({});
|
|
1221
1370
|
const bounded = (value, fallback, min, max) => {
|
|
1222
1371
|
const parsed = Number(value);
|
|
1223
|
-
if (!Number.isFinite(parsed))
|
|
1372
|
+
if (!Number.isFinite(parsed)) {
|
|
1373
|
+
return fallback;
|
|
1374
|
+
}
|
|
1224
1375
|
return Math.min(max, Math.max(min, parsed));
|
|
1225
1376
|
};
|
|
1226
1377
|
const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
|
|
@@ -1271,9 +1422,6 @@ var BnfClient = class {
|
|
|
1271
1422
|
return this.config.timeoutMs;
|
|
1272
1423
|
}
|
|
1273
1424
|
/** The one address this client sends anything to. */
|
|
1274
|
-
get endpoint() {
|
|
1275
|
-
return SPARQL_ENDPOINT;
|
|
1276
|
-
}
|
|
1277
1425
|
/**
|
|
1278
1426
|
* `cacheKey` is stated rather than taken from the query, because two reads of
|
|
1279
1427
|
* one query can produce different values: a list cut to five rows is not the
|
|
@@ -1317,32 +1465,7 @@ var BnfClient = class {
|
|
|
1317
1465
|
return { data, cached: false, retrievedAt: new Date(retrievedAt).toISOString() };
|
|
1318
1466
|
}
|
|
1319
1467
|
/** Read an identifier a caller wrote, refusing anything this dataset cannot address. */
|
|
1320
|
-
identify(input) {
|
|
1321
|
-
return parseEntityId(input);
|
|
1322
|
-
}
|
|
1323
1468
|
/** The words a search will actually look for, which a tool reports back. */
|
|
1324
|
-
words(input) {
|
|
1325
|
-
return toSearchWords(input);
|
|
1326
|
-
}
|
|
1327
|
-
/**
|
|
1328
|
-
* The terms the index requires, which is what a row's presence rests on.
|
|
1329
|
-
*
|
|
1330
|
-
* The index splits inside a word at an apostrophe and at a hyphen, so a word
|
|
1331
|
-
* a caller wrote as one can reach it as two, each required on its own.
|
|
1332
|
-
*/
|
|
1333
|
-
indexTerms(words) {
|
|
1334
|
-
return toIndexTerms(words);
|
|
1335
|
-
}
|
|
1336
|
-
/**
|
|
1337
|
-
* How a piece of caller text became the terms the search was run with.
|
|
1338
|
-
*
|
|
1339
|
-
* A tool reports the reading back, so the same reading has to be the one the
|
|
1340
|
-
* query was built from: the words, the terms the index makes of them, and
|
|
1341
|
-
* the characters that reached neither.
|
|
1342
|
-
*/
|
|
1343
|
-
searchReading(input) {
|
|
1344
|
-
return readSearchText(input);
|
|
1345
|
-
}
|
|
1346
1469
|
searchAuthors(name, limit, offset) {
|
|
1347
1470
|
const words = toSearchWords(name);
|
|
1348
1471
|
const query = searchAuthorsQuery(words, limit, offset);
|
|
@@ -1457,11 +1580,24 @@ import { z as z3 } from "zod";
|
|
|
1457
1580
|
// src/tools/arguments.ts
|
|
1458
1581
|
import { z } from "zod";
|
|
1459
1582
|
var INVALID_INPUT = "invalid_input";
|
|
1583
|
+
var defaultMessage = z.core.locales.en().localeError;
|
|
1460
1584
|
function strictInput(shape) {
|
|
1461
1585
|
const declared = Object.keys(shape);
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1586
|
+
const refuse = (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : `[${INVALID_INPUT}] ${defaultMessage(issue)}`;
|
|
1587
|
+
for (const argument of Object.values(shape)) {
|
|
1588
|
+
carryTheCode(argument, refuse);
|
|
1589
|
+
}
|
|
1590
|
+
return z.strictObject(shape, { error: refuse });
|
|
1591
|
+
}
|
|
1592
|
+
function carryTheCode(schema, refuse) {
|
|
1593
|
+
const definition = schema._zod.def;
|
|
1594
|
+
definition.error = refuse;
|
|
1595
|
+
for (const check of definition.checks ?? []) {
|
|
1596
|
+
check._zod.def.error = refuse;
|
|
1597
|
+
}
|
|
1598
|
+
if (definition.innerType) {
|
|
1599
|
+
carryTheCode(definition.innerType, refuse);
|
|
1600
|
+
}
|
|
1465
1601
|
}
|
|
1466
1602
|
function unknownArgumentMessage(keys, declared) {
|
|
1467
1603
|
const named = keys.map((key) => {
|
|
@@ -1473,15 +1609,21 @@ function unknownArgumentMessage(keys, declared) {
|
|
|
1473
1609
|
function nearestArgument(key, declared) {
|
|
1474
1610
|
const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1475
1611
|
const flat = flatten(key);
|
|
1476
|
-
if (flat.length === 0)
|
|
1612
|
+
if (flat.length === 0) {
|
|
1613
|
+
return void 0;
|
|
1614
|
+
}
|
|
1477
1615
|
const sameName = declared.find((name) => flatten(name) === flat);
|
|
1478
|
-
if (sameName)
|
|
1616
|
+
if (sameName) {
|
|
1617
|
+
return sameName;
|
|
1618
|
+
}
|
|
1479
1619
|
const overlapping = declared.find((name) => {
|
|
1480
1620
|
const other = flatten(name);
|
|
1481
1621
|
const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
|
|
1482
1622
|
return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
|
|
1483
1623
|
});
|
|
1484
|
-
if (overlapping)
|
|
1624
|
+
if (overlapping) {
|
|
1625
|
+
return overlapping;
|
|
1626
|
+
}
|
|
1485
1627
|
let closest;
|
|
1486
1628
|
let shortest = Number.POSITIVE_INFINITY;
|
|
1487
1629
|
for (const name of declared) {
|
|
@@ -1495,24 +1637,37 @@ function nearestArgument(key, declared) {
|
|
|
1495
1637
|
}
|
|
1496
1638
|
function editDistance(left, right) {
|
|
1497
1639
|
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
1640
|
+
let distance = right.length;
|
|
1498
1641
|
for (let row = 1; row <= left.length; row += 1) {
|
|
1499
1642
|
const current = [row];
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1643
|
+
let diagonal = row - 1;
|
|
1644
|
+
let leftward = row;
|
|
1645
|
+
for (const [column, above] of previous.entries()) {
|
|
1646
|
+
if (column === 0) {
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
const substitution = diagonal + (left[row - 1] === right[column - 1] ? 0 : 1);
|
|
1650
|
+
const cell = Math.min(substitution, above + 1, leftward + 1);
|
|
1651
|
+
current.push(cell);
|
|
1652
|
+
diagonal = above;
|
|
1653
|
+
leftward = cell;
|
|
1503
1654
|
}
|
|
1504
1655
|
previous = current;
|
|
1656
|
+
distance = leftward;
|
|
1505
1657
|
}
|
|
1506
|
-
return
|
|
1658
|
+
return distance;
|
|
1507
1659
|
}
|
|
1508
1660
|
|
|
1509
1661
|
// src/tools/shared.ts
|
|
1510
1662
|
import { z as z2 } from "zod";
|
|
1663
|
+
var ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
1664
|
+
var BRACKETED_TAIL = /\(([^()]*)\)\s*$/;
|
|
1665
|
+
var YEAR = /^\d{4}$/;
|
|
1511
1666
|
var MAX_TEXT_CHARS = 2400;
|
|
1512
1667
|
var SOURCE_NAME = "data.bnf.fr (Biblioth\xE8que nationale de France)";
|
|
1513
1668
|
function retrievalDay(retrievedAt) {
|
|
1514
1669
|
const day = retrievedAt.slice(0, 10);
|
|
1515
|
-
return
|
|
1670
|
+
return ISO_DAY.test(day) ? day : retrievedAt;
|
|
1516
1671
|
}
|
|
1517
1672
|
function attribution(retrievedAt, sourceUrl) {
|
|
1518
1673
|
const credit = `Source: ${SOURCE_NAME}, retrieved ${retrievalDay(retrievedAt)}`;
|
|
@@ -1544,7 +1699,9 @@ function onOneLine(value) {
|
|
|
1544
1699
|
function ok(structured, body, options) {
|
|
1545
1700
|
const credit = attribution(options.retrievedAt, options.sourceUrl);
|
|
1546
1701
|
const noteLines = (options.notes ?? []).map((note) => `Note: ${onOneLine(note)}`);
|
|
1547
|
-
while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_CHARS / 2)
|
|
1702
|
+
while (noteLines.length > 0 && noteLines.join("\n").length > MAX_TEXT_CHARS / 2) {
|
|
1703
|
+
noteLines.pop();
|
|
1704
|
+
}
|
|
1548
1705
|
const trailer = [...noteLines, credit].join("\n");
|
|
1549
1706
|
const cut = "\n\n[shortened; the full result is in the structured output]";
|
|
1550
1707
|
const budget = MAX_TEXT_CHARS - `
|
|
@@ -1561,11 +1718,15 @@ ${trailer}`;
|
|
|
1561
1718
|
function toToolError(error) {
|
|
1562
1719
|
const known = error instanceof BnfError ? error : new BnfError("network_error", error instanceof Error ? error.message : String(error));
|
|
1563
1720
|
const lines = [`[${known.code}] ${onOneLine(known.message)}`];
|
|
1564
|
-
if (known.details.hint)
|
|
1721
|
+
if (known.details.hint) {
|
|
1722
|
+
lines.push(`Hint: ${onOneLine(known.details.hint)}`);
|
|
1723
|
+
}
|
|
1565
1724
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
|
|
1566
1725
|
}
|
|
1567
1726
|
function truncate(text2, maxChars) {
|
|
1568
|
-
if (text2.length <= maxChars)
|
|
1727
|
+
if (text2.length <= maxChars) {
|
|
1728
|
+
return text2;
|
|
1729
|
+
}
|
|
1569
1730
|
return `${text2.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
|
|
1570
1731
|
}
|
|
1571
1732
|
var WINDOW_FULL_CAVEAT = "The window this search reads off the index came back full, so records carrying these words sit past what was read and this list is a part of the matches. Which records fill a full window is settled by the index as it answers, so another reading of the same search can bring back other rows. Requiring one more word narrows the reading and reaches what a full window leaves out.";
|
|
@@ -1596,7 +1757,9 @@ function readingNotes(reading, field) {
|
|
|
1596
1757
|
}
|
|
1597
1758
|
var NO_RANKING = "The full-text index answers whether a record matches, and it does not score how well. Rows are ordered by the address of the record, which measures nothing, so the row a person would call the obvious answer can sit anywhere in the list, or on a later page. That order is the one the pages are cut along, so reading page after page reaches every match once.";
|
|
1598
1759
|
async function classifyEmptyPage(page2, firstPageHasRow) {
|
|
1599
|
-
if (page2 === 1)
|
|
1760
|
+
if (page2 === 1) {
|
|
1761
|
+
return "absent";
|
|
1762
|
+
}
|
|
1600
1763
|
try {
|
|
1601
1764
|
return await firstPageHasRow() ? "past_the_end" : "absent";
|
|
1602
1765
|
} catch {
|
|
@@ -1609,18 +1772,24 @@ var WORK_TYPES = [
|
|
|
1609
1772
|
"http://rdaregistry.info/Elements/c/#C10001"
|
|
1610
1773
|
];
|
|
1611
1774
|
function recordKindOf(types) {
|
|
1612
|
-
if (types.includes(PERSON_TYPE))
|
|
1613
|
-
|
|
1775
|
+
if (types.includes(PERSON_TYPE)) {
|
|
1776
|
+
return "person";
|
|
1777
|
+
}
|
|
1778
|
+
if (types.some((type) => WORK_TYPES.includes(type))) {
|
|
1779
|
+
return "work";
|
|
1780
|
+
}
|
|
1614
1781
|
return null;
|
|
1615
1782
|
}
|
|
1616
1783
|
function headingYears(label) {
|
|
1617
|
-
const bracketed =
|
|
1784
|
+
const bracketed = BRACKETED_TAIL.exec(label);
|
|
1618
1785
|
const [birth, death] = (bracketed?.[1] ?? "").split("-");
|
|
1619
|
-
const year = (part) => part !== void 0 &&
|
|
1786
|
+
const year = (part) => part !== void 0 && YEAR.test(part.trim()) ? Number(part.trim()) : null;
|
|
1620
1787
|
return { birth: year(birth), death: year(death) };
|
|
1621
1788
|
}
|
|
1622
1789
|
function headingYearConflicts(label, birthYear, deathYear) {
|
|
1623
|
-
if (label === null)
|
|
1790
|
+
if (label === null) {
|
|
1791
|
+
return [];
|
|
1792
|
+
}
|
|
1624
1793
|
const stated = headingYears(label);
|
|
1625
1794
|
const conflicts = [];
|
|
1626
1795
|
if (stated.birth !== null && birthYear !== null && stated.birth !== birthYear) {
|
|
@@ -1636,6 +1805,37 @@ var ALIGNMENTS_CAVEAT = "The addresses under 'same_as' are the BnF's own alignme
|
|
|
1636
1805
|
var PROVISIONAL_CAVEAT = "A provisional record is one the BnF created to hold a title it has catalogued and not yet established as a work of its own. Its identifier can change, and it can be merged into an established record.";
|
|
1637
1806
|
|
|
1638
1807
|
// src/tools/findDigitised.ts
|
|
1808
|
+
async function whichKindTheCatalogueDescribes(client, id) {
|
|
1809
|
+
if (id.kind === "temp-work") {
|
|
1810
|
+
return { kind: "work" };
|
|
1811
|
+
}
|
|
1812
|
+
const types = await client.types(id);
|
|
1813
|
+
if (types.data.length === 0) {
|
|
1814
|
+
return {
|
|
1815
|
+
error: toToolError(
|
|
1816
|
+
notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
|
|
1817
|
+
hint: "Find the identifier with search_authors or search_works rather than writing one.",
|
|
1818
|
+
url: id.pageUrl
|
|
1819
|
+
})
|
|
1820
|
+
)
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
const found = recordKindOf(types.data);
|
|
1824
|
+
if (found === null) {
|
|
1825
|
+
return {
|
|
1826
|
+
error: toToolError(
|
|
1827
|
+
notFound(
|
|
1828
|
+
`"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
|
|
1829
|
+
{
|
|
1830
|
+
hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
|
|
1831
|
+
url: id.pageUrl
|
|
1832
|
+
}
|
|
1833
|
+
)
|
|
1834
|
+
)
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
return { kind: found };
|
|
1838
|
+
}
|
|
1639
1839
|
var findDigitisedDescription = [
|
|
1640
1840
|
"Gather the digitised documents the Biblioth\xE8que nationale de France catalogue attaches to one person or one work, and return them as links.",
|
|
1641
1841
|
"Give the identifier of either; the tool asks the catalogue what kind of record it is and follows the right path. For a work it walks the editions; for a person it takes the images on the record and the digitised editions of the works they are credited with. The 'kind' returned is what the catalogue types the record as.",
|
|
@@ -1669,35 +1869,13 @@ var findDigitisedOutput = z3.object({
|
|
|
1669
1869
|
});
|
|
1670
1870
|
async function runFindDigitised(client, args) {
|
|
1671
1871
|
try {
|
|
1672
|
-
const id =
|
|
1872
|
+
const id = parseEntityId(args.id);
|
|
1673
1873
|
const notes = [];
|
|
1674
|
-
|
|
1675
|
-
if (
|
|
1676
|
-
|
|
1677
|
-
} else {
|
|
1678
|
-
const types = await client.types(id);
|
|
1679
|
-
if (types.data.length === 0) {
|
|
1680
|
-
return toToolError(
|
|
1681
|
-
notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
|
|
1682
|
-
hint: "Find the identifier with search_authors or search_works rather than writing one.",
|
|
1683
|
-
url: id.pageUrl
|
|
1684
|
-
})
|
|
1685
|
-
);
|
|
1686
|
-
}
|
|
1687
|
-
const found = recordKindOf(types.data);
|
|
1688
|
-
if (found === null) {
|
|
1689
|
-
return toToolError(
|
|
1690
|
-
notFound(
|
|
1691
|
-
`"${id.id}" is described by data.bnf.fr, and it is neither a person nor a work: it is typed ${types.data.join(", ")}.`,
|
|
1692
|
-
{
|
|
1693
|
-
hint: "This tool follows a person or a work. For one edition, list_editions carries the digitised copies alongside the imprint.",
|
|
1694
|
-
url: id.pageUrl
|
|
1695
|
-
}
|
|
1696
|
-
)
|
|
1697
|
-
);
|
|
1698
|
-
}
|
|
1699
|
-
kind = found;
|
|
1874
|
+
const settled = await whichKindTheCatalogueDescribes(client, id);
|
|
1875
|
+
if ("error" in settled) {
|
|
1876
|
+
return settled.error;
|
|
1700
1877
|
}
|
|
1878
|
+
const kind = settled.kind;
|
|
1701
1879
|
if (args.kind !== "auto" && args.kind !== kind) {
|
|
1702
1880
|
return toToolError(
|
|
1703
1881
|
invalidInput(
|
|
@@ -1707,7 +1885,9 @@ async function runFindDigitised(client, args) {
|
|
|
1707
1885
|
);
|
|
1708
1886
|
}
|
|
1709
1887
|
const { data, cached, retrievedAt } = kind === "person" ? await client.digitisedForPerson(id, args.limit) : await client.digitisedForWork(id, args.limit);
|
|
1710
|
-
if (cached)
|
|
1888
|
+
if (cached) {
|
|
1889
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1890
|
+
}
|
|
1711
1891
|
const links = data.rows.map((link) => ({
|
|
1712
1892
|
ark: link.ark,
|
|
1713
1893
|
url: link.url,
|
|
@@ -1722,7 +1902,9 @@ async function runFindDigitised(client, args) {
|
|
|
1722
1902
|
depiction: countRole(data.rows, "depiction")
|
|
1723
1903
|
};
|
|
1724
1904
|
const rendered = links.filter((link) => link.rendering !== null);
|
|
1725
|
-
if (links.length > 0)
|
|
1905
|
+
if (links.length > 0) {
|
|
1906
|
+
notes.push(GALLICA_CAVEAT);
|
|
1907
|
+
}
|
|
1726
1908
|
if (counts.depiction > 0) {
|
|
1727
1909
|
notes.push(
|
|
1728
1910
|
`${counts.depiction === 1 ? "One of these is an image" : `${counts.depiction} of these are images`} the catalogue uses to illustrate the record. Such an image can be a portrait, a title page, or a newspaper page that mentions the subject in passing, and the catalogue does not say which.`
|
|
@@ -1777,6 +1959,45 @@ var countRole = (links, role) => links.filter((link) => link.role === role).leng
|
|
|
1777
1959
|
|
|
1778
1960
|
// src/tools/getAuthor.ts
|
|
1779
1961
|
import { z as z4 } from "zod";
|
|
1962
|
+
function notesOnWhatTheRecordStates(data, args) {
|
|
1963
|
+
const notes = [];
|
|
1964
|
+
if (data.biographicalInformation !== null) {
|
|
1965
|
+
notes.push(
|
|
1966
|
+
`The BnF states "${data.biographicalInformation}" as this person's biographical information. That field holds an occupation rather than a life, and this is all the catalogue says.`
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
if (data.biographicalInformation !== null && data.biographicalInformation === data.occupation) {
|
|
1970
|
+
notes.push(
|
|
1971
|
+
"This record states the same text under 'biographical_information' and 'occupation', character for character. It is one statement written in two fields rather than two the record makes separately."
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
const headings = [data.label, ...data.otherNames.map((other) => other.label)];
|
|
1975
|
+
const conflicts = [
|
|
1976
|
+
...new Set(
|
|
1977
|
+
headings.flatMap((heading) => headingYearConflicts(heading, data.birthYear, data.deathYear))
|
|
1978
|
+
)
|
|
1979
|
+
];
|
|
1980
|
+
if (conflicts.length > 0) {
|
|
1981
|
+
notes.push(
|
|
1982
|
+
`This record states its dates twice and the two disagree: the heading says ${conflicts.join(", and ")}. Both are passed on as the BnF publishes them, and the record says nothing about which is right, so read source_url before citing either.`
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
if (data.deathDate === null && data.deathYear === null) {
|
|
1986
|
+
const dated = data.birthDate !== null || data.birthYear !== null;
|
|
1987
|
+
notes.push(
|
|
1988
|
+
dated ? "The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two." : `This record states no date of birth and no date of death, so it dates the person nowhere and says nothing about whether they are living. ${SEVERAL_RECORDS_CAVEAT}`
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
if (Object.keys(data.sameAs).length > 0) {
|
|
1992
|
+
notes.push(
|
|
1993
|
+
`${ALIGNMENTS_CAVEAT} Following one is how to reach a biography, which this catalogue does not hold.`
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
if (args.include_depictions && data.depictions.length > 0) {
|
|
1997
|
+
notes.push(GALLICA_CAVEAT);
|
|
1998
|
+
}
|
|
1999
|
+
return notes;
|
|
2000
|
+
}
|
|
1780
2001
|
var getAuthorDescription = [
|
|
1781
2002
|
"Read one person's record in the Biblioth\xE8que nationale de France authority file, by the identifier search_authors returns.",
|
|
1782
2003
|
"It carries the dates and places of birth and death, the occupation the record names, the language and country it associates with the person, the field of activity with its Dewey class, and the addresses for the same person in VIAF, IdRef, DBpedia, Wikidata and ISNI.",
|
|
@@ -1831,45 +2052,13 @@ var getAuthorOutput = z4.object({
|
|
|
1831
2052
|
});
|
|
1832
2053
|
async function runGetAuthor(client, args) {
|
|
1833
2054
|
try {
|
|
1834
|
-
const id =
|
|
2055
|
+
const id = parseEntityId(args.author_id);
|
|
1835
2056
|
const { data, cached, retrievedAt } = await client.getAuthor(id);
|
|
1836
2057
|
const notes = [];
|
|
1837
|
-
if (cached)
|
|
1838
|
-
|
|
1839
|
-
notes.push(
|
|
1840
|
-
`The BnF states "${data.biographicalInformation}" as this person's biographical information. That field holds an occupation rather than a life, and this is all the catalogue says.`
|
|
1841
|
-
);
|
|
2058
|
+
if (cached) {
|
|
2059
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
1842
2060
|
}
|
|
1843
|
-
|
|
1844
|
-
notes.push(
|
|
1845
|
-
"This record states the same text under 'biographical_information' and 'occupation', character for character. It is one statement written in two fields rather than two the record makes separately."
|
|
1846
|
-
);
|
|
1847
|
-
}
|
|
1848
|
-
const headings = [data.label, ...data.otherNames.map((other) => other.label)];
|
|
1849
|
-
const conflicts = [
|
|
1850
|
-
...new Set(
|
|
1851
|
-
headings.flatMap(
|
|
1852
|
-
(heading) => headingYearConflicts(heading, data.birthYear, data.deathYear)
|
|
1853
|
-
)
|
|
1854
|
-
)
|
|
1855
|
-
];
|
|
1856
|
-
if (conflicts.length > 0) {
|
|
1857
|
-
notes.push(
|
|
1858
|
-
`This record states its dates twice and the two disagree: the heading says ${conflicts.join(", and ")}. Both are passed on as the BnF publishes them, and the record says nothing about which is right, so read source_url before citing either.`
|
|
1859
|
-
);
|
|
1860
|
-
}
|
|
1861
|
-
if (data.deathDate === null && data.deathYear === null) {
|
|
1862
|
-
const dated = data.birthDate !== null || data.birthYear !== null;
|
|
1863
|
-
notes.push(
|
|
1864
|
-
dated ? "The record states no date of death. That can mean the person is living, or that the BnF has not recorded one; the catalogue does not distinguish the two." : `This record states no date of birth and no date of death, so it dates the person nowhere and says nothing about whether they are living. ${SEVERAL_RECORDS_CAVEAT}`
|
|
1865
|
-
);
|
|
1866
|
-
}
|
|
1867
|
-
if (Object.keys(data.sameAs).length > 0) {
|
|
1868
|
-
notes.push(
|
|
1869
|
-
`${ALIGNMENTS_CAVEAT} Following one is how to reach a biography, which this catalogue does not hold.`
|
|
1870
|
-
);
|
|
1871
|
-
}
|
|
1872
|
-
if (args.include_depictions && data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
|
|
2061
|
+
notes.push(...notesOnWhatTheRecordStates(data, args));
|
|
1873
2062
|
const structured = {
|
|
1874
2063
|
author: {
|
|
1875
2064
|
id: data.id,
|
|
@@ -1911,11 +2100,11 @@ async function runGetAuthor(client, args) {
|
|
|
1911
2100
|
}));
|
|
1912
2101
|
}
|
|
1913
2102
|
const born = [
|
|
1914
|
-
data.birthDate ?? (data.birthYear
|
|
2103
|
+
data.birthDate ?? (data.birthYear === null ? null : String(data.birthYear)),
|
|
1915
2104
|
data.birthPlace
|
|
1916
2105
|
].filter(Boolean).join(", ");
|
|
1917
2106
|
const died = [
|
|
1918
|
-
data.deathDate ?? (data.deathYear
|
|
2107
|
+
data.deathDate ?? (data.deathYear === null ? null : String(data.deathYear)),
|
|
1919
2108
|
data.deathPlace
|
|
1920
2109
|
].filter(Boolean).join(", ");
|
|
1921
2110
|
const lines = [
|
|
@@ -1939,6 +2128,43 @@ async function runGetAuthor(client, args) {
|
|
|
1939
2128
|
|
|
1940
2129
|
// src/tools/getWork.ts
|
|
1941
2130
|
import { z as z5 } from "zod";
|
|
2131
|
+
function notesOnWhatTheRecordStates2(data, args) {
|
|
2132
|
+
const notes = [];
|
|
2133
|
+
if (data.status === "provisional") {
|
|
2134
|
+
notes.push(
|
|
2135
|
+
`This is a provisional record, addressed under 'temp-work' rather than by an ARK${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. ${PROVISIONAL_CAVEAT}`
|
|
2136
|
+
);
|
|
2137
|
+
} else {
|
|
2138
|
+
notes.push(
|
|
2139
|
+
`This is an established work record, addressed by the ARK '${data.id}'${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. That identifier is the one to cite.`
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
if (data.truncated) {
|
|
2143
|
+
notes.push(
|
|
2144
|
+
"This record is longer than one query reads, so parts of it are missing from this answer. A field shown as empty here may be filled on the record itself: open source_url before concluding that the BnF states nothing."
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
if (data.expressionCount !== null && data.expressionCount > 0) {
|
|
2148
|
+
notes.push(
|
|
2149
|
+
`The record links ${data.expressionCount} expression(s): translations, adaptations and recordings of this work. Published editions are a separate count, and list_editions is what reads them.`
|
|
2150
|
+
);
|
|
2151
|
+
}
|
|
2152
|
+
if (data.forms.length > 0) {
|
|
2153
|
+
notes.push(FORMS_CAVEAT);
|
|
2154
|
+
}
|
|
2155
|
+
if (data.creators.length === 0) {
|
|
2156
|
+
notes.push(
|
|
2157
|
+
"The record credits nobody with this work. That is normal for anonymous and traditional works, and it also happens on records a cataloguer has not finished."
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
if (Object.keys(data.sameAs).length > 0) {
|
|
2161
|
+
notes.push(ALIGNMENTS_CAVEAT);
|
|
2162
|
+
}
|
|
2163
|
+
if (args.include_depictions && data.depictions.length > 0) {
|
|
2164
|
+
notes.push(GALLICA_CAVEAT);
|
|
2165
|
+
}
|
|
2166
|
+
return notes;
|
|
2167
|
+
}
|
|
1942
2168
|
var getWorkDescription = [
|
|
1943
2169
|
"Read one work's record in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works returns.",
|
|
1944
2170
|
"It carries the title, everyone the record credits, the date the BnF gives the work, its language, its form and its subject with the Dewey class.",
|
|
@@ -1988,37 +2214,13 @@ var getWorkOutput = z5.object({
|
|
|
1988
2214
|
});
|
|
1989
2215
|
async function runGetWork(client, args) {
|
|
1990
2216
|
try {
|
|
1991
|
-
const id =
|
|
2217
|
+
const id = parseEntityId(args.work_id);
|
|
1992
2218
|
const { data, cached, retrievedAt } = await client.getWork(id);
|
|
1993
2219
|
const notes = [];
|
|
1994
|
-
if (cached)
|
|
1995
|
-
|
|
1996
|
-
notes.push(
|
|
1997
|
-
`This is a provisional record, addressed under 'temp-work' rather than by an ARK${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. ${PROVISIONAL_CAVEAT}`
|
|
1998
|
-
);
|
|
1999
|
-
} else {
|
|
2000
|
-
notes.push(
|
|
2001
|
-
`This is an established work record, addressed by the ARK '${data.id}'${data.statusStatement ? ` and stated as "${data.statusStatement}"` : ""}. That identifier is the one to cite.`
|
|
2002
|
-
);
|
|
2003
|
-
}
|
|
2004
|
-
if (data.truncated) {
|
|
2005
|
-
notes.push(
|
|
2006
|
-
"This record is longer than one query reads, so parts of it are missing from this answer. A field shown as empty here may be filled on the record itself: open source_url before concluding that the BnF states nothing."
|
|
2007
|
-
);
|
|
2008
|
-
}
|
|
2009
|
-
if (data.expressionCount !== null && data.expressionCount > 0) {
|
|
2010
|
-
notes.push(
|
|
2011
|
-
`The record links ${data.expressionCount} expression(s): translations, adaptations and recordings of this work. Published editions are a separate count, and list_editions is what reads them.`
|
|
2012
|
-
);
|
|
2220
|
+
if (cached) {
|
|
2221
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
2013
2222
|
}
|
|
2014
|
-
|
|
2015
|
-
if (data.creators.length === 0) {
|
|
2016
|
-
notes.push(
|
|
2017
|
-
"The record credits nobody with this work. That is normal for anonymous and traditional works, and it also happens on records a cataloguer has not finished."
|
|
2018
|
-
);
|
|
2019
|
-
}
|
|
2020
|
-
if (Object.keys(data.sameAs).length > 0) notes.push(ALIGNMENTS_CAVEAT);
|
|
2021
|
-
if (args.include_depictions && data.depictions.length > 0) notes.push(GALLICA_CAVEAT);
|
|
2223
|
+
notes.push(...notesOnWhatTheRecordStates2(data, args));
|
|
2022
2224
|
const structured = {
|
|
2023
2225
|
work: {
|
|
2024
2226
|
id: data.id,
|
|
@@ -2069,6 +2271,12 @@ async function runGetWork(client, args) {
|
|
|
2069
2271
|
|
|
2070
2272
|
// src/tools/listEditions.ts
|
|
2071
2273
|
import { z as z6 } from "zod";
|
|
2274
|
+
function nothingOnThisPage(page2, work) {
|
|
2275
|
+
if (page2 > 1) {
|
|
2276
|
+
return `Page ${page2} of the editions of "${work}" holds no row.`;
|
|
2277
|
+
}
|
|
2278
|
+
return `The BnF catalogue links no edition to the work "${work}".`;
|
|
2279
|
+
}
|
|
2072
2280
|
var listEditionsDescription = [
|
|
2073
2281
|
"List the published editions of one work in the Biblioth\xE8que nationale de France catalogue, by the identifier search_works or get_work returns.",
|
|
2074
2282
|
"Each row carries the publisher, the place, the year, the edition statement, the extent in the words of the record, the ISBN when there is one, and the record in the BnF general catalogue.",
|
|
@@ -2112,11 +2320,13 @@ var listEditionsOutput = z6.object({
|
|
|
2112
2320
|
});
|
|
2113
2321
|
async function runListEditions(client, args) {
|
|
2114
2322
|
try {
|
|
2115
|
-
const id =
|
|
2323
|
+
const id = parseEntityId(args.work_id);
|
|
2116
2324
|
const offset = (args.page - 1) * args.limit;
|
|
2117
2325
|
const { data, cached, retrievedAt } = await client.listEditions(id, args.limit, offset);
|
|
2118
2326
|
const notes = [];
|
|
2119
|
-
if (cached)
|
|
2327
|
+
if (cached) {
|
|
2328
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
2329
|
+
}
|
|
2120
2330
|
const editions = data.rows.map((row) => ({
|
|
2121
2331
|
id: row.id,
|
|
2122
2332
|
title: row.title,
|
|
@@ -2140,7 +2350,9 @@ async function runListEditions(client, args) {
|
|
|
2140
2350
|
source_url: row.sourceUrl
|
|
2141
2351
|
}));
|
|
2142
2352
|
const digitisedCount = editions.filter((edition) => edition.digitised.length > 0).length;
|
|
2143
|
-
if (digitisedCount > 0)
|
|
2353
|
+
if (digitisedCount > 0) {
|
|
2354
|
+
notes.push(GALLICA_CAVEAT);
|
|
2355
|
+
}
|
|
2144
2356
|
const bracketed = editions.some(
|
|
2145
2357
|
(edition) => (edition.publisher?.includes("[") ?? false) || (edition.place?.includes("[") ?? false)
|
|
2146
2358
|
);
|
|
@@ -2167,7 +2379,7 @@ async function runListEditions(client, args) {
|
|
|
2167
2379
|
"Rows are ordered by the address the catalogue gives each edition. It is neither chronological nor an order of importance, so a first edition can appear anywhere in the list. That order is the one the pages are cut along, so reading page after page reaches every edition once."
|
|
2168
2380
|
);
|
|
2169
2381
|
}
|
|
2170
|
-
const body = editions.length === 0 ? args.page
|
|
2382
|
+
const body = editions.length === 0 ? nothingOnThisPage(args.page, id.id) : `${editions.length} edition(s) of "${id.id}":
|
|
2171
2383
|
${editions.map((edition, index) => {
|
|
2172
2384
|
const imprint = [edition.place, edition.publisher, edition.date].filter(Boolean).join(" : ");
|
|
2173
2385
|
const extra = [
|
|
@@ -2199,7 +2411,9 @@ ${editions.map((edition, index) => {
|
|
|
2199
2411
|
}
|
|
2200
2412
|
}
|
|
2201
2413
|
async function assertWork(client, id) {
|
|
2202
|
-
if (id.kind === "temp-work")
|
|
2414
|
+
if (id.kind === "temp-work") {
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2203
2417
|
const types = await client.types(id);
|
|
2204
2418
|
if (types.data.length === 0) {
|
|
2205
2419
|
throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
|
|
@@ -2281,11 +2495,13 @@ function emptyPageBody(emptiness, page2, authorId) {
|
|
|
2281
2495
|
}
|
|
2282
2496
|
async function runListWorks(client, args) {
|
|
2283
2497
|
try {
|
|
2284
|
-
const id =
|
|
2498
|
+
const id = parseEntityId(args.author_id);
|
|
2285
2499
|
const offset = (args.page - 1) * args.limit;
|
|
2286
2500
|
const { data, cached, retrievedAt } = await client.worksByAuthor(id, args.limit, offset);
|
|
2287
2501
|
const notes = [];
|
|
2288
|
-
if (cached)
|
|
2502
|
+
if (cached) {
|
|
2503
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
2504
|
+
}
|
|
2289
2505
|
const works = data.rows.map((row) => ({
|
|
2290
2506
|
id: row.id,
|
|
2291
2507
|
title: row.title,
|
|
@@ -2307,7 +2523,9 @@ async function runListWorks(client, args) {
|
|
|
2307
2523
|
const first = await client.worksByAuthor(id, 1, 0);
|
|
2308
2524
|
return first.data.rows.length > 0;
|
|
2309
2525
|
});
|
|
2310
|
-
if (emptiness === "absent")
|
|
2526
|
+
if (emptiness === "absent") {
|
|
2527
|
+
await assertPerson(client, id);
|
|
2528
|
+
}
|
|
2311
2529
|
notes.push(emptyPageNote(emptiness, args.page, id.id));
|
|
2312
2530
|
} else {
|
|
2313
2531
|
if (data.hasMore) {
|
|
@@ -2316,7 +2534,9 @@ async function runListWorks(client, args) {
|
|
|
2316
2534
|
);
|
|
2317
2535
|
}
|
|
2318
2536
|
notes.push(`${FORMS_CAVEAT} ${FORMS_FILTER_CAVEAT}`);
|
|
2319
|
-
if (works.some((work) => work.status === "provisional"))
|
|
2537
|
+
if (works.some((work) => work.status === "provisional")) {
|
|
2538
|
+
notes.push(PROVISIONAL_CAVEAT);
|
|
2539
|
+
}
|
|
2320
2540
|
notes.push(ORDER_CAVEAT, DATE_CAVEAT);
|
|
2321
2541
|
}
|
|
2322
2542
|
const body = works.length === 0 ? emptyPageBody(emptiness, args.page, id.id) : `${works.length} work(s) the catalogue links to "${id.id}" as their creator:
|
|
@@ -2364,6 +2584,7 @@ async function assertPerson(client, id) {
|
|
|
2364
2584
|
|
|
2365
2585
|
// src/tools/searchAuthors.ts
|
|
2366
2586
|
import { z as z8 } from "zod";
|
|
2587
|
+
var JOINER = /['-]/u;
|
|
2367
2588
|
var searchAuthorsDescription = [
|
|
2368
2589
|
"Find a person in the Biblioth\xE8que nationale de France authority file by name, and get the identifier the other tools take.",
|
|
2369
2590
|
"This matches the name the BnF records, so it takes a surname, a full name or both names in either order. It does not read biographies, so it cannot find a person from what they wrote or what they did.",
|
|
@@ -2407,9 +2628,15 @@ var searchAuthorsOutput = z8.object({
|
|
|
2407
2628
|
});
|
|
2408
2629
|
var SPELLING_CAVEAT = "The index matches the words letter for letter, so a name written with other accents, or under another transliteration, is a different search reaching different records. A short answer is no evidence that the file holds nobody else of that name: ask again with the accents dropped, with them restored, and under the transliterations a library uses.";
|
|
2409
2630
|
function lifespan(birthYear, deathYear) {
|
|
2410
|
-
if (birthYear !== null && deathYear !== null)
|
|
2411
|
-
|
|
2412
|
-
|
|
2631
|
+
if (birthYear !== null && deathYear !== null) {
|
|
2632
|
+
return ` (${birthYear}\u2013${deathYear})`;
|
|
2633
|
+
}
|
|
2634
|
+
if (birthYear !== null) {
|
|
2635
|
+
return ` (born ${birthYear})`;
|
|
2636
|
+
}
|
|
2637
|
+
if (deathYear !== null) {
|
|
2638
|
+
return ` (died ${deathYear})`;
|
|
2639
|
+
}
|
|
2413
2640
|
return "";
|
|
2414
2641
|
}
|
|
2415
2642
|
function emptyPageNote2(emptiness, page2, words, windowFull) {
|
|
@@ -2439,7 +2666,7 @@ function emptyPageBody2(emptiness, page2, name, windowFull) {
|
|
|
2439
2666
|
}
|
|
2440
2667
|
async function runSearchAuthors(client, args) {
|
|
2441
2668
|
try {
|
|
2442
|
-
const reading =
|
|
2669
|
+
const reading = readSearchText(args.name);
|
|
2443
2670
|
const words = reading.words;
|
|
2444
2671
|
if (words.length === 0) {
|
|
2445
2672
|
return toToolError(
|
|
@@ -2462,12 +2689,16 @@ async function runSearchAuthors(client, args) {
|
|
|
2462
2689
|
const terms = reading.terms;
|
|
2463
2690
|
const windowFull = data.indexWindowFull ?? null;
|
|
2464
2691
|
const notes = [];
|
|
2465
|
-
if (cached)
|
|
2692
|
+
if (cached) {
|
|
2693
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
2694
|
+
}
|
|
2466
2695
|
if (windowFull) {
|
|
2467
2696
|
notes.push(WINDOW_FULL_CAVEAT);
|
|
2468
|
-
if (!data.hasMore)
|
|
2697
|
+
if (!data.hasMore) {
|
|
2698
|
+
notes.push(WINDOW_FULL_AND_NO_MORE);
|
|
2699
|
+
}
|
|
2469
2700
|
}
|
|
2470
|
-
const split = words.filter((word) =>
|
|
2701
|
+
const split = words.filter((word) => JOINER.test(word));
|
|
2471
2702
|
if (split.length > 0) {
|
|
2472
2703
|
notes.push(
|
|
2473
2704
|
`${split.map((word) => `"${word}"`).join(", ")} reached the index as separate terms: it cuts a word at an apostrophe and at a hyphen, and requires each piece to appear in the name rather than in that arrangement, so a record writing the pieces apart is a match.`
|
|
@@ -2483,7 +2714,9 @@ async function runSearchAuthors(client, args) {
|
|
|
2483
2714
|
role: row.role,
|
|
2484
2715
|
source_url: row.sourceUrl
|
|
2485
2716
|
}));
|
|
2486
|
-
if (authors.length > 0)
|
|
2717
|
+
if (authors.length > 0) {
|
|
2718
|
+
notes.push(NO_RANKING);
|
|
2719
|
+
}
|
|
2487
2720
|
notes.push(SPELLING_CAVEAT);
|
|
2488
2721
|
const disputed = authors.map((author) => ({
|
|
2489
2722
|
label: author.label ?? author.name,
|
|
@@ -2497,7 +2730,9 @@ async function runSearchAuthors(client, args) {
|
|
|
2497
2730
|
const repeated = /* @__PURE__ */ new Map();
|
|
2498
2731
|
for (const author of authors) {
|
|
2499
2732
|
const key = (author.name ?? "").toLocaleLowerCase("fr");
|
|
2500
|
-
if (key !== "")
|
|
2733
|
+
if (key !== "") {
|
|
2734
|
+
repeated.set(key, (repeated.get(key) ?? 0) + 1);
|
|
2735
|
+
}
|
|
2501
2736
|
}
|
|
2502
2737
|
const shared = [...repeated.entries()].filter(([, count]) => count > 1);
|
|
2503
2738
|
if (shared.length > 0) {
|
|
@@ -2546,6 +2781,7 @@ ${authors.map((author, index) => {
|
|
|
2546
2781
|
|
|
2547
2782
|
// src/tools/searchWorks.ts
|
|
2548
2783
|
import { z as z9 } from "zod";
|
|
2784
|
+
var JOINER2 = /['-]/u;
|
|
2549
2785
|
var searchWorksDescription = [
|
|
2550
2786
|
"Find a work in the Biblioth\xE8que nationale de France catalogue by words in its title, and get the identifier get_work and list_editions take.",
|
|
2551
2787
|
"A row matches when its title carries every word given. The index returns no measure of how well a row matches, so the rows are ordered by the address of the record and the work a person would call the obvious answer can sit anywhere in the list or on a later page.",
|
|
@@ -2612,7 +2848,7 @@ function emptyPageBody3(emptiness, page2, title, windowFull) {
|
|
|
2612
2848
|
}
|
|
2613
2849
|
async function runSearchWorks(client, args) {
|
|
2614
2850
|
try {
|
|
2615
|
-
const reading =
|
|
2851
|
+
const reading = readSearchText(args.title);
|
|
2616
2852
|
const words = reading.words;
|
|
2617
2853
|
if (words.length === 0) {
|
|
2618
2854
|
return toToolError(
|
|
@@ -2635,12 +2871,16 @@ async function runSearchWorks(client, args) {
|
|
|
2635
2871
|
const terms = reading.terms;
|
|
2636
2872
|
const windowFull = data.indexWindowFull ?? null;
|
|
2637
2873
|
const notes = [];
|
|
2638
|
-
if (cached)
|
|
2874
|
+
if (cached) {
|
|
2875
|
+
notes.push("Served from this server's short-lived in-memory cache.");
|
|
2876
|
+
}
|
|
2639
2877
|
if (windowFull) {
|
|
2640
2878
|
notes.push(WINDOW_FULL_CAVEAT);
|
|
2641
|
-
if (!data.hasMore)
|
|
2879
|
+
if (!data.hasMore) {
|
|
2880
|
+
notes.push(WINDOW_FULL_AND_NO_MORE);
|
|
2881
|
+
}
|
|
2642
2882
|
}
|
|
2643
|
-
const split = words.filter((word) =>
|
|
2883
|
+
const split = words.filter((word) => JOINER2.test(word));
|
|
2644
2884
|
if (split.length > 0) {
|
|
2645
2885
|
notes.push(
|
|
2646
2886
|
`${split.map((word) => `"${word}"`).join(", ")} reached the index as separate terms: it cuts a word at an apostrophe and at a hyphen, and requires each piece to appear in the title rather than in that arrangement, so a record writing the pieces apart is a match.`
|
|
@@ -2655,8 +2895,12 @@ async function runSearchWorks(client, args) {
|
|
|
2655
2895
|
status: row.status,
|
|
2656
2896
|
source_url: row.sourceUrl
|
|
2657
2897
|
}));
|
|
2658
|
-
if (works.length > 0)
|
|
2659
|
-
|
|
2898
|
+
if (works.length > 0) {
|
|
2899
|
+
notes.push(NO_RANKING);
|
|
2900
|
+
}
|
|
2901
|
+
if (works.some((work) => work.status === "provisional")) {
|
|
2902
|
+
notes.push(PROVISIONAL_CAVEAT);
|
|
2903
|
+
}
|
|
2660
2904
|
if (terms.length > 2) {
|
|
2661
2905
|
notes.push(
|
|
2662
2906
|
`All ${terms.length} terms were required: ${terms.map((term) => `"${term}"`).join(", ")}. A record catalogued under a shorter form of the title carries fewer of them and falls out of the answer, so dropping a word widens the search.`
|
|
@@ -2828,15 +3072,15 @@ async function main() {
|
|
|
2828
3072
|
await server.connect(transport);
|
|
2829
3073
|
const shutdown = async () => {
|
|
2830
3074
|
await server.close().catch(() => void 0);
|
|
2831
|
-
|
|
3075
|
+
process2.exit(0);
|
|
2832
3076
|
};
|
|
2833
|
-
|
|
2834
|
-
|
|
3077
|
+
process2.on("SIGINT", shutdown);
|
|
3078
|
+
process2.on("SIGTERM", shutdown);
|
|
2835
3079
|
}
|
|
2836
3080
|
main().catch((error) => {
|
|
2837
3081
|
const message = error instanceof Error ? error.message : String(error);
|
|
2838
|
-
|
|
3082
|
+
process2.stderr.write(`[mcp-databnf] fatal: ${message}
|
|
2839
3083
|
`);
|
|
2840
|
-
|
|
3084
|
+
process2.exit(1);
|
|
2841
3085
|
});
|
|
2842
3086
|
//# sourceMappingURL=index.js.map
|