fh-library-dev 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +10 -2
  3. package/dist/index.js +4063 -0
  4. package/package.json +24 -4
package/dist/index.js ADDED
@@ -0,0 +1,4063 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/env.ts
4
+ import { existsSync, readFileSync } from "fs";
5
+ import { join, resolve } from "path";
6
+ function dotenvFile(env = process.env) {
7
+ const named = env.FH_LIBRARY_ENV?.trim();
8
+ if (named) return named;
9
+ if (existsSync(resolve(".env"))) return resolve(".env");
10
+ const lib = env.FH_LIBRARY_DIR?.trim() || (existsSync("/library") ? "/library" : "library");
11
+ return join(resolve(lib), ".env");
12
+ }
13
+ var loadedFromDotenv = /* @__PURE__ */ new Set();
14
+ function loadDotenv(file = dotenvFile()) {
15
+ if (!existsSync(file)) return [];
16
+ const loaded = [];
17
+ for (const raw of readFileSync(file, "utf8").split(/\r?\n/)) {
18
+ const line = raw.trim();
19
+ if (!line || line.startsWith("#")) continue;
20
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
21
+ if (!m) continue;
22
+ let v = m[2].trim();
23
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
24
+ else v = v.replace(/\s+#.*$/, "");
25
+ if (process.env[m[1]] === void 0) {
26
+ process.env[m[1]] = v;
27
+ loaded.push(m[1]);
28
+ loadedFromDotenv.add(m[1]);
29
+ }
30
+ }
31
+ return loaded;
32
+ }
33
+ function sourceOf(name, env = process.env) {
34
+ if (!env[name]?.trim()) return "default";
35
+ return loadedFromDotenv.has(name) ? ".env" : "environment";
36
+ }
37
+ var str = (name, env = process.env) => env[name]?.trim() ?? "";
38
+ function num(name, def, env = process.env, ok = (n) => n >= 0) {
39
+ const v = str(name, env);
40
+ if (!v) return def;
41
+ const n = Number(v);
42
+ return Number.isFinite(n) && ok(n) ? n : def;
43
+ }
44
+ function bool(name, def, env = process.env) {
45
+ const v = str(name, env).toLowerCase();
46
+ return ["true", "1", "yes", "on"].includes(v) ? true : ["false", "0", "no", "off"].includes(v) ? false : def;
47
+ }
48
+
49
+ // src/env-load.ts
50
+ loadDotenv();
51
+
52
+ // src/index.ts
53
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
54
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
55
+
56
+ // src/contact.ts
57
+ var DEFAULT = "hello@forever-healthy.org";
58
+ var contactEmail = (env = process.env) => str("FH_LIBRARY_CONTACT_EMAIL", env) || DEFAULT;
59
+
60
+ // src/http.ts
61
+ var TIMEOUT_MS = 3e4;
62
+ var CHROME_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
63
+ var LIBRARY_UA = `fh-library (+https://github.com/forever-healthy/FH-library; ${contactEmail()})`;
64
+ function httpReason(status, body) {
65
+ const m = body.length < 4e3 && /<statusCode>([A-Z_]+)<\/statusCode>|"statusCode"\s*:\s*"([A-Z_]+)"/.exec(body);
66
+ return m ? `HTTP ${status} ${m[1] ?? m[2]}` : `HTTP ${status}`;
67
+ }
68
+ var DOWNLOAD_HEADERS = { "User-Agent": CHROME_UA, "Accept": "application/pdf,text/html,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9" };
69
+ async function fetchBytes(url, headers = DOWNLOAD_HEADERS) {
70
+ let last = "unknown error", finalUrl = "", lastBody = "", lastType = "";
71
+ for (let attempt = 0; attempt < 2; attempt++) {
72
+ try {
73
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(TIMEOUT_MS), redirect: "follow" });
74
+ if (res.ok) return { bytes: Buffer.from(await res.arrayBuffer()), contentType: res.headers.get("content-type") ?? "", url: res.url };
75
+ lastBody = (await res.text().catch(() => "")).slice(0, 4e3);
76
+ lastType = res.headers.get("content-type") ?? "";
77
+ last = res.status < 500 ? httpReason(res.status, lastBody) : `HTTP ${res.status}`;
78
+ finalUrl = res.url;
79
+ if (res.status !== 429 && res.status < 500) break;
80
+ } catch (e) {
81
+ last = e instanceof Error && e.name === "TimeoutError" ? "timeout" : e instanceof Error ? e.message : String(e);
82
+ }
83
+ await new Promise((r) => setTimeout(r, 1500));
84
+ }
85
+ throw Object.assign(new Error(last), finalUrl ? { url: finalUrl } : {}, lastBody ? { body: lastBody, contentType: lastType } : {});
86
+ }
87
+ async function fetchText(url, headers = {}) {
88
+ let last = "unknown error";
89
+ for (let attempt = 0; attempt < 2; attempt++) {
90
+ try {
91
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(TIMEOUT_MS), redirect: "follow" });
92
+ if (res.ok) return await res.text();
93
+ last = res.status < 500 ? httpReason(res.status, await res.text().catch(() => "")) : `HTTP ${res.status}`;
94
+ if (res.status !== 429 && res.status < 500) break;
95
+ } catch (e) {
96
+ last = e instanceof Error && e.name === "TimeoutError" ? "timeout" : e instanceof Error ? e.message : String(e);
97
+ }
98
+ await new Promise((r) => setTimeout(r, 1500));
99
+ }
100
+ throw new Error(last);
101
+ }
102
+
103
+ // src/checks.ts
104
+ var GREEK = { "\u03B1": "alpha", "\u03B2": "beta", "\xDF": "beta", "\u03B3": "gamma", "\u03B4": "delta", "\u03B5": "epsilon", "\u03B6": "zeta", "\u03B7": "eta", "\u03B8": "theta", "\u03B9": "iota", "\u03BA": "kappa", "\u03BB": "lambda", "\u03BC": "mu", "\xB5": "mu", "\u03BD": "nu", "\u03BE": "xi", "\u03C0": "pi", "\u03C1": "rho", "\u03C3": "sigma", "\u03C4": "tau", "\u03C5": "upsilon", "\u03C6": "phi", "\u03C7": "chi", "\u03C8": "psi", "\u03C9": "omega", "\u0391": "alpha", "\u0392": "beta", "\u0393": "gamma", "\u0394": "delta", "\u03A9": "omega", "\u03A3": "sigma", "\u039A": "kappa", "\u039B": "lambda", "\u03A0": "pi", "\u03A6": "phi", "\u03A8": "psi" };
105
+ var greek = (s) => s.replace(/[\u0370-\u03ff\u00b5\u00df]/g, (c) => GREEK[c] ?? c);
106
+ var norm = (s) => greek(s).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
107
+ var compact = (s) => greek(s).toLowerCase().replace(/[^a-z0-9]+/g, "");
108
+ function hasTitle(md, title) {
109
+ const t = norm(title);
110
+ if (!t) return true;
111
+ if (norm(md).includes(t.slice(0, 60))) return true;
112
+ return compact(md).includes(compact(title).slice(0, 50));
113
+ }
114
+ var WALL = [
115
+ /checking your browser/i,
116
+ /recaptcha/i,
117
+ /verify (that )?you are (a )?human/i,
118
+ /human verification/i,
119
+ /access denied/i,
120
+ /enable javascript/i,
121
+ /javascript is (disabled|required)/i,
122
+ /cloudflare/i,
123
+ /captcha/i,
124
+ /sign in to (continue|access)/i,
125
+ /purchase (access|this article|pdf)/i,
126
+ /subscribe to (read|access)/i,
127
+ /cookie.{0,40}(consent|wall)/i,
128
+ /403 forbidden/i,
129
+ /page not found/i
130
+ ];
131
+ function isWallPage(text3) {
132
+ const short = text3.length < 4e3;
133
+ if (short && WALL.some((re) => re.test(text3))) return true;
134
+ const title = /<title>([^<]*)<\/title>/i.exec(text3)?.[1] ?? "";
135
+ return /checking your browser|recaptcha|just a moment|access denied|attention required|human verification/i.test(title);
136
+ }
137
+ var headings = (md) => md.split("\n").filter((l) => /^#{1,6}\s/.test(l)).map((l) => l.replace(/^#+\s*/, ""));
138
+ function proseWords(md) {
139
+ let n = 0;
140
+ for (const p of md.split(/\n\s*\n/).map((x) => x.trim()).filter((x) => x && !/^#{1,6}\s/.test(x))) {
141
+ const words2 = p.split(/\s+/).length;
142
+ if (words2 < 40 || /^(-|\*|\d+\.)\s/.test(p)) continue;
143
+ const linkChars = (p.match(/\[[^\]]*\]\([^)]*\)/g) ?? []).join("").length;
144
+ if (linkChars / p.length > 0.3) continue;
145
+ n += words2;
146
+ }
147
+ return n;
148
+ }
149
+ var MIN_PROSE = 800;
150
+ function hasMethodsAndResults(md) {
151
+ const hs = headings(md);
152
+ const has = (re) => hs.some((h) => re.test(h));
153
+ if (has(/method|material|procedure|design|experimental|patients? and|participants/i) && has(/result|finding|outcome/i)) return true;
154
+ const words2 = md.split(/\s+/).length;
155
+ if (words2 < 1200 || hs.length < 2) return false;
156
+ const SECTION = /^(abstract|summary|introduction|background|discussion|conclusions?|references|acknowledg|limitations|key points|funding|participants|statistical|supplementary|data availability|author contributions|competing interests|conflicts? of interest|disclosure)/i;
157
+ return hs.filter((h) => SECTION.test(h.trim())).length >= 2;
158
+ }
159
+ var hasDoi = (md, doi) => !!doi && compact(md).includes(compact(doi));
160
+ function checkFulltext(md, title, titleSource, doi, trusted = false) {
161
+ if (isWallPage(md)) return { ok: false, reason: "wall page" };
162
+ const inBody = hasTitle(md, title);
163
+ if (!inBody && !(titleSource && hasTitle(titleSource, title)) && !hasDoi(md, doi)) return { ok: false, reason: "title missing" };
164
+ if (!trusted && proseWords(md) < MIN_PROSE) return { ok: false, reason: "no methods/results" };
165
+ if (!hasMethodsAndResults(md)) return { ok: false, reason: "no methods/results" };
166
+ return { ok: true };
167
+ }
168
+
169
+ // src/html.ts
170
+ import TurndownService from "turndown";
171
+ import { gfm } from "@joplin/turndown-plugin-gfm";
172
+ var td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-" });
173
+ td.use(gfm);
174
+ td.remove(["script", "style", "nav", "noscript", "iframe", "button", "input", "select", "textarea", "header", "footer", "aside"]);
175
+ var FURNITURE = /gdpr|cookie|onetrust|consent|advert|\bad-slot\b/i;
176
+ td.remove(((n) => ["div", "section"].includes(String(n.nodeName).toLowerCase()) && FURNITURE.test(`${n.className ?? ""} ${n.id ?? ""}`)));
177
+ td.addRule("drop-svg", { filter: (n) => n.nodeName.toLowerCase() === "svg", replacement: () => "" });
178
+ td.addRule("drop-images", { filter: ["img"], replacement: () => "" });
179
+ td.addRule("div-headings", {
180
+ filter: (n) => String(n.nodeName).toLowerCase() === "div" && /(^|\s)h[1-6](\s|$)/.test(String(n.className ?? "")),
181
+ replacement: (content, n) => {
182
+ const level = /(^|\s)h([1-6])(\s|$)/.exec(String(n.className))[2];
183
+ return `
184
+
185
+ ${"#".repeat(Number(level))} ${content.trim()}
186
+
187
+ `;
188
+ }
189
+ });
190
+ function htmlToMarkdown(html) {
191
+ return td.turndown(html).replace(/\n{3,}/g, "\n\n").trim() + "\n";
192
+ }
193
+
194
+ // src/jats.ts
195
+ import { XMLParser } from "fast-xml-parser";
196
+ var parser = new XMLParser({
197
+ preserveOrder: true,
198
+ ignoreAttributes: false,
199
+ attributeNamePrefix: "@_",
200
+ parseTagValue: false,
201
+ trimValues: false,
202
+ processEntities: true,
203
+ htmlEntities: true
204
+ });
205
+ var tagOf = (n) => Object.keys(n).find((k) => k !== ":@") ?? "";
206
+ var kids = (n) => n[tagOf(n)] ?? [];
207
+ var attr = (n, a) => n[":@"]?.[`@_${a}`] ?? "";
208
+ var SKIP_INLINE = /* @__PURE__ */ new Set(["inline-graphic", "graphic", "private-char"]);
209
+ function formula(n) {
210
+ const all = [n, ...descendants(n)];
211
+ const tex = all.find((x) => tagOf(x) === "tex-math");
212
+ if (tex) {
213
+ let t = plainText(kids(tex)).trim();
214
+ const m = /\\begin\{document\}([\s\S]*?)\\end\{document\}/.exec(t);
215
+ if (m) t = m[1].trim();
216
+ t = t.replace(/^\$+|\$+$/g, "").trim();
217
+ return t ? `$${t}$` : "";
218
+ }
219
+ const mml = all.find((x) => /^(mml:)?math$/.test(tagOf(x)));
220
+ return mml ? plainText(kids(mml)).replace(/\s+/g, "") : plainText(kids(n));
221
+ }
222
+ function descendants(n) {
223
+ if ("#text" in n) return [];
224
+ const out = [];
225
+ for (const k of kids(n)) {
226
+ out.push(k, ...descendants(k));
227
+ }
228
+ return out;
229
+ }
230
+ var wrap = (mark, s) => {
231
+ const m = /^(\s*)([\s\S]*?)(\s*)$/.exec(s);
232
+ return m[2] ? `${m[1]}${mark}${m[2]}${mark}${m[3]}` : m[1] + m[3];
233
+ };
234
+ function inlineText(nodes) {
235
+ let out = "";
236
+ for (const n of nodes) {
237
+ if ("#text" in n) {
238
+ out += String(n["#text"]);
239
+ continue;
240
+ }
241
+ const t = tagOf(n);
242
+ if (SKIP_INLINE.has(t)) continue;
243
+ const inner = () => inlineText(kids(n));
244
+ switch (t) {
245
+ case "italic":
246
+ case "i":
247
+ out += `*${inner()}*`;
248
+ break;
249
+ case "bold":
250
+ case "b":
251
+ out += `**${inner()}**`;
252
+ break;
253
+ case "sup":
254
+ out += wrap("^", inner());
255
+ break;
256
+ case "sub":
257
+ out += wrap("~", inner());
258
+ break;
259
+ case "break":
260
+ out += " ";
261
+ break;
262
+ case "ext-link":
263
+ case "uri": {
264
+ const href = attr(n, "xlink:href") || attr(n, "href");
265
+ const text3 = inner().trim() || href;
266
+ out += href ? `[${text3}](${href})` : text3;
267
+ break;
268
+ }
269
+ case "inline-formula":
270
+ case "tex-math":
271
+ case "mml:math":
272
+ case "alternatives":
273
+ out += formula(n);
274
+ break;
275
+ default:
276
+ out += inner();
277
+ }
278
+ }
279
+ return out;
280
+ }
281
+ function plainText(nodes) {
282
+ let out = "";
283
+ for (const n of nodes) out += "#text" in n ? String(n["#text"]) : plainText(kids(n));
284
+ return out;
285
+ }
286
+ var squash = (s) => s.replace(/\s+/g, " ").trim();
287
+ var cell = (s) => squash(s).replace(/\|/g, "\\|");
288
+ function renderTable(table3) {
289
+ const rows = [];
290
+ const walk = (nodes) => {
291
+ for (const n of nodes) {
292
+ const t = tagOf(n);
293
+ if (t === "tr") rows.push(kids(n).filter((c) => ["td", "th"].includes(tagOf(c))).map((c) => cell(inlineText(kids(c)))));
294
+ else if (!("#text" in n)) walk(kids(n));
295
+ }
296
+ };
297
+ walk(kids(table3));
298
+ if (!rows.length) return "";
299
+ const width = Math.max(...rows.map((r) => r.length));
300
+ const pad = (r) => [...r, ...Array(width - r.length).fill("")];
301
+ const line = (r) => `| ${pad(r).join(" | ")} |`;
302
+ return [line(rows[0]), `| ${Array(width).fill("---").join(" | ")} |`, ...rows.slice(1).map(line)].join("\n");
303
+ }
304
+ function labelCaption(n) {
305
+ const label = n ? squash(plainText(kids(n).filter((c) => tagOf(c) === "label"))) : "";
306
+ const cap = kids(n).find((c) => tagOf(c) === "caption");
307
+ const capText = cap ? squash(inlineText(kids(cap))) : "";
308
+ return [label ? `**${label}.**` : "", capText].filter(Boolean).join(" ");
309
+ }
310
+ function renderBlocks(nodes, depth) {
311
+ const out = [];
312
+ for (const n of nodes) {
313
+ if ("#text" in n) {
314
+ const t2 = squash(String(n["#text"]));
315
+ if (t2) out.push(t2);
316
+ continue;
317
+ }
318
+ const t = tagOf(n);
319
+ const c = kids(n);
320
+ switch (t) {
321
+ case "sec":
322
+ case "app":
323
+ case "ack":
324
+ case "boxed-text":
325
+ case "notes":
326
+ case "glossary": {
327
+ const title = c.find((x) => tagOf(x) === "title");
328
+ if (title) out.push(`${"#".repeat(Math.min(depth + 1, 6))} ${squash(inlineText(kids(title)))}`);
329
+ else if (t === "ack") out.push(`${"#".repeat(Math.min(depth + 1, 6))} Acknowledgements`);
330
+ out.push(...renderBlocks(c.filter((x) => tagOf(x) !== "title"), depth + 1));
331
+ break;
332
+ }
333
+ case "title":
334
+ out.push(`${"#".repeat(Math.min(depth + 1, 6))} ${squash(inlineText(c))}`);
335
+ break;
336
+ case "p": {
337
+ const BLOCK = /* @__PURE__ */ new Set(["table-wrap", "fig", "list", "disp-formula", "disp-quote", "boxed-text", "supplementary-material"]);
338
+ const inline2 = c.filter((x) => "#text" in x || !BLOCK.has(tagOf(x)));
339
+ const blocks2 = c.filter((x) => !("#text" in x) && BLOCK.has(tagOf(x)));
340
+ const s = squash(inlineText(inline2));
341
+ if (s) out.push(s);
342
+ out.push(...renderBlocks(blocks2, depth));
343
+ break;
344
+ }
345
+ case "list": {
346
+ const ordered = /order|alpha|roman|number/i.test(attr(n, "list-type"));
347
+ let i = 0;
348
+ for (const li of c.filter((x) => tagOf(x) === "list-item")) {
349
+ i++;
350
+ const body = renderBlocks(kids(li).filter((x) => tagOf(x) !== "label"), depth).join(" ");
351
+ out.push(`${ordered ? `${i}.` : "-"} ${body}`);
352
+ }
353
+ break;
354
+ }
355
+ case "table-wrap": {
356
+ const head = labelCaption(n);
357
+ if (head) out.push(head);
358
+ for (const tbl of c.filter((x) => tagOf(x) === "table")) {
359
+ const md = renderTable(tbl);
360
+ if (md) out.push(md);
361
+ }
362
+ const foot = c.find((x) => tagOf(x) === "table-wrap-foot");
363
+ if (foot) out.push(...renderBlocks(kids(foot), depth));
364
+ break;
365
+ }
366
+ case "fig": {
367
+ const head = labelCaption(n);
368
+ out.push(head || "*(figure)*");
369
+ break;
370
+ }
371
+ case "disp-formula": {
372
+ const s = squash(formula(n));
373
+ if (s) out.push(s);
374
+ break;
375
+ }
376
+ case "disp-quote":
377
+ out.push(...renderBlocks(c, depth).map((l) => `> ${l}`));
378
+ break;
379
+ case "def-list":
380
+ for (const d of c.filter((x) => tagOf(x) === "def-item")) {
381
+ const term = d["def-item"].find((x) => tagOf(x) === "term");
382
+ const def = d["def-item"].find((x) => tagOf(x) === "def");
383
+ out.push(`**${squash(inlineText(term ? kids(term) : []))}**: ${def ? renderBlocks(kids(def), depth).join(" ") : ""}`);
384
+ }
385
+ break;
386
+ case "ref-list": {
387
+ const title = c.find((x) => tagOf(x) === "title");
388
+ out.push(`${"#".repeat(Math.min(depth + 1, 6))} ${title ? squash(inlineText(kids(title))) : "References"}`);
389
+ for (const r of c.filter((x) => tagOf(x) === "ref")) {
390
+ const rk = kids(r);
391
+ const label = squash(plainText(rk.filter((x) => tagOf(x) === "label")));
392
+ out.push(`- ${label ? `${label} ` : ""}${renderCitation(rk.filter((x) => tagOf(x) !== "label"))}`);
393
+ }
394
+ break;
395
+ }
396
+ case "supplementary-material":
397
+ case "graphic":
398
+ case "media":
399
+ case "fn-group":
400
+ case "label":
401
+ break;
402
+ default:
403
+ out.push(...renderBlocks(c, depth));
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+ function renderCitation(nodes) {
409
+ const alt = nodes.find((x) => tagOf(x) === "citation-alternatives");
410
+ if (alt) return renderCitation(kids(alt));
411
+ const mixed = nodes.find((x) => ["mixed-citation", "citation", "nlm-citation"].includes(tagOf(x)) && !kids(x).some((y) => tagOf(y) === "person-group"));
412
+ if (mixed) return squash(inlineText(kids(mixed)));
413
+ const el = nodes.find((x) => ["element-citation", "mixed-citation", "citation", "nlm-citation"].includes(tagOf(x)));
414
+ if (!el) return squash(inlineText(nodes));
415
+ const f = kids(el);
416
+ const get = (tag) => f.filter((x) => tagOf(x) === tag).map((x) => squash(inlineText(kids(x))));
417
+ const names = f.filter((x) => tagOf(x) === "person-group").flatMap((g) => kids(g)).map((x) => {
418
+ const t = tagOf(x);
419
+ if (t === "etal") return "et al";
420
+ if (t === "collab") return squash(plainText(kids(x)));
421
+ if (t === "name" || t === "string-name") {
422
+ const sur = squash(plainText(kids(x).filter((y) => tagOf(y) === "surname")));
423
+ const giv = squash(plainText(kids(x).filter((y) => tagOf(y) === "given-names")));
424
+ return [sur, giv].filter(Boolean).join(" ");
425
+ }
426
+ return "";
427
+ }).filter(Boolean);
428
+ const vol = get("volume")[0], issue = get("issue")[0], fp = get("fpage")[0], lp = get("lpage")[0];
429
+ const pages = fp ? lp ? `${fp}-${lp}` : fp : get("elocation-id")[0];
430
+ const where = [get("source")[0], get("year")[0] ? `${get("year")[0]};` : "", vol ? `${vol}${issue ? `(${issue})` : ""}` : "", pages ? `:${pages}` : ""].filter(Boolean).join("").replace(/;$/, "");
431
+ const ids = f.filter((x) => tagOf(x) === "pub-id").map((x) => `${attr(x, "pub-id-type")}:${squash(plainText(kids(x)))}`);
432
+ return [names.join(", "), [...get("article-title"), ...get("chapter-title")][0], where, ...ids].filter(Boolean).join(". ").replace(/\.\./g, ".");
433
+ }
434
+ var FN_LABELS = {
435
+ "conflict": "Conflicts of interest",
436
+ "COI-statement": "Conflicts of interest",
437
+ "coi-statement": "Conflicts of interest",
438
+ "financial-disclosure": "Financial disclosure",
439
+ "supported-by": "Funding",
440
+ "funding": "Funding",
441
+ "equal": "Author note",
442
+ "con": "Contributions",
443
+ "contributed-by": "Contributions",
444
+ "other": ""
445
+ };
446
+ function declarations(meta, back) {
447
+ const out = [];
448
+ const fg = meta ? findDeep(kids(meta), "funding-group") : void 0;
449
+ if (fg) {
450
+ const statements = kids(fg).filter((x) => tagOf(x) === "funding-statement").map((x) => squash(inlineText(kids(x)))).filter(Boolean);
451
+ const awards = kids(fg).filter((x) => tagOf(x) === "award-group").map((ag) => {
452
+ const src = kids(ag).filter((x) => tagOf(x) === "funding-source").map((x) => squash(plainText(kids(x)))).filter(Boolean);
453
+ const ids = kids(ag).filter((x) => tagOf(x) === "award-id").map((x) => squash(plainText(kids(x)))).filter(Boolean);
454
+ return [src.join(", "), ids.length ? `(${ids.join(", ")})` : ""].filter(Boolean).join(" ");
455
+ }).filter(Boolean);
456
+ const text3 = statements.length ? statements.join(" ") : awards.join("; ");
457
+ if (text3) out.push(`Funding: ${text3}`);
458
+ }
459
+ const fns = [];
460
+ const notes = meta ? findDeep(kids(meta), "author-notes") : void 0;
461
+ if (notes) fns.push(...kids(notes).filter((x) => tagOf(x) === "fn"));
462
+ if (back) for (const g of kids(back).filter((x) => tagOf(x) === "fn-group")) fns.push(...kids(g).filter((x) => tagOf(x) === "fn"));
463
+ for (const fn of fns) {
464
+ const text3 = renderBlocks(kids(fn).filter((x) => tagOf(x) !== "label"), 1).join(" ").trim();
465
+ if (!text3) continue;
466
+ const label = FN_LABELS[attr(fn, "fn-type")] ?? "";
467
+ out.push(label && !new RegExp(`^[*_]*${label.split(" ")[0]}`, "i").test(text3) ? `${label}: ${text3}` : text3);
468
+ }
469
+ return out;
470
+ }
471
+ function find(nodes, path) {
472
+ let cur = nodes;
473
+ let hit;
474
+ for (const tag of path) {
475
+ hit = cur.find((x) => tagOf(x) === tag);
476
+ if (!hit) return void 0;
477
+ cur = kids(hit);
478
+ }
479
+ return hit;
480
+ }
481
+ function findDeep(nodes, tag) {
482
+ for (const n of nodes) {
483
+ if ("#text" in n) continue;
484
+ if (tagOf(n) === tag) return n;
485
+ const hit = findDeep(kids(n), tag);
486
+ if (hit) return hit;
487
+ }
488
+ return void 0;
489
+ }
490
+ function parseJats(xml) {
491
+ const doc = parser.parse(xml);
492
+ const article = find(doc, ["pmc-articleset", "article"]) ?? find(doc, ["article"]) ?? findDeep(doc, "article");
493
+ const a = article ? kids(article) : [];
494
+ const front = find(a, ["front"]);
495
+ const meta = front ? find(kids(front), ["article-meta"]) ?? front : void 0;
496
+ const titleNode = meta ? find(kids(meta), ["title-group", "article-title"]) ?? findDeep(kids(meta), "article-title") : void 0;
497
+ const abstractNode = meta ? kids(meta).find((x) => tagOf(x) === "abstract" && !attr(x, "abstract-type")) ?? findDeep(kids(meta), "abstract") : void 0;
498
+ const body = find(a, ["body"]);
499
+ const back = find(a, ["back"]);
500
+ return {
501
+ title: titleNode ? squash(inlineText(kids(titleNode))) : "",
502
+ abstract: abstractNode ? renderBlocks(kids(abstractNode).filter((x) => tagOf(x) !== "title"), 2) : [],
503
+ body: body ? renderBlocks(kids(body), 1) : [],
504
+ declarations: declarations(meta, back),
505
+ back: back ? renderBlocks(kids(back), 1) : [],
506
+ hasBody: !!body && kids(body).length > 0
507
+ };
508
+ }
509
+ function renderFulltextMd(doc, headLine2) {
510
+ const parts = [`# ${doc.title}`, "", headLine2, ""];
511
+ if (doc.abstract.length) parts.push("## Abstract", "", ...doc.abstract.flatMap((b) => [b, ""]));
512
+ parts.push(...doc.body.flatMap((b) => [b, ""]));
513
+ if (doc.declarations.length) parts.push("## Declarations", "", ...doc.declarations.flatMap((b) => [b, ""]));
514
+ parts.push(...doc.back.flatMap((b) => [b, ""]));
515
+ return parts.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
516
+ }
517
+
518
+ // src/pdf.ts
519
+ import { getDocumentProxy } from "unpdf";
520
+
521
+ // src/glyph-names.ts
522
+ var PI_NAMES = {
523
+ H11001: "+",
524
+ H11002: "\u2212",
525
+ H11003: "\xD7",
526
+ H11005: "=",
527
+ H11006: "\xB1",
528
+ H11009: "\u221E",
529
+ H11011: "~",
530
+ H11015: "~",
531
+ H11021: "<",
532
+ H11022: ">",
533
+ H11032: "\u2032",
534
+ H11034: "\xB0",
535
+ H11349: "\u2264",
536
+ H11350: "\u2265",
537
+ H11545: "+",
538
+ H11549: "=",
539
+ H11554: "\u2022",
540
+ H11569: "*",
541
+ H17004: "\u25CF",
542
+ H17039: "\u25AA",
543
+ H20648: "\u2016",
544
+ H20841: "|",
545
+ H20851: "[",
546
+ H20852: "]",
547
+ H20862: "/",
548
+ H20873: "(",
549
+ H20874: ")",
550
+ H9004: "\u0394",
551
+ H9018: "\u03A3",
552
+ H9251: "\u03B1",
553
+ H9252: "\u03B2",
554
+ H9253: "\u03B3",
555
+ H9260: "\u03BA",
556
+ H9262: "\u03BC",
557
+ H9270: "\u03C4",
558
+ H9273: "\u03C7",
559
+ L1151: "+",
560
+ L1152: "\u2212",
561
+ L1154: "\xD7",
562
+ L50140: ">",
563
+ L50141: "<",
564
+ L50512: "\xB1",
565
+ L50871: "\u2265",
566
+ afii9797: "\u0394",
567
+ afii9825: "\u03B1",
568
+ afii9826: "\u03B2",
569
+ afii9839: "\u03BC",
570
+ Delta1: "\u0394",
571
+ "221e": "\u221E",
572
+ "223c": "~",
573
+ uni00b5: "\u03BC"
574
+ };
575
+ var FAMILY_NAMES = {
576
+ AdvP4C4E74: { C0: "\u2212", C1: ".", C2: "\xD7", C3: "*", C6: "\xB1", C15: "\u2022", C20: "\u2264", C21: "\u2265", C24: "~" },
577
+ AdvMT_SY: { C0: "\u2212", C2: "\xD7", C6: "\xB1" },
578
+ AdvMacMthSyN: { C0: "\u2212", C2: "\xD7", C6: "\xB1" }
579
+ };
580
+ var fontFamily = (name) => name.replace(/^[A-Z]{6}\+/, "").replace(/[+-].*$/, "");
581
+ function glyphChar(fontName, glyph) {
582
+ return PI_NAMES[glyph] ?? FAMILY_NAMES[fontFamily(fontName)]?.[glyph] ?? null;
583
+ }
584
+ function emittedNames(differences) {
585
+ const out = /* @__PURE__ */ new Map();
586
+ for (const [code, name] of differences) {
587
+ const m = /^C(\d{2,3})$/.exec(name);
588
+ const emitted = m ? Number(m[1]) : code;
589
+ if (emitted < 32) out.set(emitted, name);
590
+ }
591
+ return out;
592
+ }
593
+
594
+ // src/pdf.ts
595
+ var SECTION_NAMES = [
596
+ "abstract",
597
+ "summary",
598
+ "background",
599
+ "introduction",
600
+ "method",
601
+ "methods",
602
+ "materials and methods",
603
+ "material and methods",
604
+ "methods and materials",
605
+ "subjects and methods",
606
+ "patients and methods",
607
+ "participants and methods",
608
+ "experimental",
609
+ "experimental section",
610
+ "experimental procedures",
611
+ "study design",
612
+ "study population",
613
+ "participants",
614
+ "procedure",
615
+ "procedures",
616
+ "statistical analysis",
617
+ "result",
618
+ "results",
619
+ "results and discussion",
620
+ "findings",
621
+ "discussion",
622
+ "conclusion",
623
+ "conclusions",
624
+ "limitations",
625
+ "references",
626
+ "acknowledgments",
627
+ "acknowledgements",
628
+ "funding",
629
+ "conflict of interest",
630
+ "conflicts of interest",
631
+ "supplementary material",
632
+ "supplementary information",
633
+ // Cochrane reviews (their back-matter headings — Sources of support, Declarations of interest — have no text layer in the PDF)
634
+ "objectives",
635
+ "search methods",
636
+ "selection criteria",
637
+ "data collection and analysis",
638
+ "main results",
639
+ "authors' conclusions",
640
+ "plain language summary",
641
+ "summary of findings",
642
+ "description of studies",
643
+ "risk of bias in included studies",
644
+ "effects of interventions",
645
+ "summary of main results",
646
+ "quality of the evidence",
647
+ "implications for practice",
648
+ "implications for research",
649
+ "data and analyses"
650
+ ];
651
+ var squeeze = (s) => s.toLowerCase().replace(/&/g, "and").replace(/\u2019/g, "'").replace(/[^a-z']/g, "");
652
+ var BY_SQUEEZED = new Map(SECTION_NAMES.map((n) => [squeeze(n), n.replace(/\b\w/g, (c) => c.toUpperCase())]));
653
+ function headingOf(line) {
654
+ let bare2 = line.trim().replace(/^\d+(\.\d+)*\.?\s*[|.]?\s*/, "").replace(/\s*:$/, "").replace(/([A-Za-z])\d{1,4}$/, "$1");
655
+ if (bare2.length > 60 || !/^[A-Za-z][A-Za-z &'\u2019]*$/.test(bare2)) return null;
656
+ return BY_SQUEEZED.get(squeeze(bare2)) ?? null;
657
+ }
658
+ async function pdfToMarkdown(bytes, title, headLine2, stats) {
659
+ const pdf = await getDocumentProxy(new Uint8Array(bytes), { fontExtraProperties: true });
660
+ const text3 = await extractPages(pdf);
661
+ if (stats) {
662
+ stats.pages = pdf.numPages;
663
+ stats.chars = text3.replace(/\s+/g, " ").trim().length;
664
+ }
665
+ const lines = text3.split(/\r?\n/).map((l) => l.replace(/\s+/g, " ").trim());
666
+ const out = [`# ${title}`, "", headLine2, ""];
667
+ let para2 = [];
668
+ const flush = () => {
669
+ if (para2.length) {
670
+ out.push(para2.join(" "), "");
671
+ para2 = [];
672
+ }
673
+ };
674
+ for (const line of lines) {
675
+ if (!line) {
676
+ flush();
677
+ continue;
678
+ }
679
+ const h = headingOf(line);
680
+ if (h) {
681
+ flush();
682
+ out.push(`## ${h}`, "");
683
+ continue;
684
+ }
685
+ para2.push(line);
686
+ }
687
+ flush();
688
+ return fixGlyphs(out.join("\n")).replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
689
+ }
690
+ var CONTROL = /[\u0001-\u0008\u000b\u000c\u000e-\u001f]/;
691
+ var newLine = () => ({ text: "", x0: Infinity, x1: -Infinity, y: 0, h: 0, geo: false });
692
+ async function extractPages(pdf) {
693
+ const pages = [];
694
+ let prev = null, closed = false;
695
+ for (let i = 1; i <= pdf.numPages; i++) {
696
+ const page = await pdf.getPage(i);
697
+ const fonts = /* @__PURE__ */ new Map();
698
+ const fontOf = (loaded2) => {
699
+ if (!fonts.has(loaded2)) {
700
+ let f;
701
+ try {
702
+ f = page.commonObjs.get(loaded2);
703
+ } catch {
704
+ f = void 0;
705
+ }
706
+ fonts.set(loaded2, f?.differences instanceof Map ? { name: f.name ?? "", emitted: emittedNames(f.differences) } : null);
707
+ }
708
+ return fonts.get(loaded2) ?? null;
709
+ };
710
+ let loaded = false;
711
+ const lines = [];
712
+ let cur = newLine();
713
+ closed = false;
714
+ for (const it of (await page.getTextContent()).items) {
715
+ if (it.str == null) continue;
716
+ let s = it.str;
717
+ if (CONTROL.test(s) && it.fontName) {
718
+ if (!loaded) {
719
+ try {
720
+ await page.getOperatorList();
721
+ } catch {
722
+ }
723
+ loaded = true;
724
+ }
725
+ const font = fontOf(it.fontName);
726
+ if (font) s = s.replace(new RegExp(CONTROL.source, "g"), (ch) => {
727
+ const name = font.emitted.get(ch.charCodeAt(0));
728
+ return (name && glyphChar(font.name, name)) ?? ch;
729
+ });
730
+ }
731
+ cur.text += s;
732
+ if (it.transform && it.transform.length >= 6 && s.trim()) {
733
+ const x = it.transform[4], w = it.width ?? 0;
734
+ if (!cur.geo) {
735
+ cur.y = it.transform[5];
736
+ cur.geo = true;
737
+ }
738
+ cur.x0 = Math.min(cur.x0, x);
739
+ cur.x1 = Math.max(cur.x1, x + w);
740
+ cur.h = Math.max(cur.h, it.height ?? 0);
741
+ }
742
+ if (it.hasEOL) {
743
+ let t = cur.text, k = t.length;
744
+ while (k > 0 && (t[k - 1] === " " || t[k - 1] === " ")) k--;
745
+ if (k >= 2 && t[k - 1] === "-" && /[A-Za-z]/.test(t[k - 2])) t = t.slice(0, k - 1) + BREAK + t.slice(k);
746
+ cur.text = t;
747
+ lines.push(cur);
748
+ cur = newLine();
749
+ closed = true;
750
+ } else closed = false;
751
+ }
752
+ if (cur.text) lines.push(cur);
753
+ if (!lines.length) continue;
754
+ pages.push((prev && breakBefore(prev, lines[0], columns(lines, page.view?.[2]), page.view?.[2]) ? "\n" : "") + joinLines(lines, prev, page.view?.[2]));
755
+ prev = lines[lines.length - 1];
756
+ }
757
+ return dehyphenate((pages.join("\n") + (closed ? "\n" : "")).replace(/[^\S\n]+/g, " ").replace(/ ?\n ?/g, "\n").replace(/\n{3,}/g, "\n\n"));
758
+ }
759
+ function columns(lines, pageWidth) {
760
+ const geo = lines.filter((l) => l.geo);
761
+ const width = pageWidth ?? Math.max(0, ...geo.map((l) => l.x1));
762
+ return [0, 1].map((c) => {
763
+ const ls = geo.filter((l) => columnOf(l, width) === c);
764
+ const dys = [];
765
+ for (let i = 1; i < ls.length; i++) {
766
+ const dy = ls[i - 1].y - ls[i].y;
767
+ if (dy > 0 && dy < 3 * Math.max(ls[i].h, 6)) dys.push(dy);
768
+ }
769
+ return { left: percentile(ls.map((l) => l.x0), 0.05), right: rightEdge(ls.map((l) => l.x1)), pitch: percentile(dys, 0.5) };
770
+ });
771
+ }
772
+ var columnOf = (l, width) => l.x0 < width / 2 ? 0 : 1;
773
+ function percentile(xs, q) {
774
+ if (!xs.length) return 0;
775
+ const s = [...xs].sort((a, b) => a - b);
776
+ return s[Math.min(s.length - 1, Math.floor(q * s.length))];
777
+ }
778
+ function rightEdge(x1s) {
779
+ if (!x1s.length) return 0;
780
+ const bins = /* @__PURE__ */ new Map();
781
+ for (const x of x1s) {
782
+ const b = Math.round(x / 2);
783
+ bins.set(b, (bins.get(b) ?? 0) + 1);
784
+ }
785
+ const [bin, n] = [...bins.entries()].sort((a, b) => b[1] - a[1])[0];
786
+ return n >= 0.3 * x1s.length ? bin * 2 : percentile(x1s, 0.95);
787
+ }
788
+ function breakBefore(p, l, cols, pageWidth) {
789
+ if (!p.geo || !l.geo) return false;
790
+ const width = pageWidth ?? Math.max(p.x1, l.x1);
791
+ const cp = cols[columnOf(p, width)], cl = cols[columnOf(l, width)];
792
+ const text3 = l.text.trim(), ptext = p.text.trim();
793
+ if (!text3 || !ptext) return false;
794
+ if (/^[•▪◦●■□○·]|^[-–]\s/.test(text3)) return true;
795
+ if (p.h && l.h && Math.abs(l.h - p.h) > 1.5) return true;
796
+ const dy = p.y - l.y;
797
+ if (cp === cl && dy > 0 && cl.pitch && dy > 1.4 * cl.pitch && dy < 6 * cl.pitch) return true;
798
+ const indent = l.x0 - cl.left, pindent = p.x0 - cp.left;
799
+ if (indent > 3 && indent < 40 && pindent <= 3) return true;
800
+ const short = p.x1 < cp.right - 0.08 * (cp.right - cp.left);
801
+ return short && /[.!?:][)"'\]]?$/.test(ptext) && /^[A-Z0-9(["'•]/.test(text3);
802
+ }
803
+ function joinLines(lines, prev, pageWidth) {
804
+ const cols = columns(lines, pageWidth);
805
+ let out = lines[0].text;
806
+ for (let i = 1; i < lines.length; i++) out += (breakBefore(lines[i - 1], lines[i], cols, pageWidth) ? "\n\n" : "\n") + lines[i].text;
807
+ void prev;
808
+ return out;
809
+ }
810
+ var BREAK = "\0";
811
+ var SUSPENDED = /* @__PURE__ */ new Set(["and", "or", "to", "vs", "but", "nor"]);
812
+ var COMPOUND_PREFIX = /* @__PURE__ */ new Set([
813
+ "anti",
814
+ "non",
815
+ "pre",
816
+ "post",
817
+ "self",
818
+ "meta",
819
+ "cross",
820
+ "long",
821
+ "short",
822
+ "high",
823
+ "low",
824
+ "well",
825
+ "placebo",
826
+ "double",
827
+ "single",
828
+ "randomized",
829
+ "randomised",
830
+ "multi",
831
+ "co",
832
+ "inter",
833
+ "intra",
834
+ "sub",
835
+ "semi",
836
+ "dose",
837
+ "time",
838
+ "age",
839
+ "sex",
840
+ "over",
841
+ "under",
842
+ "half",
843
+ "full",
844
+ "all",
845
+ "one",
846
+ "two",
847
+ "three",
848
+ "first",
849
+ "second",
850
+ "third",
851
+ "end",
852
+ "mid",
853
+ "late",
854
+ "early",
855
+ "whole",
856
+ "small",
857
+ "large",
858
+ "vitamin",
859
+ "omega",
860
+ "alpha",
861
+ "beta",
862
+ "gamma",
863
+ "type",
864
+ "wild",
865
+ "ex",
866
+ "quasi",
867
+ "pseudo",
868
+ "peri",
869
+ "re"
870
+ ]);
871
+ function dehyphenate(text3) {
872
+ if (!text3.includes(BREAK)) return text3;
873
+ const lower = text3.toLowerCase();
874
+ const seen = (s) => new RegExp(`(?<![a-z])${s}(?![a-z])`).test(lower);
875
+ return text3.replace(/([A-Za-z]+)\u0000\n([A-Za-z]+)/g, (m, x, y) => {
876
+ if (!/^[a-z]/.test(y)) return `${x}-${y}`;
877
+ if (SUSPENDED.has(y.toLowerCase())) return `${x}- ${y}`;
878
+ const hyph = `${x}-${y}`.toLowerCase(), joined = `${x}${y}`.toLowerCase();
879
+ if (seen(hyph)) return `${x}-${y}`;
880
+ if (seen(joined)) return `${x}${y}`;
881
+ return COMPOUND_PREFIX.has(x.toLowerCase()) ? `${x}-${y}` : `${x}${y}`;
882
+ }).replace(/\u0000\n/g, "-\n").replace(/\u0000/g, "-");
883
+ }
884
+ function fixGlyphs(md) {
885
+ return md.replace(/¡(?=\.?\d)/g, "\u2212").replace(/(?<=[\p{L}\p{N}])\u0008(?=[\p{L}\p{N}])/gu, " ");
886
+ }
887
+ var UNRESOLVED_GLYPH = /[\u0001-\u0008\u000b\u000c\u000e-\u001f]/g;
888
+ var countUnresolved = (text3) => (text3.match(UNRESOLVED_GLYPH) ?? []).length;
889
+ var looksLikePdf = (bytes) => bytes.subarray(0, 5).toString("latin1") === "%PDF-";
890
+
891
+ // src/browser.ts
892
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
893
+ import { createRequire } from "module";
894
+ import { join as join3 } from "path";
895
+ import { chromium } from "playwright-core";
896
+
897
+ // src/layout.ts
898
+ import { createHash } from "crypto";
899
+ import { existsSync as existsSync2 } from "fs";
900
+ import { join as join2, resolve as resolve2 } from "path";
901
+ var FH_LIBRARY_DIR = (() => {
902
+ const env = process.env.FH_LIBRARY_DIR;
903
+ if (env && env.trim()) return resolve2(env.trim());
904
+ if (existsSync2("/library")) return "/library";
905
+ return resolve2("library");
906
+ })();
907
+ var doiSlug = (doi) => doi.toLowerCase().replace(/[^a-z0-9.-]+/g, "_");
908
+ function entryDirRel(type, num2) {
909
+ if (type === "doi") {
910
+ const h = createHash("sha1").update(num2).digest("hex");
911
+ return `${type}/${h.slice(0, 2)}/${h.slice(0, 4)}/${num2}`;
912
+ }
913
+ if (!/^\d+$/.test(num2)) return `${type}/${num2.slice(0, 2)}/${num2.slice(0, 4)}/${num2}`;
914
+ const l1 = num2.length > 6 ? num2.slice(0, -6) : "0";
915
+ const l2 = num2.length > 4 ? num2.slice(0, -4) : "0";
916
+ return `${type}/${l1}/${l2}/${num2}`;
917
+ }
918
+ function entryPaths(libraryDir, type, num2) {
919
+ if (type === "doi") num2 = doiSlug(num2);
920
+ const entry = `${type}-${num2}`;
921
+ const entryDir = join2(libraryDir, entryDirRel(type, num2));
922
+ return {
923
+ entry,
924
+ entryDir,
925
+ xml: join2(entryDir, `${entry}-abstract.xml`),
926
+ md: join2(entryDir, `${entry}-abstract.md`),
927
+ meta: join2(entryDir, `${entry}-meta.md`),
928
+ fulltextMd: join2(entryDir, `${entry}-fulltext.md`),
929
+ fulltextRaw: (format) => join2(entryDir, `${entry}-fulltext.${format}`)
930
+ };
931
+ }
932
+
933
+ // src/browser.ts
934
+ function browserConfig(env = process.env) {
935
+ const out = { browsers: [], headless: "auto" };
936
+ for (let i = 1; i <= 9; i++) {
937
+ const v = str(`FH_LIBRARY_BROWSER_${i}`, env);
938
+ if (v && !out.browsers.includes(v)) out.browsers.push(v);
939
+ }
940
+ const h = str("FH_LIBRARY_BROWSER_HEADLESS", env).toLowerCase();
941
+ if (h === "true" || h === "false") out.headless = h === "true";
942
+ return out;
943
+ }
944
+ var INSTALL_HINT = "npx playwright-core install chromium";
945
+ var PLAYWRIGHT_VERSION = (() => {
946
+ try {
947
+ return createRequire(import.meta.url)("playwright-core/package.json").version;
948
+ } catch {
949
+ return "unknown";
950
+ }
951
+ })();
952
+ var MISMATCH = "Playwright version mismatch";
953
+ function connectError(e, endpoint) {
954
+ const msg = e instanceof Error ? e.message : String(e);
955
+ return /\b428\b/.test(msg) ? new Error(`${MISMATCH} \u2014 server ${endpoint} must run playwright-core ${PLAYWRIGHT_VERSION}`) : e instanceof Error ? e : new Error(msg);
956
+ }
957
+ var isRemote = (endpoint) => /^wss?:\/\//i.test(endpoint);
958
+ var isCdp = (endpoint) => /^cdp:\/\//i.test(endpoint);
959
+ var cdpHttp = (endpoint) => endpoint.replace(/^cdp:\/\//i, "http://");
960
+ var rungKind = (endpoint) => isCdp(endpoint) ? "chrome" : isRemote(endpoint) ? "server" : "local";
961
+ function localChromium() {
962
+ try {
963
+ const p = chromium.executablePath();
964
+ return existsSync3(p) ? p : null;
965
+ } catch {
966
+ return null;
967
+ }
968
+ }
969
+ function browserLabel(endpoint) {
970
+ if (rungKind(endpoint) === "local") return "local";
971
+ try {
972
+ const h = new URL(isCdp(endpoint) ? cdpHttp(endpoint) : endpoint).hostname;
973
+ return isCdp(endpoint) ? `chrome@${h}` : h;
974
+ } catch {
975
+ return endpoint;
976
+ }
977
+ }
978
+ var hasDisplay = () => !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
979
+ function browserHeadless(endpoint, cfg = browserConfig()) {
980
+ if (isCdp(endpoint)) return false;
981
+ return cfg.headless === "auto" ? isRemote(endpoint) ? false : !hasDisplay() : cfg.headless;
982
+ }
983
+ function browserRungs(cfg = browserConfig()) {
984
+ return cfg.browsers.map((endpoint) => ({
985
+ endpoint,
986
+ label: browserLabel(endpoint),
987
+ kind: rungKind(endpoint),
988
+ available: rungKind(endpoint) !== "local" || localChromium() !== null,
989
+ headless: browserHeadless(endpoint, cfg)
990
+ }));
991
+ }
992
+ var CHALLENGE = /just a moment|nur einen moment|attention required|verify you are human|human verification|checking your browser|access denied/i;
993
+ var CONNECT_TIMEOUT_MS = 2e4;
994
+ var NAV_TIMEOUT_MS = 2e4;
995
+ var CHALLENGE_WAIT_MS = 8e3;
996
+ var DOWNLOAD_WAIT_MS = 1e4;
997
+ var IDLE_CLOSE_MS = 12e4;
998
+ var ctxs = /* @__PURE__ */ new Map();
999
+ var idleTimer = null;
1000
+ function profileDir() {
1001
+ const dir = join3(FH_LIBRARY_DIR, ".browser");
1002
+ mkdirSync(join3(dir, "Default"), { recursive: true });
1003
+ const prefs = join3(dir, "Default", "Preferences");
1004
+ let p = {};
1005
+ try {
1006
+ p = JSON.parse(readFileSync2(prefs, "utf8"));
1007
+ } catch {
1008
+ }
1009
+ p.plugins = { ...p.plugins ?? {}, always_open_pdf_externally: true };
1010
+ p.download = { ...p.download ?? {}, prompt_for_download: false };
1011
+ writeFileSync(prefs, JSON.stringify(p));
1012
+ return dir;
1013
+ }
1014
+ var LAUNCH_ARGS = ["--disable-blink-features=AutomationControlled", "--window-size=1366,900"];
1015
+ var LOCAL_WINDOW_ARGS = [...LAUNCH_ARGS, "--window-position=4000,4000"];
1016
+ var TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Berlin";
1017
+ var CONTEXT_OPTS = { acceptDownloads: true, viewport: null, locale: "en-US", timezoneId: TIMEZONE };
1018
+ function stateFile(endpoint) {
1019
+ const dir = join3(FH_LIBRARY_DIR, ".browser");
1020
+ mkdirSync(dir, { recursive: true });
1021
+ return join3(dir, `${browserLabel(endpoint).replace(/[^A-Za-z0-9.-]+/g, "_")}.json`);
1022
+ }
1023
+ async function context(endpoint, cfg) {
1024
+ let p = ctxs.get(endpoint);
1025
+ if (!p) {
1026
+ p = (async () => {
1027
+ const headless = browserHeadless(endpoint, cfg);
1028
+ const drop = () => {
1029
+ if (ctxs.get(endpoint) === p) ctxs.delete(endpoint);
1030
+ };
1031
+ if (isCdp(endpoint)) {
1032
+ const browser = await chromium.connectOverCDP(cdpHttp(endpoint), { timeout: CONNECT_TIMEOUT_MS });
1033
+ browser.on("disconnected", drop);
1034
+ const ctx2 = browser.contexts()[0] ?? await browser.newContext(CONTEXT_OPTS);
1035
+ return { ctx: ctx2, browser };
1036
+ }
1037
+ if (isRemote(endpoint)) {
1038
+ const browser = await chromium.connect(endpoint, {
1039
+ timeout: CONNECT_TIMEOUT_MS,
1040
+ headers: { "x-playwright-launch-options": JSON.stringify({ headless, args: LAUNCH_ARGS, ignoreDefaultArgs: ["--enable-automation"] }) }
1041
+ }).catch((e) => {
1042
+ throw connectError(e, endpoint);
1043
+ });
1044
+ browser.on("disconnected", drop);
1045
+ const state = stateFile(endpoint);
1046
+ const ctx2 = await browser.newContext({ ...CONTEXT_OPTS, ...existsSync3(state) ? { storageState: state } : {} });
1047
+ return { ctx: ctx2, browser };
1048
+ }
1049
+ if (!localChromium()) throw new Error(`no local Chromium (${INSTALL_HINT})`);
1050
+ const ctx = await chromium.launchPersistentContext(profileDir(), {
1051
+ channel: "chromium",
1052
+ headless,
1053
+ ...CONTEXT_OPTS,
1054
+ args: headless ? LAUNCH_ARGS : LOCAL_WINDOW_ARGS,
1055
+ ignoreDefaultArgs: ["--enable-automation"]
1056
+ });
1057
+ ctx.on("close", drop);
1058
+ return { ctx, browser: null };
1059
+ })();
1060
+ ctxs.set(endpoint, p);
1061
+ p.catch(() => {
1062
+ if (ctxs.get(endpoint) === p) ctxs.delete(endpoint);
1063
+ });
1064
+ }
1065
+ return (await p).ctx;
1066
+ }
1067
+ function touchIdle() {
1068
+ if (idleTimer) clearTimeout(idleTimer);
1069
+ idleTimer = setTimeout(() => {
1070
+ void closeBrowsers();
1071
+ }, IDLE_CLOSE_MS);
1072
+ idleTimer.unref();
1073
+ }
1074
+ async function closeBrowsers() {
1075
+ const open = [...ctxs.entries()];
1076
+ ctxs.clear();
1077
+ for (const [endpoint, p] of open) {
1078
+ try {
1079
+ const { ctx, browser } = await p;
1080
+ if (isCdp(endpoint)) {
1081
+ await browser?.close().catch(() => void 0);
1082
+ continue;
1083
+ }
1084
+ if (browser) await ctx.storageState({ path: stateFile(endpoint) }).catch(() => void 0);
1085
+ await ctx.close().catch(() => void 0);
1086
+ await browser?.close().catch(() => void 0);
1087
+ } catch {
1088
+ }
1089
+ }
1090
+ }
1091
+ async function browserFetch(url, endpoint, cfg = browserConfig()) {
1092
+ const ctx = await context(endpoint, cfg);
1093
+ touchIdle();
1094
+ const page = await ctx.newPage();
1095
+ try {
1096
+ let pdfBest = null;
1097
+ const pdfReads = [];
1098
+ let status, contentType = "";
1099
+ page.on("response", (r) => {
1100
+ if (r.request().isNavigationRequest() && r.frame() === page.mainFrame()) {
1101
+ status = r.status();
1102
+ contentType = r.headers()["content-type"] ?? "";
1103
+ }
1104
+ if (r.status() !== 200 || !/application\/pdf/i.test(r.headers()["content-type"] ?? "")) return;
1105
+ pdfReads.push(r.body().then((b) => {
1106
+ if (b.subarray(0, 5).toString("latin1") === "%PDF-" && (!pdfBest || b.length > pdfBest.length)) pdfBest = b;
1107
+ }).catch(() => void 0));
1108
+ });
1109
+ let download2 = null;
1110
+ const dl = page.waitForEvent("download", { timeout: NAV_TIMEOUT_MS + CHALLENGE_WAIT_MS + DOWNLOAD_WAIT_MS }).then((d) => {
1111
+ download2 = d;
1112
+ return d;
1113
+ }).catch(() => null);
1114
+ let navError = null;
1115
+ const nav = page.goto(url, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS }).then((resp) => {
1116
+ if (status === void 0) {
1117
+ status = resp?.status();
1118
+ contentType = resp?.headers()["content-type"] ?? "";
1119
+ }
1120
+ }).catch((e) => {
1121
+ navError = (e instanceof Error ? e.message : String(e)).split("\n")[0].slice(0, 80);
1122
+ });
1123
+ await Promise.race([nav, dl]);
1124
+ const pause = (ms) => Promise.race([dl, new Promise((r) => setTimeout(r, ms))]);
1125
+ if (download2) {
1126
+ } else if (navError && !/download is starting|net::ERR_ABORTED/i.test(navError)) {
1127
+ await pause(2e3);
1128
+ if (!download2) throw new Error(`navigation: ${navError}`);
1129
+ } else {
1130
+ await pause(2e3);
1131
+ const t0 = Date.now();
1132
+ while (!download2 && Date.now() - t0 < CHALLENGE_WAIT_MS) {
1133
+ const title2 = await page.title().catch(() => "");
1134
+ if (!CHALLENGE.test(title2)) break;
1135
+ await pause(1e3);
1136
+ }
1137
+ if (!download2) await pause(DOWNLOAD_WAIT_MS / 5);
1138
+ }
1139
+ if (download2) {
1140
+ const d = download2;
1141
+ return { bytes: readFileSync2(await d.path()), contentType: "application/pdf", url: d.url(), via: "download" };
1142
+ }
1143
+ const title = await page.title().catch(() => "");
1144
+ if (CHALLENGE.test(title)) throw new Error(`challenge not cleared (${title.slice(0, 40)})`);
1145
+ if (status && status >= 400) throw new Error(`HTTP ${status}`);
1146
+ const docType = await page.evaluate(() => document.contentType).catch(() => "");
1147
+ const inViewer = /application\/pdf/i.test(contentType) || /application\/pdf/i.test(docType);
1148
+ if (inViewer) {
1149
+ const t1 = Date.now();
1150
+ while (Date.now() - t1 < DOWNLOAD_WAIT_MS) {
1151
+ await Promise.all(pdfReads);
1152
+ if (pdfBest && pdfBest.length > 4096) break;
1153
+ if (!pdfReads.length && Date.now() - t1 > 3e3) break;
1154
+ await new Promise((r) => setTimeout(r, 500));
1155
+ }
1156
+ if (pdfBest) return { bytes: pdfBest, contentType: "application/pdf", url: page.url(), via: "response" };
1157
+ }
1158
+ if (inViewer) {
1159
+ const r = await ctx.request.get(page.url(), { timeout: NAV_TIMEOUT_MS });
1160
+ if (!r.ok()) throw new Error(`HTTP ${r.status()} (pdf request)`);
1161
+ return { bytes: Buffer.from(await r.body()), contentType: "application/pdf", url: page.url(), via: "request" };
1162
+ }
1163
+ const html = await page.content();
1164
+ return { bytes: Buffer.from(html, "utf8"), contentType: contentType || "text/html", url: page.url(), via: "page" };
1165
+ } finally {
1166
+ await page.close().catch(() => void 0);
1167
+ if (isRemote(endpoint)) await ctx.storageState({ path: stateFile(endpoint) }).catch(() => void 0);
1168
+ }
1169
+ }
1170
+
1171
+ // src/unlock.ts
1172
+ var ENDPOINT = "https://api.brightdata.com/request";
1173
+ var TIMEOUT_MS2 = 6e4;
1174
+ var unlockerConfigured = () => !!process.env.BRIGHTDATA_API_TOKEN?.trim();
1175
+ async function unlock(url, fetchImpl = fetch) {
1176
+ const token = process.env.BRIGHTDATA_API_TOKEN?.trim();
1177
+ if (!token) throw new Error("unlocker not configured");
1178
+ const zone = process.env.BRIGHTDATA_ZONE?.trim() || "mcp_unlocker";
1179
+ const res = await fetchImpl(ENDPOINT, {
1180
+ method: "POST",
1181
+ headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
1182
+ body: JSON.stringify({ zone, url, format: "raw" }),
1183
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
1184
+ });
1185
+ if (!res.ok) {
1186
+ const why = res.headers.get("x-brd-err-msg") ?? res.headers.get("x-brd-error");
1187
+ throw new Error(`unlocker HTTP ${res.status}${why ? `: ${why}` : ""}`);
1188
+ }
1189
+ return { bytes: Buffer.from(await res.arrayBuffer()), contentType: res.headers.get("content-type") ?? "" };
1190
+ }
1191
+
1192
+ // src/tiers/tier.ts
1193
+ function oaVersion(v) {
1194
+ if (v === "acceptedVersion") return "accepted_manuscript";
1195
+ if (v === "submittedVersion") return "preprint";
1196
+ return "published";
1197
+ }
1198
+ var miss = (tier, reason) => ({ ok: false, tier, reason });
1199
+ var errMsg = (e) => (e instanceof Error ? e.message : String(e)).replace(/\x1b\[[0-9;]*m/g, "").split("\n")[0].trim().slice(0, 200);
1200
+
1201
+ // src/download.ts
1202
+ var hostOf = (url) => {
1203
+ try {
1204
+ return new URL(url).hostname.replace(/^www\./, "");
1205
+ } catch {
1206
+ return url;
1207
+ }
1208
+ };
1209
+ var NCBI = /^https?:\/\/([a-z0-9.-]+\.)?(ncbi\.nlm\.nih\.gov|pubmed\.gov)\//i;
1210
+ function order(candidates) {
1211
+ const rank = (c) => (c.pdf ? 0 : 2) + (c.host === "publisher" ? 1 : 0);
1212
+ return [...candidates].sort((a, b) => rank(a) - rank(b));
1213
+ }
1214
+ async function download(tier, p, candidates, ctx) {
1215
+ const usable = candidates.filter((c) => !NCBI.test(c.url));
1216
+ if (!usable.length && candidates.length) return miss(tier, "only pmc copies (covered by the pmc tier)");
1217
+ const ordered = order(usable).filter((c) => !ctx.tried.has(c.url));
1218
+ if (!ordered.length) return miss(tier, "only copies already tried");
1219
+ const reasons = [];
1220
+ for (const c of ordered) {
1221
+ ctx.tried.add(c.url);
1222
+ let r = await tryOne(p, c, tier);
1223
+ const target = !r.ok && r.url && r.url !== c.url ? { ...c, url: r.url } : c;
1224
+ if (target !== c) ctx.onTry?.(`${c.url} \u2192 ${target.url}`);
1225
+ ctx.onTry?.(r.ok ? `${target.url} ok fetch` : `${target.url} failed fetch: ${r.reason}`);
1226
+ const why = (reason) => reason.replace(/^not a pdf \((.*)\)$/, "$1");
1227
+ for (const b of browserRungs()) {
1228
+ if (r.ok || c.headers || c.service || !b.available || !/^(HTTP 403|HTTP 429|wall page|no methods\/results|unknown content|browser )/.test(why(r.reason))) break;
1229
+ r = await tryOne(p, target, tier, { browser: b.endpoint, label: b.label });
1230
+ ctx.onTry?.(r.ok ? `${target.url} ok browser ${b.label}` : `${target.url} failed browser ${b.label}: ${bare(r.reason)}`);
1231
+ if (!r.ok && bare(r.reason).startsWith(MISMATCH)) ctx.onAlert?.(`browser ${b.label}: ${bare(r.reason)}`);
1232
+ }
1233
+ if (!r.ok && !c.headers && !c.service && unlockerConfigured() && /^(HTTP 403|wall page|browser )/.test(why(r.reason))) {
1234
+ r = await tryOne(p, target, tier, "unlocker");
1235
+ ctx.onTry?.(r.ok ? `${target.url} ok brightdata` : `${target.url} failed brightdata: ${bare(r.reason)}`);
1236
+ if (!r.ok && /^unlocker HTTP/.test(bare(r.reason))) ctx.onAlert?.(`brightdata: ${bare(r.reason)}`);
1237
+ }
1238
+ if (r.ok) return r;
1239
+ reasons.push(`${r.reason}: ${hostOf(c.url)}`);
1240
+ }
1241
+ return miss(tier, reasons.join("; "));
1242
+ }
1243
+ var bare = (reason) => reason.replace(/^(brightdata|browser [^:]+): /, "");
1244
+ async function tryOne(p, c, tier, via = "plain") {
1245
+ let got;
1246
+ try {
1247
+ got = via === "unlocker" ? await unlock(c.url) : typeof via === "object" ? await browserFetch(c.url, via.browser) : await fetchBytes(c.url, { ...DOWNLOAD_HEADERS, ...c.headers ?? {} });
1248
+ } catch (e) {
1249
+ const body = e?.body;
1250
+ if (c.service && body?.trim()) return { ok: false, reason: `${errMsg(e)} \u2014 ${serviceMessage(Buffer.from(body), e.contentType)}`, url: e?.url };
1251
+ return { ok: false, reason: (via === "plain" ? "" : via === "unlocker" ? "brightdata: " : `browser ${via.label}: `) + errMsg(e), url: e?.url };
1252
+ }
1253
+ const { bytes, contentType } = got;
1254
+ if (c.service && !looksLikePdf(bytes) && !isXmlArticle(bytes.toString("utf8"))) return { ok: false, reason: `service: ${serviceMessage(bytes, contentType)}`, url: got.url };
1255
+ if (via === "unlocker" && !bytes.length) return { ok: false, reason: "brightdata: empty response" };
1256
+ const r = await convertBytes(p, bytes, contentType);
1257
+ if (!r.ok) return { ok: false, reason: c.pdf && r.format !== "pdf" ? `not a pdf (${r.reason})` : r.reason, url: got.url };
1258
+ return { ok: true, tier, format: r.format, raw: r.format === "pdf" ? bytes : bytes.toString("utf8"), md: r.md, version: c.version };
1259
+ }
1260
+ var isXmlArticle = (text3) => /^\s*<\?xml|<article\b/.test(text3.slice(0, 2e3)) && !/<html/i.test(text3.slice(0, 2e3));
1261
+ function serviceMessage(bytes, contentType = "") {
1262
+ const text3 = bytes.toString("utf8").trim();
1263
+ if (!text3) return `empty response (${contentType.split(";")[0] || "no type"})`;
1264
+ try {
1265
+ const j = JSON.parse(text3);
1266
+ const m = j?.message ?? j?.error?.message ?? j?.error ?? j?.detail;
1267
+ if (typeof m === "string" && m.trim()) return m.trim().replace(/\s+/g, " ").slice(0, 160);
1268
+ } catch {
1269
+ }
1270
+ const visible = text3.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, " ").replace(/<title>([\s\S]*?)<\/title>/i, "$1. ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
1271
+ return (visible || text3).slice(0, 160);
1272
+ }
1273
+ function describe(reason, format, md) {
1274
+ const words2 = md.split(/\s+/).filter(Boolean).length;
1275
+ const headings2 = md.split("\n").filter((l) => /^#{2,6}\s/.test(l)).length;
1276
+ const seen = `${format}, ${words2} words, ${proseWords(md)} in prose, ${headings2} heading${headings2 === 1 ? "" : "s"}`;
1277
+ if (reason === "no methods/results") return `no article body (${seen})`;
1278
+ if (reason === "title missing") return `neither title nor DOI in text (${seen})`;
1279
+ return reason;
1280
+ }
1281
+ async function convertBytes(p, bytes, contentType = "") {
1282
+ let format;
1283
+ let md;
1284
+ if (looksLikePdf(bytes)) {
1285
+ format = "pdf";
1286
+ const stats = {};
1287
+ try {
1288
+ md = await pdfToMarkdown(bytes, p.title, p.headLine, stats);
1289
+ } catch (e) {
1290
+ return { ok: false, reason: `pdf: ${errMsg(e)}` };
1291
+ }
1292
+ if ((stats.pages ?? 0) >= 1 && (stats.chars ?? 0) < 500 * (stats.pages ?? 1)) return { ok: false, reason: "scanned pdf", format };
1293
+ } else {
1294
+ const text3 = bytes.toString("utf8");
1295
+ if (isWallPage(text3)) return { ok: false, reason: "wall page" };
1296
+ if (/^\s*<\?xml|<article\b/.test(text3.slice(0, 2e3)) && /<body\b/.test(text3) && !/<html/i.test(text3.slice(0, 2e3))) {
1297
+ format = "xml";
1298
+ const doc = parseJats(/full-text-retrieval-response/.test(text3.slice(0, 3e3)) ? elsevierToJats(text3) : text3);
1299
+ if (!doc.title) doc.title = p.title;
1300
+ if (!doc.hasBody) return { ok: false, reason: "xml without body" };
1301
+ md = renderFulltextMd(doc, p.headLine);
1302
+ } else if (/text\/html|<html/i.test(contentType + text3.slice(0, 500))) {
1303
+ format = "html";
1304
+ const body = /<body[^>]*>([\s\S]*)<\/body>/i.exec(text3)?.[1] ?? text3;
1305
+ md = `# ${p.title}
1306
+
1307
+ ${p.headLine}
1308
+
1309
+ ${htmlToMarkdown(body)}`;
1310
+ } else {
1311
+ return { ok: false, reason: `unknown content (${contentType.split(";")[0] || "no type"})` };
1312
+ }
1313
+ }
1314
+ const content = format === "xml" ? md : md.replace(/^# [^\n]*\n/, "");
1315
+ const rawText = format === "html" ? bytes.toString("utf8").replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ") : void 0;
1316
+ const check = checkFulltext(content, p.title, rawText, p.doi, format === "xml");
1317
+ if (!check.ok) return { ok: false, reason: describe(check.reason, format, content), format };
1318
+ return { ok: true, format, md };
1319
+ }
1320
+ var ELSEVIER_TAGS = [
1321
+ [/ja:article\b/g, "article"],
1322
+ [/ja:body\b/g, "body"],
1323
+ [/ja:head\b/g, "front"],
1324
+ [/ce:sections\b/g, "sec-group"],
1325
+ [/ce:section-title\b/g, "title"],
1326
+ [/ce:section\b/g, "sec"],
1327
+ [/ce:simple-para\b/g, "p"],
1328
+ [/ce:para\b/g, "p"],
1329
+ [/ce:abstract-sec\b/g, "sec"],
1330
+ [/ce:abstract\b/g, "abstract"],
1331
+ [/ce:title\b/g, "article-title"],
1332
+ [/ce:italic\b/g, "italic"],
1333
+ [/ce:bold\b/g, "bold"],
1334
+ [/ce:sup\b/g, "sup"],
1335
+ [/ce:inf\b/g, "sub"],
1336
+ [/ce:cross-refs?\b/g, "xref"],
1337
+ [/ce:list-item\b/g, "list-item"],
1338
+ [/ce:list\b/g, "list"],
1339
+ [/ce:label\b/g, "label"],
1340
+ [/ce:caption\b/g, "caption"],
1341
+ [/ce:table\b/g, "table-wrap"],
1342
+ [/ce:figure\b/g, "fig"],
1343
+ [/ce:bib-reference\b/g, "ref"],
1344
+ [/ce:bibliography\b/g, "ref-list"],
1345
+ [/sb:reference\b/g, "mixed-citation"],
1346
+ [/ce:acknowledgment\b/g, "ack"],
1347
+ [/ce:inter-ref\b/g, "ext-link"]
1348
+ ];
1349
+ function elsevierToJats(xml) {
1350
+ let out = xml;
1351
+ for (const [re, to] of ELSEVIER_TAGS) out = out.replace(re, to);
1352
+ return out.replace(/<\/?full-text-retrieval-response[^>]*>/g, "").replace(/<\/?originalText[^>]*>/g, "").replace(/<\/?xocs:doc[^>]*>/g, "").replace(/<\/?xocs:serial-item[^>]*>/g, "");
1353
+ }
1354
+
1355
+ // src/tiers/core.ts
1356
+ var WINDOW_MS = 6e4;
1357
+ var PER_WINDOW = 10;
1358
+ var MAX_WAIT_MS = 65e3;
1359
+ var TIMEOUT_MS3 = 1e4;
1360
+ var calls = [];
1361
+ async function pace() {
1362
+ const now = Date.now();
1363
+ while (calls.length && calls[0] <= now - WINDOW_MS) calls.shift();
1364
+ if (calls.length >= PER_WINDOW) await new Promise((r) => setTimeout(r, calls[0] + WINDOW_MS - now + 200));
1365
+ calls.push(Date.now());
1366
+ }
1367
+ async function coreFetch(url, key) {
1368
+ const headers = { "User-Agent": LIBRARY_UA, Authorization: `Bearer ${key}` };
1369
+ for (let attempt = 0; attempt < 2; attempt++) {
1370
+ await pace();
1371
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(TIMEOUT_MS3), redirect: "follow" });
1372
+ if (res.ok) return await res.text();
1373
+ const body = await res.text().catch(() => "");
1374
+ if (res.status === 429 && attempt === 0) {
1375
+ const until = Date.parse(res.headers.get("x-ratelimit-retry-after") ?? "");
1376
+ const wait = Number.isFinite(until) ? Math.min(Math.max(until - Date.now(), 1e3), MAX_WAIT_MS) : 1e4;
1377
+ await new Promise((r) => setTimeout(r, wait));
1378
+ continue;
1379
+ }
1380
+ throw new Error(res.status < 500 ? httpReason(res.status, body) : `HTTP ${res.status}`);
1381
+ }
1382
+ throw new Error("HTTP 429");
1383
+ }
1384
+ function coreTier(apiKey) {
1385
+ const key = apiKey?.trim();
1386
+ return {
1387
+ name: "core",
1388
+ applies: (p) => p.doi ? true : "no doi",
1389
+ async run(p, ctx) {
1390
+ if (!key) return { ...miss(this.name, "needs api key"), skipped: true };
1391
+ let data;
1392
+ try {
1393
+ const q = encodeURIComponent(`doi:"${p.doi}"`);
1394
+ data = JSON.parse(await coreFetch(`https://api.core.ac.uk/v3/search/works/?q=${q}&limit=5`, key));
1395
+ } catch (e) {
1396
+ return miss(this.name, errMsg(e));
1397
+ }
1398
+ const candidates = coreCandidates(data);
1399
+ if (!candidates.length) return miss(this.name, data.results?.length ? "no full text" : "not in core");
1400
+ ctx.onTry?.(`located ${candidates.length} copies`);
1401
+ return download(this.name, p, candidates, ctx);
1402
+ }
1403
+ };
1404
+ }
1405
+ function coreCandidates(d) {
1406
+ const out = [];
1407
+ const seen = /* @__PURE__ */ new Set();
1408
+ const add = (url, pdf, host) => {
1409
+ if (!url || seen.has(url)) return;
1410
+ seen.add(url);
1411
+ out.push({ url, version: "published", source: "core", host, pdf });
1412
+ };
1413
+ for (const w of d.results ?? []) {
1414
+ add(w.downloadUrl, true, "core");
1415
+ for (const l of w.links ?? []) if (l.type === "download") add(l.url, /\.pdf($|\?)/i.test(l.url ?? ""), "core");
1416
+ for (const u of w.sourceFulltextUrls ?? []) add(u, /\.pdf($|\?)/i.test(u), "repository");
1417
+ }
1418
+ return out;
1419
+ }
1420
+
1421
+ // src/tiers/europepmc.ts
1422
+ var europepmc = {
1423
+ name: "europepmc",
1424
+ applies: (p) => p.pmid ? true : "no pmid",
1425
+ async run(p, ctx) {
1426
+ let data;
1427
+ try {
1428
+ const q = encodeURIComponent(`EXT_ID:${p.pmid} AND SRC:MED`);
1429
+ data = JSON.parse(await fetchText(`https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=${q}&resultType=core&format=json`, { "User-Agent": LIBRARY_UA }));
1430
+ } catch (e) {
1431
+ return miss(this.name, errMsg(e));
1432
+ }
1433
+ const rec = data.resultList?.result?.[0];
1434
+ if (!rec) return miss(this.name, "not in europe pmc");
1435
+ const candidates = europepmcCandidates(rec, p.pmcid);
1436
+ if (!candidates.length) return miss(this.name, "no free copy");
1437
+ ctx.onTry?.(`located ${candidates.length} copies`);
1438
+ return download(this.name, p, candidates, ctx);
1439
+ }
1440
+ };
1441
+ function europepmcCandidates(r, pmcid) {
1442
+ const out = [];
1443
+ const seen = /* @__PURE__ */ new Set();
1444
+ const add = (url, pdf, host, version) => {
1445
+ if (!url || seen.has(url)) return;
1446
+ seen.add(url);
1447
+ out.push({ url, version, source: "europepmc", host, pdf });
1448
+ };
1449
+ const id = (r.pmcid ?? pmcid ?? "").toUpperCase() || null;
1450
+ const own = r.isOpenAccess === "Y" ? "published" : "accepted_manuscript";
1451
+ if (id && r.inEPMC === "Y") {
1452
+ if (r.isOpenAccess === "Y") add(`https://www.ebi.ac.uk/europepmc/webservices/rest/${id}/fullTextXML`, false, "europepmc", own);
1453
+ if (r.hasPDF === "Y") add(`https://europepmc.org/articles/${id}?pdf=render`, true, "europepmc", own);
1454
+ }
1455
+ for (const u of r.fullTextUrlList?.fullTextUrl ?? []) {
1456
+ if (!u.url || u.site === "DOI" || !/^(F|OA)$/.test(u.availabilityCode ?? "")) continue;
1457
+ if (u.documentStyle === "abs") continue;
1458
+ if (/^https?:\/\/europepmc\.org\/articles\//i.test(u.url) && u.documentStyle === "html") continue;
1459
+ add(u.url, u.documentStyle === "pdf", u.site?.toLowerCase() ?? "unknown", "published");
1460
+ }
1461
+ return out;
1462
+ }
1463
+
1464
+ // src/tiers/inbox.ts
1465
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, unlinkSync } from "fs";
1466
+ import { join as join4 } from "path";
1467
+ var INBOX_DIR = join4(FH_LIBRARY_DIR, "inbox");
1468
+ var EXT = { ".pdf": "application/pdf", ".xml": "text/xml", ".html": "text/html", ".htm": "text/html" };
1469
+ function findInboxFile(dir, pmid) {
1470
+ if (!existsSync4(dir)) return null;
1471
+ const re = new RegExp(`(^|[^0-9])${pmid}([^0-9]|$)`);
1472
+ for (const name of readdirSync(dir).sort()) {
1473
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
1474
+ if (ext in EXT && re.test(name.slice(0, -ext.length))) return join4(dir, name);
1475
+ }
1476
+ return null;
1477
+ }
1478
+ function inboxTier(dir = INBOX_DIR) {
1479
+ return {
1480
+ name: "inbox",
1481
+ always: true,
1482
+ applies: (p) => p.pmid ? true : "no pmid",
1483
+ async run(p, ctx) {
1484
+ const file = findInboxFile(dir, p.pmid);
1485
+ if (!file) return { ...miss("inbox", "no file"), skipped: true, ephemeral: true };
1486
+ const name = file.slice(file.lastIndexOf("/") + 1);
1487
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
1488
+ const bytes = readFileSync3(file);
1489
+ const r = await convertBytes(p, bytes, EXT[ext]);
1490
+ if (!r.ok) return miss("inbox", `${name}: ${r.reason}`);
1491
+ ctx.onTry?.(`${name} ok, removed from the inbox`);
1492
+ unlinkSync(file);
1493
+ return { ok: true, tier: "inbox", format: r.format, raw: r.format === "pdf" ? bytes : bytes.toString("utf8"), md: r.md, version: "published" };
1494
+ }
1495
+ };
1496
+ }
1497
+ var inbox = inboxTier();
1498
+
1499
+ // src/tiers/openalex.ts
1500
+ var openalex = {
1501
+ name: "openalex",
1502
+ applies: (p) => p.pmid || p.doi ? true : "no pmid or doi",
1503
+ async run(p, ctx) {
1504
+ const mail = contactEmail();
1505
+ const key = p.pmid ? `pmid:${p.pmid}` : `doi:${p.doi}`;
1506
+ let data;
1507
+ try {
1508
+ data = JSON.parse(await fetchText(`https://api.openalex.org/works/${encodeURIComponent(key)}?mailto=${encodeURIComponent(mail)}`, { "User-Agent": LIBRARY_UA }));
1509
+ } catch (e) {
1510
+ return miss(this.name, errMsg(e));
1511
+ }
1512
+ const candidates = openalexCandidates(data);
1513
+ if (!candidates.length) return miss(this.name, data.open_access?.is_oa === false ? "closed" : "no oa copy");
1514
+ ctx.onTry?.(`located ${candidates.length} copies`);
1515
+ return download(this.name, p, candidates, ctx);
1516
+ }
1517
+ };
1518
+ function openalexCandidates(d) {
1519
+ const locs = [...d.best_oa_location ? [d.best_oa_location] : [], ...d.locations ?? []].filter((l) => l.is_oa);
1520
+ const seen = /* @__PURE__ */ new Set();
1521
+ const out = [];
1522
+ for (const l of locs) {
1523
+ for (const [url, pdf] of [[l.pdf_url, true], [l.landing_page_url, false]]) {
1524
+ if (!url || seen.has(url) || /doi\.org\//.test(url)) continue;
1525
+ seen.add(url);
1526
+ out.push({ url, version: oaVersion(l.version), source: "openalex", host: l.source?.display_name ?? "unknown", pdf });
1527
+ }
1528
+ }
1529
+ return out;
1530
+ }
1531
+
1532
+ // src/tiers/pmc.ts
1533
+ function pmcEfetchUrl(pmcid) {
1534
+ const u = new URL("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi");
1535
+ u.searchParams.set("db", "pmc");
1536
+ u.searchParams.set("id", pmcid.replace(/^PMC/i, ""));
1537
+ u.searchParams.set("retmode", "xml");
1538
+ if (process.env.NCBI_API_KEY) u.searchParams.set("api_key", process.env.NCBI_API_KEY);
1539
+ u.searchParams.set("tool", "fh-library");
1540
+ u.searchParams.set("email", contactEmail());
1541
+ return u.toString();
1542
+ }
1543
+ var pmc = {
1544
+ name: "pmc",
1545
+ applies: (p) => p.pmcid ? true : "no pmcid",
1546
+ async run(p) {
1547
+ let xml;
1548
+ try {
1549
+ xml = await fetchText(pmcEfetchUrl(p.pmcid));
1550
+ } catch (e) {
1551
+ return miss(this.name, errMsg(e));
1552
+ }
1553
+ const doc = parseJats(xml);
1554
+ if (!doc.hasBody) return miss(this.name, "no body");
1555
+ const md = renderFulltextMd(doc, p.headLine);
1556
+ const check = checkFulltext(md, p.title);
1557
+ if (!check.ok) return miss(this.name, check.reason);
1558
+ return { ok: true, tier: this.name, format: "xml", raw: xml, md, version: "published" };
1559
+ }
1560
+ };
1561
+
1562
+ // src/tiers/tdm.ts
1563
+ var CROSSREF = "https://api.crossref.org/works/";
1564
+ var TOKENED = [
1565
+ { host: /(^|\.)api\.elsevier\.com$/i, key: "elsevier", env: "ELSEVIER_API_KEY", header: "X-ELS-APIKey" },
1566
+ { host: /(^|\.)api\.wiley\.com$/i, key: "wiley", env: "WILEY_TDM_TOKEN", header: "Wiley-TDM-Client-Token" }
1567
+ ];
1568
+ var hostOf2 = (url) => {
1569
+ try {
1570
+ return new URL(url).hostname;
1571
+ } catch {
1572
+ return "";
1573
+ }
1574
+ };
1575
+ function tdmCandidates(work, tokens2 = {}, env = process.env) {
1576
+ const links = (work.message?.link ?? []).filter((l) => l["intended-application"] === "text-mining" && l.URL);
1577
+ const seen = /* @__PURE__ */ new Set();
1578
+ const candidates = [];
1579
+ const missingTokens = /* @__PURE__ */ new Set();
1580
+ for (const l of links) {
1581
+ const ct = (l["content-type"] ?? "").toLowerCase();
1582
+ if (seen.has(l.URL)) {
1583
+ const prev = candidates.find((c) => c.url === l.URL);
1584
+ if (prev && /pdf/.test(ct)) prev.pdf = true;
1585
+ continue;
1586
+ }
1587
+ seen.add(l.URL);
1588
+ const rule = TOKENED.find((r) => r.host.test(hostOf2(l.URL)));
1589
+ let headers;
1590
+ if (rule) {
1591
+ const token = tokens2[rule.key]?.trim() || env[rule.env]?.trim();
1592
+ if (!token) {
1593
+ missingTokens.add(rule.key);
1594
+ continue;
1595
+ }
1596
+ headers = { [rule.header]: token };
1597
+ }
1598
+ if (/text\/plain/.test(ct)) continue;
1599
+ candidates.push({ url: l.URL, version: "published", source: "tdm", host: work.message?.publisher ?? hostOf2(l.URL), pdf: /pdf/.test(ct) || /\.pdf(\?|$)/i.test(l.URL), headers });
1600
+ }
1601
+ const rank = (c) => /\/xml|text\/xml/.test(c.url) ? 0 : c.pdf ? 1 : 2;
1602
+ candidates.sort((a, b) => rank(a) - rank(b));
1603
+ return { candidates, missingTokens: [...missingTokens] };
1604
+ }
1605
+ function tdmTier(tokens2 = {}) {
1606
+ return {
1607
+ name: "tdm",
1608
+ applies: (p) => p.doi ? true : "no doi",
1609
+ async run(p, ctx) {
1610
+ let work;
1611
+ try {
1612
+ work = JSON.parse(await fetchText(CROSSREF + encodeURIComponent(p.doi), { "User-Agent": `${LIBRARY_UA} (mailto:${contactEmail()})` }));
1613
+ } catch (e) {
1614
+ return miss(this.name, errMsg(e));
1615
+ }
1616
+ const { candidates, missingTokens } = tdmCandidates(work, tokens2);
1617
+ if (!candidates.length) return missingTokens.length ? { ...miss(this.name, `needs ${missingTokens.join("/")} token`), skipped: true } : miss(this.name, "no text-mining links");
1618
+ ctx.onTry?.(`${candidates.length} text-mining links${missingTokens.length ? ` (skipped hosts needing ${missingTokens.join("/")} token)` : ""}`);
1619
+ return download(this.name, p, candidates, ctx);
1620
+ }
1621
+ };
1622
+ }
1623
+ var tdm = tdmTier();
1624
+
1625
+ // src/tiers/unpaywall.ts
1626
+ var unpaywall = {
1627
+ name: "unpaywall",
1628
+ applies: (p) => p.doi ? true : "no doi",
1629
+ async run(p, ctx) {
1630
+ const mail = contactEmail();
1631
+ let data;
1632
+ try {
1633
+ data = JSON.parse(await fetchText(`https://api.unpaywall.org/v2/${encodeURIComponent(p.doi)}?email=${encodeURIComponent(mail)}`, { "User-Agent": LIBRARY_UA }));
1634
+ } catch (e) {
1635
+ return miss(this.name, errMsg(e));
1636
+ }
1637
+ const candidates = unpaywallCandidates(data);
1638
+ if (!candidates.length) return miss(this.name, data.is_oa === false ? "closed" : "no oa copy");
1639
+ ctx.onTry?.(`located ${candidates.length} copies`);
1640
+ return download(this.name, p, candidates, ctx);
1641
+ }
1642
+ };
1643
+ function unpaywallCandidates(d) {
1644
+ const locs = [...d.best_oa_location ? [d.best_oa_location] : [], ...d.oa_locations ?? []];
1645
+ const seen = /* @__PURE__ */ new Set();
1646
+ const out = [];
1647
+ for (const l of locs) {
1648
+ for (const [url, pdf] of [[l.url_for_pdf, true], [l.url ?? l.url_for_landing_page, false]]) {
1649
+ if (!url || seen.has(url)) continue;
1650
+ seen.add(url);
1651
+ out.push({ url, version: oaVersion(l.version), source: "unpaywall", host: l.host_type ?? "unknown", pdf });
1652
+ }
1653
+ }
1654
+ return out;
1655
+ }
1656
+
1657
+ // src/tiers/index.ts
1658
+ var BUILTIN = [
1659
+ { name: "inbox", make: () => inbox },
1660
+ { name: "pmc", make: () => pmc },
1661
+ { name: "europepmc", make: () => europepmc },
1662
+ { name: "tdm", make: (o) => tdmTier(o.tokens ?? {}) },
1663
+ { name: "unpaywall", make: () => unpaywall },
1664
+ { name: "openalex", make: () => openalex },
1665
+ { name: "core", make: (o) => coreTier(o.api_key) }
1666
+ ];
1667
+
1668
+ // src/tiers/url.ts
1669
+ var PLACEHOLDERS = ["doi", "pmid", "pmcid"];
1670
+ function urlTier(name, template, headers) {
1671
+ const used = PLACEHOLDERS.filter((k) => template.includes(`{${k}}`));
1672
+ return {
1673
+ name,
1674
+ applies(p) {
1675
+ const missing = used.filter((k) => !p[k]);
1676
+ return missing.length ? `no ${missing.join("/")}` : true;
1677
+ },
1678
+ async run(p, ctx) {
1679
+ if (!used.length) return miss(name, "template has no placeholder");
1680
+ const url = used.reduce((u, k) => u.replaceAll(`{${k}}`, encodeURIComponent(p[k])), template);
1681
+ return download(name, p, [{ url, version: "published", source: name, host: "user", pdf: /\.pdf(\?|$)/i.test(url), headers, service: true }], ctx);
1682
+ }
1683
+ };
1684
+ }
1685
+
1686
+ // src/config.ts
1687
+ var VARIABLES = [
1688
+ "FH_LIBRARY_DIR",
1689
+ "FH_LIBRARY_ENV",
1690
+ "FH_LIBRARY_TIERS",
1691
+ "FH_LIBRARY_CONTACT_EMAIL",
1692
+ "FH_LIBRARY_ELSEVIER_API_KEY",
1693
+ "FH_LIBRARY_WILEY_TDM_TOKEN",
1694
+ "FH_LIBRARY_CORE_API_KEY",
1695
+ "FH_LIBRARY_PROXY_URL",
1696
+ "FH_LIBRARY_PROXY_HEADERS",
1697
+ "FH_LIBRARY_BROWSER_1",
1698
+ "FH_LIBRARY_BROWSER_2",
1699
+ "FH_LIBRARY_BROWSER_3",
1700
+ "FH_LIBRARY_BROWSER_HEADLESS",
1701
+ "FH_LIBRARY_VERIFY_URL",
1702
+ "FH_LIBRARY_VERIFY_API_KEY",
1703
+ "FH_LIBRARY_VERIFY_MODEL",
1704
+ "FH_LIBRARY_VERIFY_THRESHOLD",
1705
+ "FH_LIBRARY_VERIFY_TIMEOUT_SECS",
1706
+ "FH_LIBRARY_RETRY_AFTER_DAYS",
1707
+ "FH_LIBRARY_RETRY_BLOCKED",
1708
+ "FH_LIBRARY_REFRESH_AFTER_DAYS",
1709
+ "NCBI_API_KEY",
1710
+ "BRIGHTDATA_API_TOKEN",
1711
+ "BRIGHTDATA_ZONE"
1712
+ ];
1713
+ var sources = (env = process.env) => Object.fromEntries(VARIABLES.map((v) => [v, sourceOf(v, env)]));
1714
+ function settings(env = process.env) {
1715
+ return {
1716
+ retry_after_days: num("FH_LIBRARY_RETRY_AFTER_DAYS", 1, env),
1717
+ retry_blocked: bool("FH_LIBRARY_RETRY_BLOCKED", false, env),
1718
+ refresh_after_days: num("FH_LIBRARY_REFRESH_AFTER_DAYS", 30, env)
1719
+ };
1720
+ }
1721
+ function proxyHeaders(spec) {
1722
+ const out = {};
1723
+ for (const part of spec.split(";")) {
1724
+ const i = part.indexOf(":");
1725
+ if (i < 0) continue;
1726
+ const name = part.slice(0, i).trim(), value = part.slice(i + 1).trim();
1727
+ if (name && value) out[name] = value;
1728
+ }
1729
+ return out;
1730
+ }
1731
+ function resolveTiers(env = process.env) {
1732
+ const problems = [];
1733
+ const available = /* @__PURE__ */ new Map();
1734
+ const tokens2 = { elsevier: str("FH_LIBRARY_ELSEVIER_API_KEY", env) || void 0, wiley: str("FH_LIBRARY_WILEY_TDM_TOKEN", env) || void 0 };
1735
+ for (const t of BUILTIN) available.set(t.name, t.make({ tokens: tokens2, api_key: str("FH_LIBRARY_CORE_API_KEY", env) || void 0 }));
1736
+ const proxyUrl = str("FH_LIBRARY_PROXY_URL", env);
1737
+ if (proxyUrl) available.set("proxy", urlTier("proxy", proxyUrl, proxyHeaders(str("FH_LIBRARY_PROXY_HEADERS", env))));
1738
+ const listed = str("FH_LIBRARY_TIERS", env);
1739
+ const order2 = listed ? listed.split(",").map((s) => s.trim()).filter(Boolean) : [...available.keys()];
1740
+ const tiers = [], seen = /* @__PURE__ */ new Set();
1741
+ for (const name of order2) {
1742
+ if (seen.has(name)) continue;
1743
+ seen.add(name);
1744
+ const t = available.get(name);
1745
+ if (t) tiers.push(t);
1746
+ else if (name === "proxy") problems.push('FH_LIBRARY_TIERS: "proxy" listed, but FH_LIBRARY_PROXY_URL is unset');
1747
+ else problems.push(`FH_LIBRARY_TIERS: unknown tier "${name}" (built-in: ${BUILTIN.map((t2) => t2.name).join(", ")}; proxy when FH_LIBRARY_PROXY_URL is set)`);
1748
+ }
1749
+ const all = [...tiers.map((t) => ({ name: t.name, enabled: true })), ...[...available.keys()].filter((n) => !seen.has(n)).map((name) => ({ name, enabled: false }))];
1750
+ return { tiers, all, source: sourceOf("FH_LIBRARY_TIERS", env), problems };
1751
+ }
1752
+
1753
+ // src/log.ts
1754
+ import { appendFileSync, mkdirSync as mkdirSync2, renameSync, statSync } from "fs";
1755
+ import { hostname } from "os";
1756
+ import { join as join5 } from "path";
1757
+ var HOST = hostname();
1758
+ var LOG_FILE = join5(FH_LIBRARY_DIR, "library.log");
1759
+ function stamp() {
1760
+ const d = /* @__PURE__ */ new Date();
1761
+ const p = (n) => String(n).padStart(2, "0");
1762
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
1763
+ }
1764
+ var LOG_MAX_BYTES = 50 * 1024 * 1024;
1765
+ function rotateIfLarge(file, max = LOG_MAX_BYTES) {
1766
+ let size = 0;
1767
+ try {
1768
+ size = statSync(file).size;
1769
+ } catch {
1770
+ return false;
1771
+ }
1772
+ if (size <= max) return false;
1773
+ try {
1774
+ renameSync(file, `${file}.1`);
1775
+ return true;
1776
+ } catch {
1777
+ return false;
1778
+ }
1779
+ }
1780
+ function log(tool, id, message) {
1781
+ const clean = message.replace(/\x1b\[[0-9;]*m/g, "").replace(/\s*\n\s*/g, " | ");
1782
+ const line = `${stamp()} ${HOST} ${tool.replace(/^library_/, "")} ${id ?? "-"} ${clean}
1783
+ `;
1784
+ try {
1785
+ mkdirSync2(FH_LIBRARY_DIR, { recursive: true });
1786
+ rotateIfLarge(LOG_FILE);
1787
+ appendFileSync(LOG_FILE, line);
1788
+ } catch {
1789
+ }
1790
+ }
1791
+
1792
+ // src/tools.ts
1793
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
1794
+ import { z } from "zod";
1795
+
1796
+ // src/entry.ts
1797
+ import { copyFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
1798
+ import { join as join6 } from "path";
1799
+ import { basename, dirname } from "path";
1800
+
1801
+ // src/pubmed.ts
1802
+ import { XMLParser as XMLParser2 } from "fast-xml-parser";
1803
+ var CORRECTION_TYPES = {
1804
+ ErratumIn: "erratum_in",
1805
+ RetractionIn: "retraction_in",
1806
+ PartialRetractionIn: "partial_retraction_in",
1807
+ RetractedandRepublishedIn: "retracted_and_republished_in",
1808
+ ExpressionOfConcernIn: "expression_of_concern_in",
1809
+ UpdateIn: "update_in"
1810
+ };
1811
+ var RETRACTION_TYPES = /* @__PURE__ */ new Set(["retraction_in", "partial_retraction_in", "retracted_and_republished_in"]);
1812
+ var isRetractionNotice = (c) => RETRACTION_TYPES.has(c.type);
1813
+ var EFETCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi";
1814
+ function efetchUrl(pmid) {
1815
+ const u = new URL(EFETCH);
1816
+ u.searchParams.set("db", "pubmed");
1817
+ u.searchParams.set("id", pmid);
1818
+ u.searchParams.set("retmode", "xml");
1819
+ if (process.env.NCBI_API_KEY) u.searchParams.set("api_key", process.env.NCBI_API_KEY);
1820
+ u.searchParams.set("tool", "fh-library");
1821
+ u.searchParams.set("email", contactEmail());
1822
+ return u.toString();
1823
+ }
1824
+ async function fetchPubmedXml(pmid) {
1825
+ try {
1826
+ return await fetchText(efetchUrl(pmid));
1827
+ } catch (e) {
1828
+ throw new Error(`pubmed fetch failed: ${e instanceof Error ? e.message : String(e)}`);
1829
+ }
1830
+ }
1831
+ var parser2 = new XMLParser2({
1832
+ ignoreAttributes: false,
1833
+ attributeNamePrefix: "@_",
1834
+ parseTagValue: false,
1835
+ processEntities: true,
1836
+ stopNodes: ["*.ArticleTitle", "*.AbstractText", "*.CoiStatement"],
1837
+ isArray: (name) => ["PubmedArticle", "PubmedBookArticle", "AbstractText", "Author", "ArticleId", "PublicationType", "CommentsCorrections", "Grant", "AuthorList"].includes(name)
1838
+ });
1839
+ function text(n) {
1840
+ if (n == null) return "";
1841
+ if (typeof n === "object") return text(n["#text"]);
1842
+ return String(n);
1843
+ }
1844
+ var plain = (n) => decodeEntities(text(n)).trim();
1845
+ function inlineText2(raw) {
1846
+ return decodeEntities(raw.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
1847
+ }
1848
+ function decodeEntities(s) {
1849
+ return s.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
1850
+ }
1851
+ function parsePubmedXml(xml) {
1852
+ const doc = parser2.parse(xml);
1853
+ const article = doc?.PubmedArticleSet?.PubmedArticle?.[0];
1854
+ if (!article) {
1855
+ const book = doc?.PubmedArticleSet?.PubmedBookArticle?.[0];
1856
+ return book ? parseBook(book) : null;
1857
+ }
1858
+ const cit = article.MedlineCitation ?? {};
1859
+ const art = cit.Article ?? {};
1860
+ const ids = article.PubmedData?.ArticleIdList?.ArticleId ?? [];
1861
+ const idOf = (t) => {
1862
+ const hit = ids.find((i) => i["@_IdType"] === t);
1863
+ return hit ? text(hit).trim() || null : null;
1864
+ };
1865
+ const authorList = art.AuthorList?.[0]?.Author ?? [];
1866
+ const authors = authorList.map((a) => {
1867
+ if (a.CollectiveName) return plain(a.CollectiveName);
1868
+ const last = plain(a.LastName);
1869
+ const ini = plain(a.Initials);
1870
+ return [last, ini].filter(Boolean).join(" ");
1871
+ }).filter(Boolean);
1872
+ const first = authorList[0];
1873
+ const firstAuthor = first ? plain(first.CollectiveName ?? first.LastName) || null : null;
1874
+ const pub = art.Journal?.JournalIssue?.PubDate ?? {};
1875
+ let year = text(pub.Year).trim() || null;
1876
+ if (!year) year = /\d{4}/.exec(text(pub.MedlineDate))?.[0] ?? null;
1877
+ const abstract = (art.Abstract?.AbstractText ?? []).map((a) => ({
1878
+ label: typeof a === "object" ? a["@_Label"] ?? null : null,
1879
+ text: inlineText2(text(a))
1880
+ })).filter((a) => a.text);
1881
+ const corrections = (cit.CommentsCorrectionsList?.CommentsCorrections ?? []).flatMap((c) => {
1882
+ const type = CORRECTION_TYPES[String(c["@_RefType"] ?? "")];
1883
+ if (!type) return [];
1884
+ return [{ type, pmid: text(c.PMID).trim() || null, source: inlineText2(text(c.RefSource)) }];
1885
+ });
1886
+ const funding = [];
1887
+ for (const g of art.GrantList?.Grant ?? []) {
1888
+ const a = plain(g.Agency);
1889
+ if (a && !funding.includes(a)) funding.push(a);
1890
+ }
1891
+ return {
1892
+ coi: inlineText2(text(cit.CoiStatement)) || null,
1893
+ funding,
1894
+ pmid: text(cit.PMID).trim() || idOf("pubmed") || "",
1895
+ pmcid: idOf("pmc"),
1896
+ doi: idOf("doi"),
1897
+ title: inlineText2(text(art.ArticleTitle)),
1898
+ authors,
1899
+ firstAuthor,
1900
+ journal: plain(art.Journal?.Title) || null,
1901
+ year,
1902
+ publicationTypes: (art.PublicationTypeList?.PublicationType ?? []).map(plain).filter(Boolean),
1903
+ abstract,
1904
+ corrections
1905
+ };
1906
+ }
1907
+ function parseBook(b) {
1908
+ const d = b.BookDocument ?? {};
1909
+ const book = d.Book ?? {};
1910
+ const ids = [...d.ArticleIdList?.ArticleId ?? [], ...b.PubmedBookData?.ArticleIdList?.ArticleId ?? []];
1911
+ const idOf = (t) => {
1912
+ const hit = ids.find((i) => i["@_IdType"] === t);
1913
+ return hit ? text(hit).trim() || null : null;
1914
+ };
1915
+ const lists = [...d.AuthorList ?? [], ...book.AuthorList ?? []];
1916
+ const pick = lists.find((l) => (l["@_Type"] ?? "authors") === "authors" && (l.Author ?? []).length) ?? lists.find((l) => (l.Author ?? []).length);
1917
+ const people = pick?.Author ?? [];
1918
+ const authors = people.map((a) => a.CollectiveName ? plain(a.CollectiveName) : [plain(a.LastName), plain(a.Initials)].filter(Boolean).join(" ")).filter(Boolean);
1919
+ const first = people[0];
1920
+ const abstract = (d.Abstract?.AbstractText ?? []).map((a) => ({ label: typeof a === "object" ? a["@_Label"] ?? null : null, text: inlineText2(text(a)) })).filter((a) => a.text);
1921
+ const types = ["Book", ...(d.PublicationType ?? []).map(plain).filter(Boolean)];
1922
+ return {
1923
+ pmid: text(d.PMID).trim() || idOf("pubmed") || "",
1924
+ pmcid: null,
1925
+ doi: idOf("doi") ?? (text(book.ELocationID).trim() || null),
1926
+ title: inlineText2(text(d.ArticleTitle)) || inlineText2(text(book.BookTitle)),
1927
+ authors,
1928
+ firstAuthor: first ? plain(first.CollectiveName ?? first.LastName) || null : null,
1929
+ journal: plain(book.Publisher?.PublisherName) || null,
1930
+ year: text(book.PubDate?.Year).trim() || null,
1931
+ publicationTypes: [...new Set(types)],
1932
+ abstract,
1933
+ corrections: [],
1934
+ coi: null,
1935
+ funding: []
1936
+ };
1937
+ }
1938
+
1939
+ // src/spl.ts
1940
+ import { XMLParser as XMLParser3 } from "fast-xml-parser";
1941
+ var DAILYMED_PAGE = (setid) => `https://dailymed.nlm.nih.gov/dailymed/drugInfo.cfm?setid=${setid}`;
1942
+ var SPL_URL = (setid) => `https://dailymed.nlm.nih.gov/dailymed/services/v2/spls/${setid}.xml`;
1943
+ async function fetchSpl(setid) {
1944
+ const xml = await fetchText(SPL_URL(setid), { "User-Agent": LIBRARY_UA, Accept: "application/xml, */*;q=0.8" });
1945
+ if (!/<document[\s>]/.test(xml.slice(0, 3e3))) throw new Error("dailymed: not an SPL document");
1946
+ return xml;
1947
+ }
1948
+ var parser3 = new XMLParser3({ preserveOrder: true, ignoreAttributes: false, attributeNamePrefix: "@_", parseTagValue: false, trimValues: false, processEntities: true, htmlEntities: true });
1949
+ var tagOf2 = (n) => Object.keys(n).find((k) => k !== ":@") ?? "";
1950
+ var kids2 = (n) => n[tagOf2(n)] ?? [];
1951
+ var attr2 = (n, a) => n[":@"]?.[`@_${a}`] ?? "";
1952
+ var squash2 = (s) => s.replace(/\s+/g, " ").trim();
1953
+ var child = (n, tag) => kids2(n).find((x) => tagOf2(x) === tag);
1954
+ function findDeep2(nodes, tag) {
1955
+ for (const n of nodes) {
1956
+ if ("#text" in n) continue;
1957
+ if (tagOf2(n) === tag) return n;
1958
+ const h = findDeep2(kids2(n), tag);
1959
+ if (h) return h;
1960
+ }
1961
+ return void 0;
1962
+ }
1963
+ function inline(nodes) {
1964
+ let out = "";
1965
+ for (const n of nodes) {
1966
+ if ("#text" in n) {
1967
+ out += String(n["#text"]);
1968
+ continue;
1969
+ }
1970
+ const t = tagOf2(n);
1971
+ const inner = () => inline(kids2(n));
1972
+ switch (t) {
1973
+ case "content": {
1974
+ const style = attr2(n, "styleCode").toLowerCase();
1975
+ const s = inner();
1976
+ out += /bold/.test(style) ? `**${s}**` : /italic/.test(style) ? `*${s}*` : s;
1977
+ break;
1978
+ }
1979
+ case "sup":
1980
+ out += `^${inner()}^`;
1981
+ break;
1982
+ case "sub":
1983
+ out += `~${inner()}~`;
1984
+ break;
1985
+ case "br":
1986
+ out += " ";
1987
+ break;
1988
+ case "footnote":
1989
+ out += ` (${squash2(inner())})`;
1990
+ break;
1991
+ case "footnoteRef":
1992
+ case "renderMultiMedia":
1993
+ break;
1994
+ default:
1995
+ out += inner();
1996
+ }
1997
+ }
1998
+ return out;
1999
+ }
2000
+ function table(n) {
2001
+ const row2 = (x) => kids2(x).filter((c) => ["td", "th"].includes(tagOf2(c))).map((c) => squash2(inline(kids2(c))).replace(/\|/g, "\\|"));
2002
+ const collect = (nodes, into) => {
2003
+ for (const x of nodes) {
2004
+ if ("#text" in x) continue;
2005
+ if (tagOf2(x) === "tr") into.push(row2(x));
2006
+ else if (!["thead", "tfoot"].includes(tagOf2(x))) collect(kids2(x), into);
2007
+ }
2008
+ };
2009
+ const head = [], body = [], foot = [];
2010
+ for (const x of kids2(n)) {
2011
+ const t = tagOf2(x);
2012
+ if (t === "thead") collect(kids2(x), head);
2013
+ else if (t === "tfoot") collect(kids2(x), foot);
2014
+ else if (t === "tr") body.push(row2(x));
2015
+ else if (!("#text" in x) && t !== "caption") collect(kids2(x), body);
2016
+ }
2017
+ const rows = [...head, ...body];
2018
+ if (!rows.length) return "";
2019
+ const width = Math.max(...rows.map((r) => r.length));
2020
+ const line = (r) => `| ${[...r, ...Array(width - r.length).fill("")].join(" | ")} |`;
2021
+ const cap = child(n, "caption");
2022
+ const title = cap ? squash2(inline(kids2(cap))) : "";
2023
+ const notes = foot.map((r) => r.filter(Boolean).join(" ")).filter(Boolean);
2024
+ return [title ? `**${title}**` : "", line(rows[0]), `| ${Array(width).fill("---").join(" | ")} |`, ...rows.slice(1).map(line), ...notes.length ? ["", ...notes] : []].filter((l, i) => l || i > 0).join("\n");
2025
+ }
2026
+ function blocks(nodes, depth) {
2027
+ const out = [];
2028
+ for (const n of nodes) {
2029
+ if ("#text" in n) {
2030
+ const t2 = squash2(String(n["#text"]));
2031
+ if (t2) out.push(t2);
2032
+ continue;
2033
+ }
2034
+ const t = tagOf2(n);
2035
+ const c = kids2(n);
2036
+ switch (t) {
2037
+ case "paragraph": {
2038
+ const s = squash2(inline(c.filter((x) => !["table", "list"].includes(tagOf2(x)))));
2039
+ if (s) out.push(s);
2040
+ out.push(...blocks(c.filter((x) => ["table", "list"].includes(tagOf2(x))), depth));
2041
+ break;
2042
+ }
2043
+ case "list": {
2044
+ const ordered = /^(ordered|numbered|arabic|roman|alpha)/i.test(attr2(n, "listType"));
2045
+ let i = 0;
2046
+ for (const it of c.filter((x) => tagOf2(x) === "item")) {
2047
+ i++;
2048
+ const body = blocks(kids2(it).filter((x) => tagOf2(x) !== "caption"), depth).join(" ");
2049
+ if (body) out.push(`${ordered ? `${i}.` : "-"} ${body}`);
2050
+ }
2051
+ break;
2052
+ }
2053
+ case "table": {
2054
+ const md = table(n);
2055
+ if (md) out.push(md);
2056
+ break;
2057
+ }
2058
+ case "renderMultiMedia":
2059
+ case "observationMedia":
2060
+ case "subject":
2061
+ break;
2062
+ case "section":
2063
+ out.push(...section(n, depth));
2064
+ break;
2065
+ case "component":
2066
+ case "text":
2067
+ case "excerpt":
2068
+ case "highlight":
2069
+ default:
2070
+ out.push(...blocks(c, depth));
2071
+ break;
2072
+ }
2073
+ }
2074
+ return out;
2075
+ }
2076
+ var SKIP_CODES = /* @__PURE__ */ new Set(["48780-1", "51945-4"]);
2077
+ var CODE_NAMES = { "34066-1": "Boxed Warning", "43683-2": "Recent Major Changes" };
2078
+ var plain2 = (nodes) => {
2079
+ let out = "";
2080
+ for (const n of nodes) out += "#text" in n ? String(n["#text"]) : tagOf2(n) === "br" ? " " : plain2(kids2(n));
2081
+ return out;
2082
+ };
2083
+ function section(n, depth) {
2084
+ const code = child(n, "code");
2085
+ const c = code ? attr2(code, "code") : "";
2086
+ if (SKIP_CODES.has(c)) return [];
2087
+ const title = child(n, "title");
2088
+ let head = title ? squash2(plain2(kids2(title))) : "";
2089
+ if (c === "34066-1" && head && !/boxed warning/i.test(head)) head = `Boxed Warning: ${head}`;
2090
+ if (!head && CODE_NAMES[c]) head = CODE_NAMES[c];
2091
+ const out = [];
2092
+ if (head) out.push(`${"#".repeat(Math.min(depth, 6))} ${head}`);
2093
+ const rest = kids2(n).filter((x) => !["title", "code", "id", "effectiveTime"].includes(tagOf2(x)));
2094
+ out.push(...blocks(rest, head ? depth + 1 : depth));
2095
+ return out;
2096
+ }
2097
+ var isoDate = (v) => /^\d{8}$/.test(v) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : v || null;
2098
+ function parseSpl(xml) {
2099
+ const doc = parser3.parse(xml);
2100
+ const d = doc.find((x) => tagOf2(x) === "document");
2101
+ if (!d) return null;
2102
+ const k = kids2(d);
2103
+ const setid = attr2(child(d, "setId") ?? {}, "root").toLowerCase();
2104
+ const version = attr2(child(d, "versionNumber") ?? {}, "value") || null;
2105
+ const effective = isoDate(attr2(child(d, "effectiveTime") ?? {}, "value"));
2106
+ const named = (nodes) => {
2107
+ for (const n of nodes) {
2108
+ if ("#text" in n) continue;
2109
+ if (tagOf2(n) === "manufacturedProduct") {
2110
+ const inner2 = child(n, "manufacturedProduct") ?? n;
2111
+ if (child(inner2, "name")) return inner2;
2112
+ }
2113
+ const h = named(kids2(n));
2114
+ if (h) return h;
2115
+ }
2116
+ return void 0;
2117
+ };
2118
+ const inner = named(k);
2119
+ const brand = inner ? squash2(inline(kids2(child(inner, "name") ?? { name: [] }))) || null : null;
2120
+ const genericNode = inner ? findDeep2(kids2(inner), "genericMedicine") : void 0;
2121
+ const generic = genericNode ? squash2(inline(kids2(child(genericNode, "name") ?? { name: [] }))) || null : null;
2122
+ const formNode = inner ? child(inner, "formCode") : void 0;
2123
+ const form = formNode ? attr2(formNode, "displayName").toLowerCase() || null : null;
2124
+ const org = findDeep2(k, "representedOrganization");
2125
+ const labeler = org ? squash2(inline(kids2(child(org, "name") ?? { name: [] }))) || null : null;
2126
+ const body = findDeep2(k, "structuredBody");
2127
+ const sections2 = body ? blocks(kids2(body), 2) : [];
2128
+ const docTitle = child(d, "title");
2129
+ const lines = [];
2130
+ let cur = "";
2131
+ for (const x of docTitle ? kids2(docTitle) : []) {
2132
+ if (!("#text" in x) && tagOf2(x) === "br") {
2133
+ lines.push(cur);
2134
+ cur = "";
2135
+ } else cur += "#text" in x ? String(x["#text"]) : inline([x]);
2136
+ }
2137
+ lines.push(cur);
2138
+ const highlights = lines.map(squash2).filter((l) => l && !/^these highlights do not include/i.test(l) && !/^see full prescribing information/i.test(l)).map((l) => l.replace(/^see full prescribing information for [^.]+\.\s*/i, "")).filter(Boolean);
2139
+ const approvalYear = /initial u\.?s\.? approval:?\s*(\d{4})/i.exec(highlights.join(" "))?.[1] ?? null;
2140
+ const productLine = highlights.find((l) => !/initial u\.?s\.? approval/i.test(l)) ?? null;
2141
+ const title = [brand, generic ? `(${generic})` : "", form].filter(Boolean).join(" ") || productLine || setid;
2142
+ return { setid, version, effective, brand, generic, form, labeler, title: `${title} \u2014 prescribing information`, highlights, approvalYear, sections: sections2 };
2143
+ }
2144
+ function headLineSpl(s) {
2145
+ return [s.labeler, `DailyMed set ID ${s.setid}`, s.version ? `version ${s.version}` : "", s.effective ? `effective ${s.effective}` : "", s.approvalYear ? `initial U.S. approval ${s.approvalYear}` : "", DAILYMED_PAGE(s.setid)].filter(Boolean).join(" \xB7 ");
2146
+ }
2147
+ function renderLabelMd(s) {
2148
+ const parts = [`# ${s.title}`, "", headLineSpl(s), ""];
2149
+ if (s.highlights.length) parts.push("## Highlights", "", ...s.highlights.flatMap((l) => [l, ""]));
2150
+ parts.push(...s.sections.flatMap((b) => [b, ""]));
2151
+ return parts.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
2152
+ }
2153
+
2154
+ // src/ctgov.ts
2155
+ var CTGOV_PAGE = (nctid) => `https://clinicaltrials.gov/study/${nctid}`;
2156
+ var API = (nctid) => `https://clinicaltrials.gov/api/v2/studies/${nctid}?format=json`;
2157
+ async function fetchStudy(nctid) {
2158
+ const json2 = await fetchText(API(nctid), { "User-Agent": LIBRARY_UA, Accept: "application/json" });
2159
+ if (!/"protocolSection"/.test(json2.slice(0, 2e3))) throw new Error("ctgov: not a study record");
2160
+ return json2;
2161
+ }
2162
+ var STATUS = {
2163
+ NOT_YET_RECRUITING: "Not yet recruiting",
2164
+ RECRUITING: "Recruiting",
2165
+ ENROLLING_BY_INVITATION: "Enrolling by invitation",
2166
+ ACTIVE_NOT_RECRUITING: "Active, not recruiting",
2167
+ SUSPENDED: "Suspended",
2168
+ TERMINATED: "Terminated",
2169
+ COMPLETED: "Completed",
2170
+ WITHDRAWN: "Withdrawn",
2171
+ UNKNOWN: "Unknown status",
2172
+ AVAILABLE: "Available",
2173
+ NO_LONGER_AVAILABLE: "No longer available",
2174
+ TEMPORARILY_NOT_AVAILABLE: "Temporarily not available",
2175
+ APPROVED_FOR_MARKETING: "Approved for marketing",
2176
+ WITHHELD: "Withheld"
2177
+ };
2178
+ var PHASE = { EARLY_PHASE1: "Early Phase 1", PHASE1: "Phase 1", PHASE2: "Phase 2", PHASE3: "Phase 3", PHASE4: "Phase 4", NA: "Not applicable" };
2179
+ var words = (v) => {
2180
+ const s = String(v ?? "");
2181
+ if (!s) return "";
2182
+ if (STATUS[s]) return STATUS[s];
2183
+ if (PHASE[s]) return PHASE[s];
2184
+ const w = s.toLowerCase().replace(/_/g, " ");
2185
+ return w.charAt(0).toUpperCase() + w.slice(1);
2186
+ };
2187
+ var dated = (d) => d?.date ? `${d.date}${d.type ? ` (${String(d.type).toLowerCase()})` : ""}` : "";
2188
+ var list = (a) => Array.isArray(a) ? a : [];
2189
+ var row = (cells) => `| ${cells.map((c) => c.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()).join(" | ")} |`;
2190
+ var table2 = (head, rows) => rows.length ? [row(head), `| ${head.map(() => "---").join(" | ")} |`, ...rows.map(row)].join("\n") : "";
2191
+ var para = (s) => String(s ?? "").replace(/\r/g, "").trim();
2192
+ function parseStudy(json2) {
2193
+ let d;
2194
+ try {
2195
+ d = JSON.parse(json2);
2196
+ } catch {
2197
+ return null;
2198
+ }
2199
+ const ps = d?.protocolSection;
2200
+ if (!ps) return null;
2201
+ const idm = ps.identificationModule ?? {}, st = ps.statusModule ?? {}, sp = ps.sponsorCollaboratorsModule ?? {}, de = ps.descriptionModule ?? {};
2202
+ const dm = ps.designModule ?? {}, ai = ps.armsInterventionsModule ?? {}, om = ps.outcomesModule ?? {}, el = ps.eligibilityModule ?? {}, cl = ps.contactsLocationsModule ?? {}, rf = ps.referencesModule ?? {};
2203
+ const nctid = String(idm.nctId ?? "").toUpperCase();
2204
+ const out = [];
2205
+ const sec = (title, lines) => {
2206
+ const body = lines.filter((l) => !!l && l.trim() !== "");
2207
+ if (body.length) out.push(`## ${title}`, ...body);
2208
+ };
2209
+ const secondary = list(idm.secondaryIdInfos).map((s) => `${words(s.type)}${s.domain ? ` (${s.domain})` : ""}: ${s.id}`);
2210
+ sec("Identification", [
2211
+ idm.officialTitle ? `Official title: ${para(idm.officialTitle)}` : null,
2212
+ idm.acronym ? `Acronym: ${idm.acronym}` : null,
2213
+ idm.organization?.fullName ? `Organization: ${idm.organization.fullName}` : null,
2214
+ secondary.length ? `Secondary IDs: ${secondary.join("; ")}` : null
2215
+ ]);
2216
+ sec("Status", [
2217
+ `Overall status: ${words(st.overallStatus)}${st.whyStopped ? ` \u2014 why stopped: ${para(st.whyStopped)}` : ""}`,
2218
+ st.startDateStruct ? `Start: ${dated(st.startDateStruct)}` : null,
2219
+ st.primaryCompletionDateStruct ? `Primary completion: ${dated(st.primaryCompletionDateStruct)}` : null,
2220
+ st.completionDateStruct ? `Completion: ${dated(st.completionDateStruct)}` : null,
2221
+ st.studyFirstPostDateStruct ? `First posted: ${dated(st.studyFirstPostDateStruct)}` : null,
2222
+ st.lastUpdatePostDateStruct ? `Last update posted: ${dated(st.lastUpdatePostDateStruct)}` : null,
2223
+ st.statusVerifiedDate ? `Status verified: ${st.statusVerifiedDate}` : null,
2224
+ `Results posted: ${d.hasResults ? "yes" : "no"}`
2225
+ ]);
2226
+ const rp = sp.responsibleParty;
2227
+ sec("Sponsor and collaborators", [
2228
+ sp.leadSponsor?.name ? `Lead sponsor: ${sp.leadSponsor.name}${sp.leadSponsor.class ? ` (${words(sp.leadSponsor.class).toLowerCase()})` : ""}` : null,
2229
+ list(sp.collaborators).length ? `Collaborators: ${list(sp.collaborators).map((c) => c.name).join("; ")}` : null,
2230
+ rp ? `Responsible party: ${[words(rp.type).toLowerCase(), rp.investigatorFullName, rp.investigatorTitle, rp.investigatorAffiliation].filter(Boolean).join(", ")}` : null
2231
+ ]);
2232
+ sec("Description", [de.briefSummary ? para(de.briefSummary) : null, de.detailedDescription ? para(de.detailedDescription) : null]);
2233
+ sec("Conditions", [list(ps.conditionsModule?.conditions).length ? list(ps.conditionsModule.conditions).join("; ") : null, list(ps.conditionsModule?.keywords).length ? `Keywords: ${list(ps.conditionsModule.keywords).join("; ")}` : null]);
2234
+ const di = dm.designInfo ?? {};
2235
+ const mi = di.maskingInfo ?? {};
2236
+ sec("Design", [
2237
+ dm.studyType ? `Study type: ${words(dm.studyType)}` : null,
2238
+ list(dm.phases).length ? `Phase: ${list(dm.phases).map(words).join(", ")}` : null,
2239
+ di.allocation ? `Allocation: ${words(di.allocation)}` : null,
2240
+ di.interventionModel ? `Intervention model: ${words(di.interventionModel)}${di.interventionModelDescription ? ` \u2014 ${para(di.interventionModelDescription)}` : ""}` : null,
2241
+ di.primaryPurpose ? `Primary purpose: ${words(di.primaryPurpose)}` : null,
2242
+ mi.masking ? `Masking: ${words(mi.masking)}${list(mi.whoMasked).length ? ` (${list(mi.whoMasked).map((w) => words(w).toLowerCase()).join(", ")})` : ""}${mi.maskingDescription ? ` \u2014 ${para(mi.maskingDescription)}` : ""}` : null,
2243
+ dm.enrollmentInfo?.count !== void 0 ? `Enrollment: ${dm.enrollmentInfo.count}${dm.enrollmentInfo.type ? ` (${String(dm.enrollmentInfo.type).toLowerCase()})` : ""}` : null
2244
+ ]);
2245
+ const arms = list(ai.armGroups).map((a) => `- **${a.label}**${a.type ? ` (${words(a.type).toLowerCase()})` : ""}${a.description ? `: ${para(a.description)}` : ""}${list(a.interventionNames).length ? ` \u2014 ${list(a.interventionNames).join("; ")}` : ""}`);
2246
+ const ints = list(ai.interventions).map((i) => `- **${words(i.type)}: ${i.name}**${list(i.otherNames).length ? ` (${list(i.otherNames).join(", ")})` : ""}${i.description ? `: ${para(i.description)}` : ""}${list(i.armGroupLabels).length ? ` \u2014 arms: ${list(i.armGroupLabels).join("; ")}` : ""}`);
2247
+ sec("Arms and interventions", [arms.length ? "Arms:" : null, ...arms, ints.length ? "Interventions:" : null, ...ints]);
2248
+ const outcome = (o) => `- **${para(o.measure)}**${o.description ? `: ${para(o.description)}` : ""}${o.timeFrame ? ` Time frame: ${para(o.timeFrame)}.` : ""}`;
2249
+ sec("Outcomes", [
2250
+ list(om.primaryOutcomes).length ? "### Primary outcomes" : null,
2251
+ ...list(om.primaryOutcomes).map(outcome),
2252
+ list(om.secondaryOutcomes).length ? "### Secondary outcomes" : null,
2253
+ ...list(om.secondaryOutcomes).map(outcome),
2254
+ list(om.otherOutcomes).length ? "### Other outcomes" : null,
2255
+ ...list(om.otherOutcomes).map(outcome)
2256
+ ]);
2257
+ sec("Eligibility", [
2258
+ [el.sex ? `Sex: ${words(el.sex).toLowerCase()}` : "", el.minimumAge || el.maximumAge ? `Age: ${el.minimumAge ?? "no minimum"} to ${el.maximumAge ?? "no maximum"}` : "", el.healthyVolunteers !== void 0 ? `Healthy volunteers: ${el.healthyVolunteers ? "yes" : "no"}` : ""].filter(Boolean).join(" \xB7 "),
2259
+ el.eligibilityCriteria ? para(el.eligibilityCriteria) : null
2260
+ ]);
2261
+ const countries = [...new Set(list(cl.locations).map((l) => l.country).filter(Boolean))];
2262
+ sec("Locations", [countries.length ? `${list(cl.locations).length} sites in ${countries.join(", ")}` : null, list(cl.overallOfficials).length ? `Overall officials: ${list(cl.overallOfficials).map((o) => [o.name, o.role ? words(o.role).toLowerCase() : "", o.affiliation].filter(Boolean).join(", ")).join("; ")}` : null]);
2263
+ const refs = list(rf.references);
2264
+ sec("References", refs.map((r) => `- ${para(r.citation)}${r.pmid ? ` pmid:${r.pmid}` : ""}${r.type ? ` (${words(r.type).toLowerCase()})` : ""}`));
2265
+ const publications = refs.filter((r) => r.pmid && ["RESULT", "DERIVED"].includes(String(r.type))).map((r) => `pmid:${r.pmid}`);
2266
+ if (d.hasResults && d.resultsSection) out.push(...results(d.resultsSection));
2267
+ return {
2268
+ nctid,
2269
+ title: para(idm.briefTitle) || nctid,
2270
+ acronym: idm.acronym ?? null,
2271
+ sponsor: sp.leadSponsor?.name ?? null,
2272
+ firstPosted: st.studyFirstPostDateStruct?.date ?? null,
2273
+ lastUpdate: st.lastUpdatePostDateStruct?.date ?? null,
2274
+ status: st.overallStatus ? words(st.overallStatus) : null,
2275
+ hasResults: !!d.hasResults,
2276
+ publications,
2277
+ sections: out
2278
+ };
2279
+ }
2280
+ function results(rs) {
2281
+ const out = ["## Results"];
2282
+ const pf = rs.participantFlowModule;
2283
+ if (pf) {
2284
+ out.push("### Participant flow");
2285
+ const groups = list(pf.groups);
2286
+ out.push(...groups.map((g) => `- **${g.title}**${g.description ? `: ${para(g.description)}` : ""}`));
2287
+ if (pf.recruitmentDetails) out.push(para(pf.recruitmentDetails));
2288
+ for (const p of list(pf.periods)) {
2289
+ const head = ["Period: " + (p.title ?? ""), ...groups.map((g) => g.title)];
2290
+ const rows = list(p.milestones).map((m) => [words(m.type), ...groups.map((g) => list(m.achievements).find((a) => a.groupId === g.id)?.numSubjects ?? "")]);
2291
+ for (const dw of list(p.dropWithdraws)) rows.push([`Not completed: ${dw.type}`, ...groups.map((g) => list(dw.reasons).find((r) => r.groupId === g.id)?.numSubjects ?? "")]);
2292
+ out.push(table2(head, rows));
2293
+ }
2294
+ }
2295
+ const bc = rs.baselineCharacteristicsModule;
2296
+ if (bc) {
2297
+ out.push("### Baseline characteristics");
2298
+ const groups = list(bc.groups);
2299
+ out.push(...groups.map((g) => `- **${g.title}**${g.description ? `: ${para(g.description)}` : ""}`));
2300
+ for (const m of list(bc.measures)) {
2301
+ const unit = [m.paramType ? words(m.paramType).toLowerCase() : "", m.dispersionType ? words(m.dispersionType).toLowerCase() : "", m.unitOfMeasure].filter(Boolean).join(", ");
2302
+ const rows = [];
2303
+ for (const c of list(m.classes)) for (const cat of list(c.categories)) rows.push([[c.title, cat.title].filter(Boolean).join(" \u2014 ") || m.title, ...groups.map((g) => {
2304
+ const x = list(cat.measurements).find((v) => v.groupId === g.id);
2305
+ return x ? `${x.value ?? ""}${x.spread !== void 0 ? ` \xB1 ${x.spread}` : ""}${x.lowerLimit !== void 0 ? ` (${x.lowerLimit} to ${x.upperLimit})` : ""}` : "";
2306
+ })]);
2307
+ out.push(`**${m.title}**${unit ? ` (${unit})` : ""}`, table2(["", ...groups.map((g) => g.title)], rows));
2308
+ }
2309
+ }
2310
+ const om = rs.outcomeMeasuresModule;
2311
+ if (om) {
2312
+ out.push("### Outcome measures");
2313
+ for (const m of list(om.outcomeMeasures)) {
2314
+ const groups = list(m.groups);
2315
+ const unit = [m.paramType ? words(m.paramType).toLowerCase() : "", m.dispersionType ? String(m.dispersionType).toLowerCase() : "", m.unitOfMeasure].filter(Boolean).join(", ");
2316
+ out.push(`#### ${m.title}${m.type ? ` (${words(m.type).toLowerCase()})` : ""}`);
2317
+ if (m.description) out.push(para(m.description));
2318
+ out.push([m.timeFrame ? `Time frame: ${para(m.timeFrame)}.` : "", unit ? `Measure: ${unit}.` : "", m.populationDescription ? `Population: ${para(m.populationDescription)}` : ""].filter(Boolean).join(" "));
2319
+ if (m.reportingStatus && m.reportingStatus !== "POSTED") out.push(`Reporting status: ${words(m.reportingStatus)}.`);
2320
+ const rows = [];
2321
+ for (const c of list(m.classes)) for (const cat of list(c.categories)) rows.push([[c.title, cat.title].filter(Boolean).join(" \u2014 ") || m.title, ...groups.map((g) => {
2322
+ const x = list(cat.measurements).find((v) => v.groupId === g.id);
2323
+ return x ? `${x.value ?? ""}${x.spread !== void 0 ? ` \xB1 ${x.spread}` : ""}${x.lowerLimit !== void 0 ? ` (${x.lowerLimit} to ${x.upperLimit})` : ""}` : "";
2324
+ })]);
2325
+ const denoms = list(m.denoms).flatMap((dn) => list(dn.counts)).filter((c) => c.groupId);
2326
+ if (denoms.length) rows.unshift(["Participants analysed", ...groups.map((g) => denoms.find((c) => c.groupId === g.id)?.value ?? "")]);
2327
+ out.push(table2(["", ...groups.map((g) => g.title)], rows));
2328
+ for (const a of list(m.analyses)) out.push(`Analysis${a.groupDescription ? ` (${para(a.groupDescription)})` : ""}: ${[a.statisticalMethod, a.pValue !== void 0 ? `p ${a.pValueComment ?? ""}${a.pValue}`.trim() : "", a.paramType ? `${words(a.paramType).toLowerCase()} ${a.paramValue ?? ""}` : "", a.ciPctValue ? `${a.ciPctValue}% CI ${a.ciLowerLimit ?? ""} to ${a.ciUpperLimit ?? ""}` : ""].filter(Boolean).join("; ")}`);
2329
+ }
2330
+ }
2331
+ const ae = rs.adverseEventsModule;
2332
+ if (ae) {
2333
+ out.push("### Adverse events");
2334
+ const groups = list(ae.eventGroups);
2335
+ out.push([ae.timeFrame ? `Time frame: ${para(ae.timeFrame)}.` : "", ae.frequencyThreshold ? `Threshold for other events: ${ae.frequencyThreshold}%.` : "", ae.description ? para(ae.description) : ""].filter(Boolean).join(" "));
2336
+ const summary = [
2337
+ ["Serious events, participants affected / at risk", ...groups.map((g) => `${g.seriousNumAffected ?? ""} / ${g.seriousNumAtRisk ?? ""}`)],
2338
+ ["Other events, participants affected / at risk", ...groups.map((g) => `${g.otherNumAffected ?? ""} / ${g.otherNumAtRisk ?? ""}`)],
2339
+ ["Deaths, participants affected / at risk", ...groups.map((g) => g.deathsNumAffected !== void 0 ? `${g.deathsNumAffected} / ${g.deathsNumAtRisk ?? ""}` : "")]
2340
+ ].filter((r) => r.slice(1).some(Boolean));
2341
+ out.push(table2(["", ...groups.map((g) => g.title)], summary));
2342
+ const events = (title, evs) => {
2343
+ if (!evs.length) return;
2344
+ out.push(`**${title}** (participants affected / at risk)`, table2(["Event", "System", ...groups.map((g) => g.title)], evs.map((e) => [e.term ?? "", e.organSystem ?? "", ...groups.map((g) => {
2345
+ const s = list(e.stats).find((x) => x.groupId === g.id);
2346
+ return s ? `${s.numAffected ?? ""} / ${s.numAtRisk ?? ""}` : "";
2347
+ })])));
2348
+ };
2349
+ events("Serious adverse events", list(ae.seriousEvents));
2350
+ events("Other adverse events", list(ae.otherEvents));
2351
+ }
2352
+ return out;
2353
+ }
2354
+ function headLineStudy(s) {
2355
+ return [s.sponsor, s.nctid, s.status ? s.status.toLowerCase() : "", s.firstPosted ? `first posted ${s.firstPosted}` : "", s.lastUpdate ? `last update posted ${s.lastUpdate}` : "", CTGOV_PAGE(s.nctid)].filter(Boolean).join(" \xB7 ");
2356
+ }
2357
+ function renderStudyMd(s) {
2358
+ const parts = [`# ${s.title}${s.acronym ? ` (${s.acronym})` : ""} \u2014 ClinicalTrials.gov record`, "", headLineStudy(s), "", ...s.sections.flatMap((b) => [b, ""])];
2359
+ return parts.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
2360
+ }
2361
+
2362
+ // src/entry.ts
2363
+ function emptyStatus(requested, type, id, libraryDir) {
2364
+ return {
2365
+ requested,
2366
+ type,
2367
+ id,
2368
+ entry: null,
2369
+ entry_dir: null,
2370
+ library_dir: libraryDir,
2371
+ pmid: null,
2372
+ setid: null,
2373
+ nctid: null,
2374
+ publications: [],
2375
+ pmcid: null,
2376
+ doi: null,
2377
+ title: null,
2378
+ first_author: null,
2379
+ year: null,
2380
+ publication_types: [],
2381
+ retracted: false,
2382
+ corrections: [],
2383
+ coi: null,
2384
+ funding: [],
2385
+ citable: true,
2386
+ comment: null,
2387
+ has_abstract: null,
2388
+ level: null,
2389
+ version: null,
2390
+ retrieved_by: null,
2391
+ retrieved_date: null,
2392
+ pubmed_checked: null,
2393
+ attempts: [],
2394
+ files: [],
2395
+ glyphs_unresolved: null
2396
+ };
2397
+ }
2398
+ var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2399
+ function headLine(r) {
2400
+ return [
2401
+ r.authors.join(", ") || null,
2402
+ r.journal,
2403
+ r.year,
2404
+ r.pmid ? `PMID ${r.pmid}` : "not in PubMed",
2405
+ r.doi ? `DOI ${r.doi}` : null,
2406
+ r.pmcid ? `PMCID ${r.pmcid}` : null
2407
+ ].filter(Boolean).join(" \xB7 ");
2408
+ }
2409
+ function renderAbstractMd(r) {
2410
+ const body = r.abstract.length ? r.abstract.map((a) => a.label ? `**${a.label}.** ${a.text}` : a.text).join("\n\n") : "_No abstract available._";
2411
+ return `# ${r.title}
2412
+
2413
+ ${headLine(r)}
2414
+
2415
+ ## Abstract
2416
+
2417
+ ${body}
2418
+ `;
2419
+ }
2420
+ function readHeadLine(p) {
2421
+ try {
2422
+ const lines = readFileSync4(p.md, "utf8").split("\n").filter((l) => l.trim());
2423
+ return lines[1] ?? "";
2424
+ } catch {
2425
+ return "";
2426
+ }
2427
+ }
2428
+ function metaFromRecord(r, files, converter) {
2429
+ return {
2430
+ pmid: r.pmid,
2431
+ setid: null,
2432
+ nctid: null,
2433
+ publications: [],
2434
+ pmcid: r.pmcid,
2435
+ doi: r.doi,
2436
+ title: r.title,
2437
+ first_author: r.firstAuthor,
2438
+ year: r.year,
2439
+ publication_types: r.publicationTypes,
2440
+ retracted: isRetracted(r),
2441
+ corrections: r.corrections,
2442
+ coi: r.coi,
2443
+ funding: r.funding,
2444
+ has_abstract: r.abstract.length > 0,
2445
+ level: "abstract",
2446
+ version: null,
2447
+ files,
2448
+ retrieved_by: "pubmed",
2449
+ retrieved_date: today(),
2450
+ converted_by: converter,
2451
+ pubmed_checked: today(),
2452
+ attempts: []
2453
+ };
2454
+ }
2455
+ var isRetracted = (r) => r.publicationTypes.includes("Retracted Publication") || r.corrections.some(isRetractionNotice);
2456
+ var renderCorrection = (c) => `${c.type} ${c.pmid ?? "-"} ${JSON.stringify(c.source)}`;
2457
+ function parseCorrection(v) {
2458
+ const m = /^(\S+) (\S+) ("[\s\S]*")$/.exec(v.trim());
2459
+ if (!m) return null;
2460
+ let source;
2461
+ try {
2462
+ source = JSON.parse(m[3]);
2463
+ } catch {
2464
+ return null;
2465
+ }
2466
+ return { type: m[1], pmid: m[2] === "-" ? null : m[2], source };
2467
+ }
2468
+ function renderMeta(m) {
2469
+ const lines = [
2470
+ ["pmid", m.pmid],
2471
+ ...m.setid ? [["setid", m.setid]] : [],
2472
+ ...m.nctid ? [["nctid", m.nctid]] : [],
2473
+ ...m.nctid ? [["publications", m.publications.join(", ")]] : [],
2474
+ ["pmcid", m.pmcid ?? ""],
2475
+ ["doi", m.doi ?? ""],
2476
+ ["title", JSON.stringify(m.title)],
2477
+ ["first_author", m.first_author ?? ""],
2478
+ ["year", m.year ?? ""],
2479
+ ["publication_types", m.publication_types.join("; ")],
2480
+ ["retracted", m.retracted ? "yes" : "no"],
2481
+ ...m.corrections.map((c) => ["corrections", renderCorrection(c)]),
2482
+ ["coi", m.coi === null ? "" : JSON.stringify(m.coi)],
2483
+ ["funding", m.funding.length ? JSON.stringify(m.funding) : ""],
2484
+ // a JSON array: a statement may hold semicolons
2485
+ ["has_abstract", m.has_abstract ? "yes" : "no"],
2486
+ ["level", m.level],
2487
+ ["version", m.version ?? ""],
2488
+ ["files", m.files.join(", ")],
2489
+ ["retrieved_by", m.retrieved_by],
2490
+ ["retrieved_date", m.retrieved_date],
2491
+ ["converted_by", m.converted_by],
2492
+ ["pubmed_checked", m.pubmed_checked],
2493
+ ["attempts", m.attempts.join(", ")]
2494
+ ];
2495
+ return "---\n" + lines.map(([k, v]) => v === "" ? `${k}:` : `${k}: ${v}`).join("\n") + "\n---\n";
2496
+ }
2497
+ function parseFrontmatter(md) {
2498
+ const m = /^---\n([\s\S]*?)\n---/.exec(md);
2499
+ const fm = {}, all = {};
2500
+ for (const line of (m?.[1] ?? "").split("\n")) {
2501
+ const i = line.indexOf(":");
2502
+ if (i <= 0) continue;
2503
+ const k = line.slice(0, i).trim(), v = line.slice(i + 1).trim();
2504
+ fm[k] = v;
2505
+ (all[k] ??= []).push(v);
2506
+ }
2507
+ return { fm, all };
2508
+ }
2509
+ var list2 = (v) => v ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
2510
+ function splitAttempts(v) {
2511
+ if (!v) return [];
2512
+ const out = [];
2513
+ let cur = "", depth = 0;
2514
+ for (const ch of v) {
2515
+ if (ch === "(") depth++;
2516
+ else if (ch === ")") depth = Math.max(0, depth - 1);
2517
+ if (ch === "," && depth === 0) {
2518
+ out.push(cur);
2519
+ cur = "";
2520
+ } else cur += ch;
2521
+ }
2522
+ out.push(cur);
2523
+ const merged = [];
2524
+ for (const piece of out.map((s) => s.trim()).filter(Boolean)) {
2525
+ if (merged.length && !/^\S+ \(/.test(piece)) merged[merged.length - 1] += ", " + piece;
2526
+ else merged.push(piece);
2527
+ }
2528
+ return merged;
2529
+ }
2530
+ var opt = (v) => v ? v : null;
2531
+ var TYPE_CONTINUATION = /* @__PURE__ */ new Set(["N.I.H.", "Extramural", "Intramural", "Non-U.S. Gov't", "U.S. Gov't", "U.S. Government", "P.H.S.", "Non-P.H.S.", "Phase I", "Phase II", "Phase III", "Phase IV", "Veterinary", "NIH", "American Recovery and Reinvestment Act"]);
2532
+ function splitPublicationTypes(v) {
2533
+ if (!v) return [];
2534
+ if (v.includes(";")) return v.split(";").map((s) => s.trim()).filter(Boolean);
2535
+ const out = [];
2536
+ for (const piece of list2(v)) {
2537
+ if (out.length && TYPE_CONTINUATION.has(piece)) out[out.length - 1] += ", " + piece;
2538
+ else out.push(piece);
2539
+ }
2540
+ return out;
2541
+ }
2542
+ function abstractMissing(p) {
2543
+ try {
2544
+ return readFileSync4(p.md, "utf8").includes("_No abstract available._");
2545
+ } catch {
2546
+ return false;
2547
+ }
2548
+ }
2549
+ function correctionsFromXml(p) {
2550
+ try {
2551
+ return parsePubmedXml(readFileSync4(p.xml, "utf8"))?.corrections ?? [];
2552
+ } catch {
2553
+ return [];
2554
+ }
2555
+ }
2556
+ function declarationsFromXml(p) {
2557
+ try {
2558
+ const r = parsePubmedXml(readFileSync4(p.xml, "utf8"));
2559
+ return { coi: r?.coi ?? null, funding: r?.funding ?? [] };
2560
+ } catch {
2561
+ return { coi: null, funding: [] };
2562
+ }
2563
+ }
2564
+ function readMeta(p) {
2565
+ const { fm, all } = parseFrontmatter(readFileSync4(p.meta, "utf8"));
2566
+ let title = fm.title ?? "";
2567
+ if (title.startsWith('"')) {
2568
+ try {
2569
+ title = JSON.parse(title);
2570
+ } catch {
2571
+ }
2572
+ }
2573
+ const legacy = fm.pubmed_checked === void 0;
2574
+ const corrections = legacy ? correctionsFromXml(p) : (all.corrections ?? []).map(parseCorrection).filter((c) => c !== null);
2575
+ let coi = null, funding = [];
2576
+ if (fm.coi === void 0) ({ coi, funding } = declarationsFromXml(p));
2577
+ else {
2578
+ if (fm.coi.startsWith('"')) {
2579
+ try {
2580
+ coi = JSON.parse(fm.coi);
2581
+ } catch {
2582
+ coi = fm.coi;
2583
+ }
2584
+ } else coi = fm.coi || null;
2585
+ funding = !fm.funding ? [] : fm.funding.startsWith("[") ? JSON.parse(fm.funding) : fm.funding.split(";").map((s) => s.trim()).filter(Boolean);
2586
+ }
2587
+ return {
2588
+ pmid: fm.pmid ?? "",
2589
+ setid: opt(fm.setid),
2590
+ nctid: opt(fm.nctid),
2591
+ publications: list2(fm.publications),
2592
+ pmcid: opt(fm.pmcid),
2593
+ doi: opt(fm.doi),
2594
+ title,
2595
+ first_author: opt(fm.first_author),
2596
+ year: opt(fm.year),
2597
+ publication_types: splitPublicationTypes(fm.publication_types),
2598
+ retracted: fm.retracted === "yes" || corrections.some(isRetractionNotice),
2599
+ corrections,
2600
+ coi,
2601
+ funding,
2602
+ has_abstract: fm.has_abstract ? fm.has_abstract === "yes" : !abstractMissing(p),
2603
+ level: fm.level === "full_text" ? "full_text" : "abstract",
2604
+ version: opt(fm.version),
2605
+ files: list2(fm.files),
2606
+ retrieved_by: fm.retrieved_by ?? "",
2607
+ retrieved_date: fm.retrieved_date ?? "",
2608
+ converted_by: fm.converted_by ?? "",
2609
+ pubmed_checked: legacy ? fm.retrieved_date ?? "" : fm.pubmed_checked,
2610
+ attempts: splitAttempts(fm.attempts)
2611
+ };
2612
+ }
2613
+ function writeAtomic(file, data) {
2614
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
2615
+ writeFileSync2(tmp, data);
2616
+ renameSync2(tmp, file);
2617
+ }
2618
+ function writeMeta(p, m) {
2619
+ writeAtomic(p.meta, renderMeta(m));
2620
+ }
2621
+ function copyEntry(from, to) {
2622
+ const tmp = `${to.entryDir}.${process.pid}.tmp`, old = `${to.entryDir}.${process.pid}.old`;
2623
+ rmSync(tmp, { recursive: true, force: true });
2624
+ mkdirSync3(tmp, { recursive: true });
2625
+ const rename = (n) => n.startsWith(from.entry) ? to.entry + n.slice(from.entry.length) : n;
2626
+ for (const n of readdirSync2(from.entryDir)) if (join6(from.entryDir, n) !== from.meta && !n.endsWith(".tmp")) copyFileSync(join6(from.entryDir, n), join6(tmp, rename(n)));
2627
+ const m = readMeta(from);
2628
+ const copy = { ...m, files: m.files.map(rename) };
2629
+ writeFileSync2(join6(tmp, basename(to.meta)), renderMeta(copy));
2630
+ mkdirSync3(dirname(to.entryDir), { recursive: true });
2631
+ if (existsSync5(to.entryDir)) renameSync2(to.entryDir, old);
2632
+ renameSync2(tmp, to.entryDir);
2633
+ rmSync(old, { recursive: true, force: true });
2634
+ return copy;
2635
+ }
2636
+ var NOTHING_TO_VERIFY = "no abstract and no full text: nothing to verify";
2637
+ function citability(m) {
2638
+ const of = (t) => m.corrections.filter((c) => c.type === t);
2639
+ const cite = (c) => `${c.source}${c.pmid ? ` (pmid:${c.pmid})` : ""}`;
2640
+ if (m.retracted) {
2641
+ const r = m.corrections.filter(isRetractionNotice);
2642
+ return { citable: false, comment: r.length ? `retracted: ${r.map(cite).join("; ")}` : "retracted (PubMed publication type)" };
2643
+ }
2644
+ if (m.level !== "full_text" && !m.has_abstract) return { citable: false, comment: NOTHING_TO_VERIFY };
2645
+ const notes = [
2646
+ ...of("erratum_in").map((c) => `erratum, not assessed: ${cite(c)}`),
2647
+ ...of("expression_of_concern_in").map((c) => `expression of concern: ${cite(c)}`),
2648
+ ...of("update_in").map((c) => `superseded by a newer version: ${cite(c)}`)
2649
+ ];
2650
+ return { citable: true, comment: notes.length ? notes.join("; ") : null };
2651
+ }
2652
+ function statusFromMeta(p, m, base) {
2653
+ const full = existsSync5(p.fulltextMd) ? readFileSync4(p.fulltextMd, "utf8") : null;
2654
+ const cit = citability(m);
2655
+ if (full?.includes(`
2656
+ ${CORRECTED_HEADING}
2657
+ `) && cit.comment) cit.comment = cit.comment.replace(/erratum, not assessed: /g, "erratum, corrected figures stored (Corrected in PubMed abstract): ");
2658
+ return {
2659
+ ...base,
2660
+ entry: p.entry,
2661
+ entry_dir: p.entryDir,
2662
+ pmid: m.pmid || null,
2663
+ setid: m.setid,
2664
+ nctid: m.nctid,
2665
+ publications: m.publications,
2666
+ pmcid: m.pmcid,
2667
+ doi: m.doi,
2668
+ title: m.title || null,
2669
+ first_author: m.first_author,
2670
+ year: m.year,
2671
+ publication_types: m.publication_types,
2672
+ retracted: m.retracted,
2673
+ corrections: m.corrections,
2674
+ coi: m.coi,
2675
+ funding: m.funding,
2676
+ ...cit,
2677
+ // What the entry holds: full_text, abstract, or only the title (the meta's
2678
+ // level is "abstract" until a full text arrives; has_abstract says whether
2679
+ // there is abstract text).
2680
+ has_abstract: m.has_abstract,
2681
+ level: m.level === "abstract" && !m.has_abstract ? "title" : m.level,
2682
+ version: m.version,
2683
+ retrieved_by: m.retrieved_by || null,
2684
+ retrieved_date: m.retrieved_date || null,
2685
+ pubmed_checked: m.pubmed_checked || null,
2686
+ attempts: m.attempts,
2687
+ files: m.files,
2688
+ glyphs_unresolved: full === null ? null : countUnresolved(full)
2689
+ };
2690
+ }
2691
+ function readStatus(p, base) {
2692
+ return statusFromMeta(p, readMeta(p), base);
2693
+ }
2694
+ function entryExists(p) {
2695
+ return existsSync5(p.meta);
2696
+ }
2697
+ function writeEntry(p, r, xml, converter) {
2698
+ mkdirSync3(p.entryDir, { recursive: true });
2699
+ writeAtomic(p.xml, xml);
2700
+ writeAtomic(p.md, renderAbstractMd(r));
2701
+ const meta = metaFromRecord(r, [basename(p.xml), basename(p.md)], converter);
2702
+ writeMeta(p, meta);
2703
+ return meta;
2704
+ }
2705
+ function refreshRecord(p, m, r, xml) {
2706
+ writeAtomic(p.xml, xml);
2707
+ syncCorrectedBlock(p);
2708
+ const next = {
2709
+ ...m,
2710
+ pmcid: r.pmcid ?? m.pmcid,
2711
+ doi: r.doi ?? m.doi,
2712
+ publication_types: r.publicationTypes,
2713
+ retracted: isRetracted(r),
2714
+ corrections: r.corrections,
2715
+ coi: r.coi ?? m.coi,
2716
+ funding: r.funding.length ? r.funding : m.funding,
2717
+ // the paper's own statements stay when PubMed has none
2718
+ pubmed_checked: today()
2719
+ };
2720
+ writeMeta(p, next);
2721
+ return next;
2722
+ }
2723
+ function daysSince(date, now = /* @__PURE__ */ new Date()) {
2724
+ const t = new Date(date).getTime();
2725
+ return Number.isFinite(t) ? (now.getTime() - t) / 864e5 : Infinity;
2726
+ }
2727
+ var FUNDING_RE = /^[*_\s]*(funding|financial support|sources? of (financial )?support|support(ed by)?|grant support|this (work|study|research) was (supported|funded))\b[^:]*:?[*_\s]*(.*)$/i;
2728
+ var COI_RE = /^[*_\s]*(conflicts? of interest|competing interests?|disclosures?|financial disclosures?|declarations? of (competing |conflicting )?interests?|conflict[- ]of[- ]interest statement)\b[^:]*:?[*_\s]*(.*)$/i;
2729
+ function declarationsFromMd(md) {
2730
+ let coi = null;
2731
+ const funding = [];
2732
+ const lines = md.split("\n");
2733
+ let section2 = "";
2734
+ for (let i = 0; i < lines.length; i++) {
2735
+ const l = lines[i].trim();
2736
+ const h = /^#{2,6}\s+(.*)$/.exec(l);
2737
+ if (h) {
2738
+ section2 = h[1];
2739
+ const body = lines.slice(i + 1).find((x) => x.trim() && !/^#/.test(x))?.trim() ?? "";
2740
+ if (body && /^(funding|financial support|sources? of funding|role of the funding source)/i.test(section2) && !funding.length) funding.push(body);
2741
+ if (body && /^(conflicts? of interest|competing interests?|disclosures?|financial disclosures?|declarations? of (competing |conflicting )?interests?)/i.test(section2) && coi === null) coi = body;
2742
+ continue;
2743
+ }
2744
+ if (section2 !== "Declarations" || !l) continue;
2745
+ const f = FUNDING_RE.exec(l);
2746
+ const c = COI_RE.exec(l);
2747
+ if (c && c[3]?.trim() && coi === null) coi = c[3].trim().replace(/[*_]+$/, "");
2748
+ else if (f && f[6]?.trim() && !funding.length) funding.push(f[6].trim().replace(/[*_]+$/, ""));
2749
+ }
2750
+ return { coi, funding };
2751
+ }
2752
+ var CORRECTED_HEADING = "## Corrected in PubMed abstract";
2753
+ function correctedSentences(r) {
2754
+ const out = [];
2755
+ for (const a of r.abstract) {
2756
+ for (const s of a.text.split(/(?<=[.!?\]])\s+(?=[A-Z(\u2212-])/)) {
2757
+ const i = s.indexOf("[corrected]");
2758
+ if (i >= 0) out.push(s.slice(0, i).trim().replace(/\s+([.,;])$/, "$1"));
2759
+ }
2760
+ }
2761
+ return out.filter(Boolean);
2762
+ }
2763
+ function withCorrectedBlock(md, sentences) {
2764
+ const base = md.split(`
2765
+ ${CORRECTED_HEADING}
2766
+ `)[0].trimEnd();
2767
+ if (!sentences.length) return base + "\n";
2768
+ return `${base}
2769
+
2770
+ ${CORRECTED_HEADING}
2771
+
2772
+ NLM corrected these sentences of the abstract after an erratum; the text above may predate the correction.
2773
+
2774
+ ${sentences.join("\n\n")}
2775
+ `;
2776
+ }
2777
+ function syncCorrectedBlock(p) {
2778
+ if (!existsSync5(p.fulltextMd) || !existsSync5(p.xml)) return;
2779
+ let r;
2780
+ try {
2781
+ r = parsePubmedXml(readFileSync4(p.xml, "utf8"));
2782
+ } catch {
2783
+ return;
2784
+ }
2785
+ if (!r) return;
2786
+ const md = readFileSync4(p.fulltextMd, "utf8");
2787
+ const next = withCorrectedBlock(md, correctedSentences(r));
2788
+ if (next !== md) writeAtomic(p.fulltextMd, next);
2789
+ }
2790
+ function applyFulltext(p, m, hit, converter) {
2791
+ const raw = p.fulltextRaw(hit.format);
2792
+ writeAtomic(raw, hit.raw);
2793
+ writeAtomic(p.fulltextMd, hit.md);
2794
+ syncCorrectedBlock(p);
2795
+ const files = [...m.files.filter((f) => !f.includes("-fulltext.")), basename(raw), basename(p.fulltextMd)];
2796
+ const own = declarationsFromMd(hit.md);
2797
+ const next = {
2798
+ ...m,
2799
+ level: "full_text",
2800
+ version: hit.version,
2801
+ files,
2802
+ retrieved_by: hit.tier,
2803
+ retrieved_date: today(),
2804
+ converted_by: converter,
2805
+ coi: m.coi ?? own.coi,
2806
+ funding: m.funding.length ? m.funding : own.funding
2807
+ };
2808
+ writeMeta(p, next);
2809
+ return next;
2810
+ }
2811
+ function recordAttempts(p, m, labels) {
2812
+ if (!labels.length) return m;
2813
+ const tierOf = (a) => /^(\S+) \(/.exec(a.trim())?.[1] ?? a;
2814
+ const replaced = new Set(labels.map(tierOf));
2815
+ const next = { ...m, attempts: [...m.attempts.filter((a) => !replaced.has(tierOf(a))), ...labels] };
2816
+ writeMeta(p, next);
2817
+ return next;
2818
+ }
2819
+ function writeLabelEntry(p, s, xml, converter) {
2820
+ mkdirSync3(p.entryDir, { recursive: true });
2821
+ const raw = p.fulltextRaw("xml");
2822
+ writeAtomic(raw, xml);
2823
+ writeAtomic(p.fulltextMd, renderLabelMd(s));
2824
+ const meta = {
2825
+ pmid: "",
2826
+ setid: s.setid,
2827
+ nctid: null,
2828
+ publications: [],
2829
+ pmcid: null,
2830
+ doi: null,
2831
+ title: s.title,
2832
+ first_author: s.labeler,
2833
+ year: s.effective?.slice(0, 4) ?? null,
2834
+ publication_types: ["Drug Label"],
2835
+ retracted: false,
2836
+ corrections: [],
2837
+ coi: null,
2838
+ funding: [],
2839
+ has_abstract: false,
2840
+ level: "full_text",
2841
+ version: s.version ? `spl ${s.version}` : null,
2842
+ files: [basename(raw), basename(p.fulltextMd)],
2843
+ retrieved_by: "dailymed",
2844
+ retrieved_date: today(),
2845
+ converted_by: converter,
2846
+ pubmed_checked: today(),
2847
+ attempts: []
2848
+ };
2849
+ writeMeta(p, meta);
2850
+ return meta;
2851
+ }
2852
+ function writeStudyEntry(p, s, json2, converter) {
2853
+ mkdirSync3(p.entryDir, { recursive: true });
2854
+ const raw = p.fulltextRaw("json");
2855
+ writeAtomic(raw, json2);
2856
+ writeAtomic(p.fulltextMd, renderStudyMd(s));
2857
+ const meta = {
2858
+ pmid: "",
2859
+ setid: null,
2860
+ nctid: s.nctid,
2861
+ publications: s.publications,
2862
+ pmcid: null,
2863
+ doi: null,
2864
+ title: s.title,
2865
+ first_author: s.sponsor,
2866
+ year: s.firstPosted?.slice(0, 4) ?? null,
2867
+ publication_types: ["Trial Registration"],
2868
+ retracted: false,
2869
+ corrections: [],
2870
+ coi: null,
2871
+ funding: [],
2872
+ has_abstract: false,
2873
+ level: "full_text",
2874
+ version: s.lastUpdate,
2875
+ files: [basename(raw), basename(p.fulltextMd)],
2876
+ retrieved_by: "ctgov",
2877
+ retrieved_date: today(),
2878
+ converted_by: converter,
2879
+ pubmed_checked: today(),
2880
+ attempts: []
2881
+ };
2882
+ writeMeta(p, meta);
2883
+ return meta;
2884
+ }
2885
+ function writeDoiEntry(p, r, json2, converter, abstractFrom = "crossref", recordFrom = "crossref") {
2886
+ mkdirSync3(p.entryDir, { recursive: true });
2887
+ const raw = p.fulltextRaw("json").replace(/-fulltext\.json$/, recordFrom === "crossref" ? "-crossref.json" : "-csl.json");
2888
+ writeAtomic(raw, json2);
2889
+ writeAtomic(p.md, renderAbstractMd(r));
2890
+ const meta = { ...metaFromRecord(r, [basename(raw), basename(p.md)], converter), retrieved_by: r.abstract.length ? abstractFrom : recordFrom };
2891
+ writeMeta(p, meta);
2892
+ return meta;
2893
+ }
2894
+
2895
+ // src/cascade.ts
2896
+ var PERMANENT = /^(no pmcid|no doi|no pmid|no pmid or doi|no body|closed|no oa copy|no free copy|not in core|no full text|xml without body|template has no placeholder|title missing|title not in text|no methods\/results|no article body|scanned pdf|not a pdf|only copies already tried|only pmc copies|nothing located|unknown content|no text-mining links|needs [a-z/]+ token)/i;
2897
+ var BLOCKED = /^(challenge not cleared|empty response)/i;
2898
+ var RUNG = /^(browser [^:]+|brightdata|unlocker): /;
2899
+ function isPermanent(reason, retryBlocked = false) {
2900
+ return reason.split(";").every((r) => {
2901
+ const b = r.trim().replace(RUNG, "");
2902
+ return PERMANENT.test(b) || !retryBlocked && BLOCKED.test(b);
2903
+ });
2904
+ }
2905
+ function parseAttempt(a) {
2906
+ const m = /^(\S+) \((.*)\)(?: (\d{4}-\d{2}-\d{2}))?$/.exec(a.trim());
2907
+ return m ? { tier: m[1], reason: m[2], date: m[3] ?? null } : null;
2908
+ }
2909
+ var attemptLabel = (m, date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) => `${m.tier} (${m.reason}) ${date}`;
2910
+ function shouldSkip(attempts, tier, retryAfterDays, now = /* @__PURE__ */ new Date(), retryBlocked = false) {
2911
+ for (const a of attempts) {
2912
+ const p = parseAttempt(a);
2913
+ if (!p || p.tier !== tier) continue;
2914
+ if (isPermanent(p.reason, retryBlocked)) return true;
2915
+ if (!p.date) return false;
2916
+ const age = (now.getTime() - new Date(p.date).getTime()) / 864e5;
2917
+ return age < retryAfterDays;
2918
+ }
2919
+ return false;
2920
+ }
2921
+ async function fulltextCascade(p, attempts, onEvent, tiers = resolveTiers().tiers, retryAfterDays = settings().retry_after_days, retryBlocked = settings().retry_blocked) {
2922
+ const misses = [];
2923
+ const tried = /* @__PURE__ */ new Set();
2924
+ for (const tier of tiers) {
2925
+ if (!tier.always && shouldSkip(attempts, tier.name, retryAfterDays, /* @__PURE__ */ new Date(), retryBlocked)) continue;
2926
+ const applies = tier.applies(p);
2927
+ const ctx = { tried, onTry: (message) => onEvent?.({ ok: "try", tier: tier.name, message }), onAlert: (message) => onEvent?.({ ok: "alert", tier: tier.name, message }) };
2928
+ const r = applies === true ? await tier.run(p, ctx) : { ...miss(tier.name, applies), skipped: true };
2929
+ onEvent?.(r);
2930
+ if (r.ok) return { hit: r, misses };
2931
+ misses.push(r);
2932
+ }
2933
+ return { hit: null, misses };
2934
+ }
2935
+
2936
+ // src/ids.ts
2937
+ var SUPPORTED = /* @__PURE__ */ new Set(["pmid", "dailymed", "nct", "doi"]);
2938
+ var KNOWN = /* @__PURE__ */ new Set(["pmid", "pmc", "doi", "nct", "dailymed"]);
2939
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
2940
+ function parseId(raw) {
2941
+ const requested = String(raw ?? "").trim();
2942
+ const bare2 = /^\d+$/.test(requested);
2943
+ const bareNct = /^NCT\d{8}$/i.test(requested);
2944
+ const m = bare2 || bareNct ? null : /^([A-Za-z][A-Za-z0-9_-]*):(.+)$/.exec(requested);
2945
+ if (!bare2 && !bareNct && !m) return { requested, type: null, id: null, error: "invalid id" };
2946
+ const type = bare2 ? "pmid" : bareNct ? "nct" : m[1].toLowerCase();
2947
+ const value = bare2 || bareNct ? requested : m[2].trim();
2948
+ if (!KNOWN.has(type)) return { requested, type, id: value, error: "invalid id" };
2949
+ if (!SUPPORTED.has(type)) return { requested, type, id: value, error: "type not supported yet" };
2950
+ if (type === "doi") {
2951
+ const v = value.replace(/^https?:\/\/(dx\.)?doi\.org\//i, "").replace(/^doi:\s*/i, "").trim().toLowerCase();
2952
+ return /^10\.\d{4,9}\/\S+$/.test(v) ? { requested, type, id: v } : { requested, type, id: value, error: "invalid id" };
2953
+ }
2954
+ if (type === "nct") {
2955
+ const v = value.toUpperCase().replace(/^NCT/, "");
2956
+ return /^\d{8}$/.test(v) ? { requested, type, id: v } : { requested, type, id: value, error: "invalid id" };
2957
+ }
2958
+ if (type === "dailymed") {
2959
+ const v = value.toLowerCase();
2960
+ return UUID.test(v) ? { requested, type, id: v } : { requested, type, id: value, error: "invalid id" };
2961
+ }
2962
+ if (!/^[1-9]\d*$/.test(value)) return { requested, type, id: value, error: "invalid id" };
2963
+ return { requested, type, id: value };
2964
+ }
2965
+
2966
+ // src/info.ts
2967
+ import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
2968
+ import { dirname as dirname2, join as join7 } from "path";
2969
+
2970
+ // src/verify.ts
2971
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
2972
+ var DEFAULT_MODEL = "pplx-decider-flash-v1.2";
2973
+ var QUESTION = "Does the paper state this, in substance? Ignore section labels and definitions of terms in parentheses. Every fact and number of the statement must be stated in the paper or follow from its numbers; a rounded number counts. If any part of the statement is absent from the paper or contradicted by it, answer false.";
2974
+ function verifyConfig(env = process.env) {
2975
+ const base_url = str("FH_LIBRARY_VERIFY_URL", env).replace(/\/+$/, "");
2976
+ if (!base_url) return null;
2977
+ return {
2978
+ base_url,
2979
+ api_key: str("FH_LIBRARY_VERIFY_API_KEY", env) || null,
2980
+ model: str("FH_LIBRARY_VERIFY_MODEL", env) || DEFAULT_MODEL,
2981
+ threshold: num("FH_LIBRARY_VERIFY_THRESHOLD", 0.5, env, (n) => n > 0 && n < 1),
2982
+ // Hosted takes 256k tokens in one request (a 50k-token paper in 2 s); the
2983
+ // Mac app is limited to 32k and slows steeply beyond 25k, so it windows.
2984
+ window_chars: isHosted(base_url) ? 8e5 : 9e4,
2985
+ timeout_seconds: num("FH_LIBRARY_VERIFY_TIMEOUT_SECS", 60, env, (n) => n > 0)
2986
+ };
2987
+ }
2988
+ var isHosted = (baseUrl) => {
2989
+ try {
2990
+ return new URL(baseUrl).hostname === "api.perplexity.ai";
2991
+ } catch {
2992
+ return false;
2993
+ }
2994
+ };
2995
+ function isLocal(baseUrl) {
2996
+ try {
2997
+ const h = new URL(baseUrl).hostname;
2998
+ return h === "localhost" || /^127\./.test(h) || h === "::1" || /^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h) || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h);
2999
+ } catch {
3000
+ return false;
3001
+ }
3002
+ }
3003
+ function cleanMarkdown(md) {
3004
+ return md.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\]\([^)]*\)/g, "]").replace(/^\s*#+\s*(see more about|more for you|back to top)\b.*$/gim, "").replace(/\n{3,}/g, "\n\n");
3005
+ }
3006
+ function windows(md, max) {
3007
+ const out = [];
3008
+ let cur = "";
3009
+ for (const s of cleanMarkdown(md).split(/\n(?=#)/)) {
3010
+ if (cur && cur.length + s.length + 1 > max) {
3011
+ out.push(cur);
3012
+ cur = "";
3013
+ }
3014
+ cur += (cur ? "\n" : "") + s.slice(0, max);
3015
+ }
3016
+ if (cur) out.push(cur);
3017
+ return out.length ? out : [""];
3018
+ }
3019
+ function citationGroups(claim) {
3020
+ const one = /[(\[][^()\[\]]*?\b(1[89]\d\d|20\d\d)[a-z]?\b[^()\[\]]*[)\]]/g;
3021
+ const groups = [];
3022
+ let last = -1;
3023
+ for (const m of claim.matchAll(one)) {
3024
+ const between = claim.slice(last, m.index);
3025
+ if (last >= 0 && /^[\s;,]*(and|&)?[\s;,]*$/i.test(between)) {
3026
+ groups[groups.length - 1] += between + m[0];
3027
+ } else groups.push(m[0]);
3028
+ last = m.index + m[0].length;
3029
+ }
3030
+ return groups;
3031
+ }
3032
+ function fragment(claim) {
3033
+ const body = claim.trim().replace(/^[-*+]\s+/, "").replace(/^\*\*[^*]+:\*\*\s*/, "").replace(/^[("'\u201c]+/, "");
3034
+ const first = /^(\S+)/.exec(body)?.[1] ?? "";
3035
+ if (/^(and|or|but|nor|which|whereas|while)$/i.test(first.replace(/[,;:]$/, ""))) return `claim is a fragment: starts with "${first}" \u2014 send a complete statement with its subject`;
3036
+ if (/^[a-z]/.test(body)) return `claim is a fragment: starts with "${first}" in lowercase \u2014 send a complete statement with its subject`;
3037
+ return null;
3038
+ }
3039
+ var SUPERSEDED = "The corrected text replaces figures that the paper first published wrongly. Does the statement give a figure for a quantity the corrected text also gives, but with a different value \u2014 the superseded, uncorrected figure? If the statement's figures match the corrected text, or it concerns something the corrected text does not cover, answer false.";
3040
+ function correctedText(md) {
3041
+ const i = md.indexOf(`
3042
+ ${CORRECTED_HEADING}
3043
+ `);
3044
+ if (i < 0) return null;
3045
+ return md.slice(i + CORRECTED_HEADING.length + 2).split("\n").filter((l) => l.trim() && !/^NLM corrected these sentences/.test(l)).join("\n").trim() || null;
3046
+ }
3047
+ function decider(cfg, fetchFn = fetch) {
3048
+ return async (state, questions) => {
3049
+ const headers = { "content-type": "application/json" };
3050
+ if (cfg.api_key) headers.authorization = `Bearer ${cfg.api_key}`;
3051
+ const body = JSON.stringify({ model: cfg.model, state, questions });
3052
+ let r;
3053
+ try {
3054
+ r = await fetchFn(`${cfg.base_url}/v1/decisions`, { method: "POST", headers, body, signal: AbortSignal.timeout(cfg.timeout_seconds * 1e3) });
3055
+ } catch (e) {
3056
+ const name = e instanceof Error ? e.name : "";
3057
+ const cause = e.cause;
3058
+ if (name === "TimeoutError" || name === "AbortError" || cause?.code === "UND_ERR_HEADERS_TIMEOUT") throw new Error(`${cfg.model}: no answer within ${cfg.timeout_seconds}s (${body.length} chars)`);
3059
+ throw new Error(`${cfg.model}: ${cause?.message ?? (e instanceof Error ? e.message : String(e))}`);
3060
+ }
3061
+ const j = await r.json().catch(() => ({}));
3062
+ if (j.answers) return j.answers;
3063
+ const msg = typeof j.error === "string" ? j.error : j.error?.message;
3064
+ throw new Error(msg ? `${cfg.model}: ${msg}` : `${cfg.model}: HTTP ${r.status} without an answer`);
3065
+ };
3066
+ }
3067
+ async function readPaper(raw, libraryDir = FH_LIBRARY_DIR) {
3068
+ const p = parseId(raw);
3069
+ if (p.error) return { id: p.requested, error: p.error };
3070
+ const paths = entryPaths(libraryDir, p.type, p.id);
3071
+ if (!entryExists(paths)) return { id: p.requested, error: "not in library" };
3072
+ const full = existsSync6(paths.fulltextMd);
3073
+ if (full) return { id: p.requested, basis: "full_text", text: readFileSync5(paths.fulltextMd, "utf8") };
3074
+ const meta = readMeta(paths);
3075
+ return { id: p.requested, basis: meta.has_abstract ? "abstract" : "title", text: readFileSync5(paths.md, "utf8") };
3076
+ }
3077
+ async function verifyClaim(claim, id, cfg, decide, read = readPaper) {
3078
+ const out = { claim, id, verdict: "error", reason: null, score: null, basis: null, windows: 0, glyphs_unresolved: 0, superseded: null, model: cfg?.model ?? null };
3079
+ const words2 = claim.trim().split(/\s+/).filter(Boolean).length;
3080
+ if (!words2) return { ...out, error: "claim empty" };
3081
+ if (words2 < 4) return { ...out, error: "claim too short to be a finding" };
3082
+ const frag = fragment(claim);
3083
+ if (frag) return { ...out, error: frag };
3084
+ const groups = citationGroups(claim);
3085
+ if (groups.length) return { ...out, error: groups.length > 1 ? `compound claim: ${groups.length} citation groups ${groups.join(" ")} \u2014 one finding per call, without its sources` : `claim names a source ${groups[0]} \u2014 send the finding without its sources; the paper is the id` };
3086
+ const paper = await read(id);
3087
+ out.id = paper.id;
3088
+ if ("error" in paper) return { ...out, error: paper.error };
3089
+ if (!cfg || !decide) return { ...out, error: "no verify model configured (FH_LIBRARY_VERIFY_URL)" };
3090
+ out.basis = paper.basis;
3091
+ out.glyphs_unresolved = countUnresolved(paper.text);
3092
+ if (paper.basis === "title") return { ...out, verdict: "fail", reason: NOTHING_TO_VERIFY };
3093
+ const ws = windows(paper.text, cfg.window_chars);
3094
+ out.windows = ws.length;
3095
+ try {
3096
+ let score = 0;
3097
+ for (const w of ws) {
3098
+ const a = await decide({ paper: w, statement: claim }, { backed: { type: "noul", instructions: QUESTION } });
3099
+ score = Math.max(score, a.backed?.noul ?? 0);
3100
+ }
3101
+ out.score = score;
3102
+ const corrected = correctedText(paper.text);
3103
+ if (corrected) {
3104
+ const a = await decide({ corrected, statement: claim }, { superseded: { type: "noul", instructions: SUPERSEDED } });
3105
+ out.superseded = (a.superseded?.noul ?? 0) >= 0.5;
3106
+ }
3107
+ } catch (e) {
3108
+ return { ...out, error: e instanceof Error ? e.message : String(e) };
3109
+ }
3110
+ if (out.score >= cfg.threshold) return { ...out, verdict: "pass" };
3111
+ return paper.basis === "full_text" ? { ...out, verdict: "fail" } : { ...out, verdict: "unverifiable", reason: "only the abstract is stored and it does not state the claim; the full text may" };
3112
+ }
3113
+
3114
+ // src/info.ts
3115
+ function* metaFiles(dir) {
3116
+ if (!existsSync7(dir)) return;
3117
+ for (const name of readdirSync3(dir)) {
3118
+ const p = join7(dir, name);
3119
+ if (statSync2(p).isDirectory()) yield* metaFiles(p);
3120
+ else if (name.endsWith("-meta.md")) yield p;
3121
+ }
3122
+ }
3123
+ var human = (n) => {
3124
+ const u = ["B", "KiB", "MiB", "GiB", "TiB"];
3125
+ let i = 0;
3126
+ while (n >= 1024 && i < u.length - 1) {
3127
+ n /= 1024;
3128
+ i++;
3129
+ }
3130
+ return i ? `${n.toFixed(1)} ${u[i]}` : `${n} B`;
3131
+ };
3132
+ function field(md, key) {
3133
+ const m = new RegExp(`^${key}:\\s*(.*)$`, "m").exec(md);
3134
+ return m ? m[1].trim() : "";
3135
+ }
3136
+ function libraryInfo(libraryDir, host, withIds, tiers = resolveTiers()) {
3137
+ const info = {
3138
+ library_dir: libraryDir,
3139
+ host,
3140
+ tiers: tiers.all,
3141
+ config: { ...settings(), problems: tiers.problems, sources: sources() },
3142
+ download: downloadInfo(),
3143
+ verify: verifyInfo(),
3144
+ entries: 0,
3145
+ size_bytes: 0,
3146
+ size: "",
3147
+ by_type: {},
3148
+ by_level: {},
3149
+ types: {},
3150
+ retracted: 0,
3151
+ not_citable: 0,
3152
+ errata: 0,
3153
+ concerns: 0,
3154
+ updates: 0
3155
+ };
3156
+ const ids = [];
3157
+ for (const type of existsSync7(libraryDir) ? readdirSync3(libraryDir) : []) {
3158
+ const typeDir = join7(libraryDir, type);
3159
+ if (!statSync2(typeDir).isDirectory()) continue;
3160
+ for (const meta of metaFiles(typeDir)) {
3161
+ const md = readFileSync6(meta, "utf8");
3162
+ const raw = field(md, "level") || "unknown";
3163
+ const level = raw === "abstract" && field(md, "has_abstract") === "no" ? "title" : raw;
3164
+ info.entries++;
3165
+ info.by_type[type] = (info.by_type[type] ?? 0) + 1;
3166
+ info.by_level[level] = (info.by_level[level] ?? 0) + 1;
3167
+ const t = info.types[type] ??= { entries: 0, full_text: 0, abstract: 0, title: 0, retracted: 0 };
3168
+ t.entries++;
3169
+ if (level === "full_text" || level === "abstract" || level === "title") t[level]++;
3170
+ const dir = dirname2(meta);
3171
+ for (const f of readdirSync3(dir)) {
3172
+ try {
3173
+ const st = statSync2(join7(dir, f));
3174
+ if (st.isFile()) info.size_bytes += st.size;
3175
+ } catch {
3176
+ }
3177
+ }
3178
+ const types = noticeTypes(md, meta);
3179
+ if (field(md, "retracted") === "yes" || types.has("retraction_in") || types.has("partial_retraction_in") || types.has("retracted_and_republished_in")) {
3180
+ info.retracted++;
3181
+ info.not_citable++;
3182
+ t.retracted++;
3183
+ } else if (level === "title") info.not_citable++;
3184
+ if (types.has("erratum_in")) info.errata++;
3185
+ if (types.has("expression_of_concern_in")) info.concerns++;
3186
+ if (types.has("update_in")) info.updates++;
3187
+ if (withIds) ids.push(`${type}:${field(md, type) || meta.split("/").slice(-2, -1)[0]}`);
3188
+ }
3189
+ }
3190
+ if (withIds) info.ids = ids.sort();
3191
+ info.size = human(info.size_bytes);
3192
+ return info;
3193
+ }
3194
+ var XML_TYPES = { ErratumIn: "erratum_in", RetractionIn: "retraction_in", PartialRetractionIn: "partial_retraction_in", RetractedandRepublishedIn: "retracted_and_republished_in", ExpressionOfConcernIn: "expression_of_concern_in", UpdateIn: "update_in" };
3195
+ function noticeTypes(md, metaPath) {
3196
+ const out = /* @__PURE__ */ new Set();
3197
+ if (/^pubmed_checked:/m.test(md)) {
3198
+ for (const m of md.matchAll(/^corrections:\s*(\S+)/gm)) out.add(m[1]);
3199
+ return out;
3200
+ }
3201
+ try {
3202
+ const xml = readFileSync6(metaPath.replace(/-meta\.md$/, "-abstract.xml"), "utf8");
3203
+ for (const m of xml.matchAll(/<CommentsCorrections RefType="([A-Za-z]+)"/g)) if (XML_TYPES[m[1]]) out.add(XML_TYPES[m[1]]);
3204
+ } catch {
3205
+ }
3206
+ return out;
3207
+ }
3208
+ function verifyInfo() {
3209
+ const v = verifyConfig();
3210
+ return v ? { model: v.model, base_url: v.base_url, local: isLocal(v.base_url) } : null;
3211
+ }
3212
+ function downloadInfo() {
3213
+ const browsers = browserRungs().map((b) => b.available ? b : { ...b, note: `not installed: ${INSTALL_HINT}` });
3214
+ return { fetch: true, playwright: PLAYWRIGHT_VERSION, browsers, unlocker: unlockerConfigured() };
3215
+ }
3216
+
3217
+ // src/read.ts
3218
+ function sections(md) {
3219
+ const lines = md.split("\n");
3220
+ const out = [];
3221
+ lines.forEach((l, i) => {
3222
+ const m = /^(#{1,6})\s+(.*\S)\s*$/.exec(l);
3223
+ if (m) out.push({ level: m[1].length, heading: m[2], label: m[2], start: i, end: lines.length, bodyEnd: lines.length, words: 0 });
3224
+ });
3225
+ for (let i = 0; i < out.length; i++) {
3226
+ for (let j = i + 1; j < out.length; j++) {
3227
+ if (out[j].level <= out[i].level) {
3228
+ out[i].end = out[j].start;
3229
+ break;
3230
+ }
3231
+ }
3232
+ out[i].bodyEnd = i + 1 < out.length && out[i + 1].start < out[i].end ? out[i + 1].start : out[i].end;
3233
+ out[i].words = countWords(lines.slice(out[i].start + 1, out[i].end).join("\n"));
3234
+ }
3235
+ const seen = /* @__PURE__ */ new Map();
3236
+ for (const s of out) {
3237
+ const k = s.heading.toLowerCase();
3238
+ seen.set(k, [...seen.get(k) ?? [], s]);
3239
+ }
3240
+ for (const group of seen.values()) if (group.length > 1) group.forEach((s, i) => {
3241
+ s.label = `${s.heading} [${i + 1}/${group.length}]`;
3242
+ });
3243
+ return out;
3244
+ }
3245
+ var countWords = (s) => (s.replace(/^#{1,6}\s+/gm, "").match(/\S+/g) ?? []).length;
3246
+ function outline(md, text3) {
3247
+ return { text: text3, words: countWords(md), sections: sections(md).filter((s) => s.level > 1).map(({ level, label, words: words2 }) => ({ level, heading: label, words: words2 })) };
3248
+ }
3249
+ function findSections(md, name) {
3250
+ let q = name.trim().toLowerCase().replace(/^#+\s*/, "");
3251
+ let index = null;
3252
+ const m = /^(.*?)\s*\[(\d+)(?:\/\d+)?\]$/.exec(q);
3253
+ if (m) {
3254
+ q = m[1];
3255
+ index = Number(m[2]);
3256
+ }
3257
+ if (!q) return [];
3258
+ const hits = sections(md).filter((s) => s.level > 1 && s.heading.toLowerCase().includes(q)).sort((a, b) => a.heading.length - b.heading.length);
3259
+ if (!hits.length) return [];
3260
+ const same = hits.filter((s) => s.heading.toLowerCase() === hits[0].heading.toLowerCase()).sort((a, b) => a.start - b.start);
3261
+ if (index !== null) return same[index - 1] ? [same[index - 1]] : [];
3262
+ return same;
3263
+ }
3264
+ function readSection(md, name, maxChars) {
3265
+ const found = findSections(md, name);
3266
+ if (!found.length) return null;
3267
+ const lines = md.split("\n");
3268
+ if (found.length > 1) {
3269
+ return { ambiguous: found.map((s) => ({ heading: s.label, words: s.words, first_line: (lines.slice(s.start + 1, s.end).find((l) => l.trim()) ?? "").trim().slice(0, 120) })) };
3270
+ }
3271
+ return sectionText(lines, found[0], sections(md), maxChars);
3272
+ }
3273
+ function sectionText(lines, s, all, maxChars) {
3274
+ const body = lines.slice(s.start + 1, s.bodyEnd).join("\n").trim();
3275
+ const truncated = body.length > maxChars;
3276
+ const text3 = truncated ? body.slice(0, maxChars) + "\n\n[truncated]" : body;
3277
+ const inside = all.filter((c) => c.start > s.start && c.start < s.end);
3278
+ const childLevel = Math.min(...inside.map((c) => c.level));
3279
+ const children = inside.filter((c) => c.level === childLevel).map((c) => ({ heading: c.label, words: c.words }));
3280
+ const out = { heading: s.label, words: s.words, text: text3, returned_words: countWords(truncated ? body.slice(0, maxChars) : body), truncated };
3281
+ if (children.length) out.subsections = children;
3282
+ return out;
3283
+ }
3284
+ var DECLARATION = /fund|financ|conflict|competing|interest|disclos|support|acknowledg|declar|ethic|contribut|consent|availab|sponsor/i;
3285
+ function assembleDeclarations(md, maxChars) {
3286
+ const lines = md.split("\n");
3287
+ const parts = sections(md).filter((s) => s.level > 1 && DECLARATION.test(s.heading));
3288
+ if (!parts.length) return null;
3289
+ const body = parts.map((s) => `### ${s.label}
3290
+
3291
+ ${lines.slice(s.start + 1, s.bodyEnd).join("\n").trim()}`).join("\n\n");
3292
+ const truncated = body.length > maxChars;
3293
+ return {
3294
+ heading: "Declarations (assembled)",
3295
+ words: countWords(body),
3296
+ text: truncated ? body.slice(0, maxChars) + "\n\n[truncated]" : body,
3297
+ returned_words: countWords(truncated ? body.slice(0, maxChars) : body),
3298
+ truncated,
3299
+ subsections: parts.map((s) => ({ heading: s.label, words: s.words }))
3300
+ };
3301
+ }
3302
+ var WINDOW_CHARS = 1e3;
3303
+ function search(md, query, maxHits, opts = {}) {
3304
+ const window = opts.window ?? WINDOW_CHARS;
3305
+ const terms = [...new Set(tokens(query))];
3306
+ const stems = [...new Set(terms.map(stem))];
3307
+ if (!stems.length) return { passages: [], partial: false, missing_terms: [] };
3308
+ const paras = paragraphs(md, opts.scope);
3309
+ const found = /* @__PURE__ */ new Set();
3310
+ const scored = [];
3311
+ for (const p of paras) {
3312
+ const counts = /* @__PURE__ */ new Map();
3313
+ for (const t of tokens(p.text)) {
3314
+ const s = stem(t);
3315
+ counts.set(s, (counts.get(s) ?? 0) + 1);
3316
+ }
3317
+ const matched = stems.filter((s) => counts.has(s));
3318
+ if (!matched.length) continue;
3319
+ matched.forEach((s) => found.add(s));
3320
+ scored.push({ p, matched, hits: matched.reduce((n, s) => n + counts.get(s), 0) });
3321
+ }
3322
+ const missing_terms = terms.filter((t) => !found.has(stem(t)));
3323
+ const full = scored.filter((x) => x.matched.length === stems.length);
3324
+ const partial = !full.length && scored.length > 0;
3325
+ const df = /* @__PURE__ */ new Map();
3326
+ for (const x of scored) for (const s of x.matched) df.set(s, (df.get(s) ?? 0) + 1);
3327
+ const rarity = (x) => x.matched.reduce((n, s) => n + 1 / df.get(s), 0);
3328
+ const pick = (partial ? scored : full).sort((a, b) => b.matched.length - a.matched.length || (partial ? rarity(b) - rarity(a) : 0) || b.hits - a.hits || a.p.text.length - b.p.text.length).slice(0, maxHits);
3329
+ const passages = pick.map(({ p, matched, hits }) => {
3330
+ const w = clip(p, matched, window);
3331
+ const out = { section: p.section, text: w.text, hits };
3332
+ if (partial) out.matched = terms.filter((t) => matched.includes(stem(t)));
3333
+ if (w.clipped) out.clipped = true;
3334
+ return out;
3335
+ });
3336
+ return { passages, partial, missing_terms };
3337
+ }
3338
+ function paragraphs(md, scope) {
3339
+ const lines = md.split("\n");
3340
+ const secs = sections(md);
3341
+ const from = scope ? scope.start + 1 : 0, to = scope ? scope.end : lines.length;
3342
+ const sectionAt = (i) => {
3343
+ let h = "";
3344
+ for (const s of secs) {
3345
+ if (s.start <= i) h = s.label;
3346
+ else break;
3347
+ }
3348
+ return h;
3349
+ };
3350
+ const out = [];
3351
+ let para2 = [], paraStart = 0, header = null;
3352
+ const flush = () => {
3353
+ const text3 = para2.join("\n").trim();
3354
+ para2 = [];
3355
+ if (text3) out.push({ section: sectionAt(paraStart), text: text3 });
3356
+ };
3357
+ for (let i = from; i < to; i++) {
3358
+ const l = lines[i];
3359
+ if (!l.trim() || /^#{1,6}\s/.test(l)) {
3360
+ flush();
3361
+ header = null;
3362
+ continue;
3363
+ }
3364
+ if (/^\|/.test(l)) {
3365
+ flush();
3366
+ if (/^\|?\s*:?-{3,}/.test(l)) continue;
3367
+ if (header === null) {
3368
+ header = l.trim();
3369
+ continue;
3370
+ }
3371
+ out.push({ section: sectionAt(i), text: l.trim(), row: header });
3372
+ continue;
3373
+ }
3374
+ header = null;
3375
+ if (!para2.length) paraStart = i;
3376
+ para2.push(l);
3377
+ }
3378
+ flush();
3379
+ return out;
3380
+ }
3381
+ function clip(p, matched, window) {
3382
+ if (p.row !== void 0) {
3383
+ const text4 = `${p.row}
3384
+ ${p.text}`;
3385
+ return text4.length <= window ? { text: text4, clipped: false } : { text: text4.slice(0, window) + " \u2026", clipped: true };
3386
+ }
3387
+ if (p.text.length <= window) return { text: p.text, clipped: false };
3388
+ const sents = p.text.split(/(?<=[.!?;])\s+(?=[^a-z])/u);
3389
+ const score = sents.map((s) => {
3390
+ const st = new Set(tokens(s).map(stem));
3391
+ return matched.filter((m) => st.has(m)).length;
3392
+ });
3393
+ let best = 0;
3394
+ score.forEach((v, i) => {
3395
+ if (v > score[best]) best = i;
3396
+ });
3397
+ let lo = best, hi = best, len = sents[best].length;
3398
+ for (; ; ) {
3399
+ const left = lo > 0 ? sents[lo - 1].length + 1 : Infinity;
3400
+ const right = hi + 1 < sents.length ? sents[hi + 1].length + 1 : Infinity;
3401
+ if (Math.min(left, right) === Infinity || len + Math.min(left, right) > window) break;
3402
+ if (right <= left) {
3403
+ hi++;
3404
+ len += right;
3405
+ } else {
3406
+ lo--;
3407
+ len += left;
3408
+ }
3409
+ }
3410
+ let text3 = sents.slice(lo, hi + 1).join(" ");
3411
+ if (text3.length > window) text3 = text3.slice(0, window) + " \u2026";
3412
+ if (lo > 0) text3 = "\u2026 " + text3;
3413
+ if (hi + 1 < sents.length && !text3.endsWith("\u2026")) text3 += " \u2026";
3414
+ return { text: text3, clipped: true };
3415
+ }
3416
+ var fold = (s) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[*_~^]/g, "").replace(/[-/]/g, " ");
3417
+ function tokens(s) {
3418
+ return (fold(s).match(/[\p{L}\p{N}][\p{L}\p{N}'.%]*/gu) ?? []).map((t) => t.replace(/[.']+$/, "")).filter(Boolean);
3419
+ }
3420
+ function stem(w) {
3421
+ if (/\d/.test(w)) return w;
3422
+ if (w.length > 3 && w.endsWith("s") && !w.endsWith("ss")) w = w.slice(0, -1);
3423
+ if (w.endsWith("ie")) return w.slice(0, -2) + "y";
3424
+ if (w.length >= 4) w = w.replace(/(ation|ing|ed|e)$/, "");
3425
+ return w;
3426
+ }
3427
+
3428
+ // src/doi.ts
3429
+ var NCBI_ESEARCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi";
3430
+ async function pmidForDoi(doi) {
3431
+ const u = new URL(NCBI_ESEARCH);
3432
+ u.searchParams.set("db", "pubmed");
3433
+ u.searchParams.set("term", `${doi}[doi]`);
3434
+ u.searchParams.set("retmode", "json");
3435
+ if (process.env.NCBI_API_KEY) u.searchParams.set("api_key", process.env.NCBI_API_KEY);
3436
+ u.searchParams.set("tool", "fh-library");
3437
+ u.searchParams.set("email", contactEmail());
3438
+ const j = JSON.parse(await fetchText(u.toString()));
3439
+ const ids = j.esearchresult?.idlist ?? [];
3440
+ return ids.length === 1 ? ids[0] : null;
3441
+ }
3442
+ async function fetchCrossref(doi) {
3443
+ try {
3444
+ return await fetchText(`https://api.crossref.org/works/${encodeURIComponent(doi)}?mailto=${encodeURIComponent(contactEmail())}`, { "User-Agent": LIBRARY_UA });
3445
+ } catch (e) {
3446
+ if (/HTTP 404/.test(e instanceof Error ? e.message : String(e))) return null;
3447
+ throw new Error(`crossref fetch failed: ${e instanceof Error ? e.message : String(e)}`);
3448
+ }
3449
+ }
3450
+ var TYPES = {
3451
+ "journal-article": "Journal Article",
3452
+ "proceedings-article": "Conference Paper",
3453
+ "posted-content": "Preprint",
3454
+ "book-chapter": "Book Chapter",
3455
+ "book": "Book",
3456
+ "monograph": "Book",
3457
+ "report": "Report",
3458
+ "dissertation": "Dissertation",
3459
+ "peer-review": "Peer Review",
3460
+ "dataset": "Dataset",
3461
+ "other": "Other"
3462
+ };
3463
+ var UPDATES = {
3464
+ retraction: "retraction_in",
3465
+ withdrawal: "retraction_in",
3466
+ removal: "retraction_in",
3467
+ "partial-retraction": "partial_retraction_in",
3468
+ correction: "erratum_in",
3469
+ erratum: "erratum_in",
3470
+ corrigendum: "erratum_in",
3471
+ clarification: "erratum_in",
3472
+ addendum: "erratum_in",
3473
+ "expression-of-concern": "expression_of_concern_in",
3474
+ "new-version": "update_in"
3475
+ };
3476
+ var decode = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
3477
+ var text2 = (s) => decode(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
3478
+ function parseCrossref(json2) {
3479
+ let m;
3480
+ try {
3481
+ m = JSON.parse(json2)?.message;
3482
+ } catch {
3483
+ return null;
3484
+ }
3485
+ if (!m?.DOI) return null;
3486
+ const authors = (m.author ?? []).map((a) => a.family ? [a.family, String(a.given ?? "").split(/[\s-]+/).filter(Boolean).map((g) => g[0]).join("")].filter(Boolean).join(" ") : a.name ?? "").filter(Boolean);
3487
+ const first = (m.author ?? [])[0];
3488
+ const date = m.issued?.["date-parts"]?.[0] ?? m.published?.["date-parts"]?.[0] ?? m.created?.["date-parts"]?.[0];
3489
+ const abstract = [];
3490
+ const raw = String(m.abstract ?? "");
3491
+ const secs = [...raw.matchAll(/<jats:sec>\s*(?:<jats:title>([\s\S]*?)<\/jats:title>)?([\s\S]*?)<\/jats:sec>/g)];
3492
+ if (secs.length) for (const s of secs) {
3493
+ const t = text2(s[2]);
3494
+ if (t) abstract.push({ label: s[1] ? text2(s[1]).replace(/[:.]$/, "") : null, text: t });
3495
+ }
3496
+ else {
3497
+ const t = text2(raw.replace(/<jats:title>[\s\S]*?<\/jats:title>/, ""));
3498
+ if (t) abstract.push({ label: null, text: t });
3499
+ }
3500
+ const corrections = (m["updated-by"] ?? []).flatMap((u) => {
3501
+ const type = UPDATES[String(u.type ?? "").toLowerCase()];
3502
+ return type ? [{ type, pmid: null, source: `${u.label ?? u.type}: doi:${u.DOI}${u.updated?.["date-time"] ? ` (${String(u.updated["date-time"]).slice(0, 10)})` : ""}` }] : [];
3503
+ });
3504
+ const funding = [];
3505
+ for (const f of m.funder ?? []) {
3506
+ const n = text2(String(f.name ?? ""));
3507
+ if (n && !funding.includes(n)) funding.push(n);
3508
+ }
3509
+ return {
3510
+ pmid: "",
3511
+ pmcid: null,
3512
+ doi: String(m.DOI).toLowerCase(),
3513
+ title: text2(String((m.title ?? [])[0] ?? "")) || String(m.DOI),
3514
+ authors,
3515
+ firstAuthor: first ? first.family ?? first.name ?? null : null,
3516
+ journal: text2(String((m["container-title"] ?? [])[0] ?? "")) || m.publisher || null,
3517
+ year: date?.[0] ? String(date[0]) : null,
3518
+ publicationTypes: [TYPES[m.type] ?? "Other"],
3519
+ abstract,
3520
+ corrections,
3521
+ coi: null,
3522
+ funding
3523
+ };
3524
+ }
3525
+ function fromInvertedIndex(index) {
3526
+ if (!index) return "";
3527
+ const words2 = [];
3528
+ for (const [w, ps] of Object.entries(index)) for (const p of ps) words2[p] = w;
3529
+ return words2.filter((w) => w !== void 0).join(" ").replace(/\s+([.,;:!?)])/g, "$1").replace(/\(\s+/g, "(").trim();
3530
+ }
3531
+ async function openAlexAbstract(doi) {
3532
+ try {
3533
+ const j = JSON.parse(await fetchText(`https://api.openalex.org/works/https://doi.org/${encodeURIComponent(doi)}?mailto=${encodeURIComponent(contactEmail())}&select=abstract_inverted_index`, { "User-Agent": LIBRARY_UA }));
3534
+ return fromInvertedIndex(j.abstract_inverted_index);
3535
+ } catch {
3536
+ return "";
3537
+ }
3538
+ }
3539
+ async function fetchCsl(doi) {
3540
+ try {
3541
+ const r = await fetch(`https://doi.org/${encodeURIComponent(doi)}`, { headers: { Accept: "application/vnd.citationstyles.csl+json", "User-Agent": LIBRARY_UA }, redirect: "follow", signal: AbortSignal.timeout(3e4) });
3542
+ if (!r.ok) return null;
3543
+ const t = await r.text();
3544
+ return /^\s*\{/.test(t) ? t : null;
3545
+ } catch {
3546
+ return null;
3547
+ }
3548
+ }
3549
+ var CSL_TYPES = { "article-journal": "Journal Article", "paper-conference": "Conference Paper", chapter: "Book Chapter", book: "Book", report: "Report", thesis: "Dissertation", "posted-content": "Preprint", article: "Preprint", dataset: "Dataset" };
3550
+ function parseCsl(json2) {
3551
+ let m;
3552
+ try {
3553
+ m = JSON.parse(json2);
3554
+ } catch {
3555
+ return null;
3556
+ }
3557
+ if (!m?.DOI && !m?.title) return null;
3558
+ const people = m.author ?? m.editor ?? [];
3559
+ const authors = people.map((a) => a.family ? [a.family, String(a.given ?? "").split(/[\s.-]+/).filter(Boolean).map((g) => g[0]).join("")].filter(Boolean).join(" ") : a.literal ?? a.name ?? "").filter(Boolean);
3560
+ const date = m.issued?.["date-parts"]?.[0] ?? m.published?.["date-parts"]?.[0];
3561
+ const abs = text2(String(m.abstract ?? ""));
3562
+ return {
3563
+ pmid: "",
3564
+ pmcid: null,
3565
+ doi: String(m.DOI ?? "").toLowerCase(),
3566
+ title: text2(String(Array.isArray(m.title) ? m.title[0] : m.title ?? "")) || String(m.DOI),
3567
+ authors,
3568
+ firstAuthor: people[0] ? people[0].family ?? people[0].literal ?? null : null,
3569
+ journal: text2(String(Array.isArray(m["container-title"]) ? m["container-title"][0] ?? "" : m["container-title"] ?? "")) || m.publisher || null,
3570
+ year: date?.[0] ? String(date[0]) : null,
3571
+ publicationTypes: [CSL_TYPES[m.type] ?? "Other"],
3572
+ abstract: abs ? [{ label: null, text: abs }] : [],
3573
+ corrections: [],
3574
+ coi: null,
3575
+ funding: []
3576
+ };
3577
+ }
3578
+
3579
+ // src/tools.ts
3580
+ var pkg = (() => {
3581
+ try {
3582
+ return JSON.parse(readFileSync7(new URL("../package.json", import.meta.url), "utf8"));
3583
+ } catch {
3584
+ return { name: "fh-library", version: "0.0.0" };
3585
+ }
3586
+ })();
3587
+ var INSTRUCTIONS = `fh-library keeps a local library of scientific papers, FDA drug labels and clinical trial records, so you can check what a source actually says. The server downloads each record, converts it to Markdown and stores it on disk. Every tool returns a small JSON answer; the document itself never enters your context. One ID per call.
3588
+
3589
+ IDs \u2014 always "type:value"
3590
+ - pmid:30955454 \u2014 a paper by PubMed ID. Bare digits are taken as a PMID.
3591
+ - doi:10.\u2026 \u2014 a paper by DOI. A DOI PubMed indexes is stored under its PMID; any other gets its record from Crossref.
3592
+ - dailymed:<set ID> \u2014 an FDA prescribing-information label, current version. The set ID is the "setid=" value in the label's DailyMed URL.
3593
+ - nct:05173896 \u2014 a ClinicalTrials.gov trial record, current version. NCT05173896 is accepted too.
3594
+
3595
+ Tools
3596
+ - version() \u2014 package name, version, library directory.
3597
+ - info(ids?) \u2014 the whole library: entry counts by type and level, the retrieval sources, browsers, the decision model, the configuration. ids=true lists every entry.
3598
+ - add(id) \u2014 download and store one record; returns its status. A stored full text is returned without any download; an abstract-level record retries only the sources that failed transiently; a record older than 30 days gets its correction notices re-read.
3599
+ - status(id) \u2014 the status of one record without downloading; level "missing" when it is not stored.
3600
+ - read(id, section?, query?, max_chars?, max_hits?) \u2014 one small piece of a stored document: its outline, one section, or the passages matching a query.
3601
+ - verify(claim, id) \u2014 does this one source state this one finding? A decision model judges the stored text and returns a verdict.
3602
+
3603
+ Hints
3604
+ - read and verify store a missing record themselves; the read answer then carries added: true. No add necessary first.
3605
+ - citable false \u2014 the paper is retracted (a retraction notice, or PubMed's publication type), or holds nothing to read: no abstract and no full text. comment says which; corrections lists the notices as {type, pmid, source}.
3606
+ - level \u2014 what is stored: "full_text", "abstract" (no full text could be found; attempts names each source tried and why it failed) or "title" (not even an abstract; citable false).
3607
+ - error "pmid not found" or "invalid id" \u2014 the ID itself is wrong; the same call gives the same answer.
3608
+ - Calls may run in parallel: add, read and verify. Two calls on the same missing record share one add.
3609
+
3610
+ Verifying a claim
3611
+ - verify judges one claim against one stored document. A finding backed by three papers takes three calls with the same claim.
3612
+ - It expects the finding as a complete sentence: who or what, the intervention, the outcome, the number. Citation markers and commentary around the finding are not part of what a document states.
3613
+ - It can only confirm what this one document states. A finding about two interventions from two papers is two claims, each against its own paper. A summary of several trials ("trials used 60\u2013200 mg") is stated by none of them; the dose this one trial used is.
3614
+ - It returns error for a claim under four words, a sentence fragment, or a claim still containing citations. A short finding with its setting (in vitro, cell line, dose) is accepted; filler words do not help.
3615
+ - verdict "pass" \u2014 the document states the claim. score is the model's probability.
3616
+ - verdict "fail" \u2014 the stored full text does not state the claim as worded; read with a query shows what the document does say. Or, with basis "title", nothing is stored to read: reason says so, score is null.
3617
+ - verdict "unverifiable" \u2014 only the abstract is stored and it does not state the claim; the full text may (reason says so). A retry gives the same answer until a full text is stored.
3618
+ - glyphs_unresolved above 0 \u2014 the PDF font lost symbols (minus, \xB1, \u2264); a fail on a number may be the font. read shows the passage.
3619
+ - superseded true \u2014 a published correction replaced the figure the claim uses; the status's corrections holds the notice.
3620
+
3621
+ Reading a document
3622
+ - read with only the ID returns the outline: every heading with its word count, and whether the full text or only the abstract is stored.
3623
+ - section \u2014 a heading from the outline, matched case-insensitively as a substring; the text is capped at max_chars (default 6000). A heading that occurs several times is listed as "Participants [3/12]" and is addressed that way.
3624
+ - query \u2014 returns the passages holding every term: up to max_hits (default 5), each at most 1000 characters around the matches, with its section, best first. It works best with two or three distinctive terms the document itself would use (a measure and a unit, an outcome and a group, a compound name), not a claim sentence. Case, accents, hyphens, plurals and -ed/-ing forms are matched.
3625
+ - partial true \u2014 no passage holds every term; missing_terms are the words the document never uses. The same query without them finds the passage if there is one.
3626
+ - Numbers are often in tables; a matching table row comes with its header row.
3627
+ - section and query together search inside that section only.
3628
+ - section "Abstract" on a full text without one returns the stored abstract. section "Declarations" returns funding, conflicts of interest, acknowledgements and ethics statements, assembled when the document has no section of that name.
3629
+
3630
+ Status fields: requested, type, id, entry, entry_dir, library_dir, pmid, pmcid, doi, setid, nctid, publications, title, first_author, year, publication_types, retracted, corrections, coi (conflict-of-interest statement), funding, citable, comment, has_abstract, level, version, retrieved_by, retrieved_date, pubmed_checked, attempts, files, glyphs_unresolved, error.
3631
+
3632
+ Where records come from: PubMed for the record and abstract; the full text from a file you drop into <library>/inbox/ (PMID in the file name), PubMed Central, Europe PMC, publishers' text-mining links, Unpaywall, OpenAlex, CORE, and a proxy URL template from the configuration. Every action is appended as one line to <library>/library.log.`;
3633
+ var idSchema = z.string().describe('ID as "type:value": a paper "pmid:30955454" (bare digits count as pmid) or "doi:10.5812/asjsm.110753" (a paper PubMed does not index; a DOI PubMed indexes is taken as its PMID), a drug label "dailymed:<set ID>" (the set ID from its DailyMed page URL), or a trial record "nct:05173896" (the eight digits; nct:NCT05173896 and a bare NCT05173896 are accepted).');
3634
+ var levelWord = (m) => m.level === "abstract" && !m.has_abstract ? "title" : m.level;
3635
+ var json = (s) => ({ content: [{ type: "text", text: JSON.stringify(s, null, 2) }] });
3636
+ function noticeTag(m) {
3637
+ const n = (t) => m.corrections.filter((c) => c.type === t).length;
3638
+ const parts = [
3639
+ m.retracted ? "retracted" : "",
3640
+ n("erratum_in") ? `erratum=${n("erratum_in")}` : "",
3641
+ n("expression_of_concern_in") ? `concern=${n("expression_of_concern_in")}` : "",
3642
+ n("update_in") ? `update=${n("update_in")}` : ""
3643
+ ].filter(Boolean);
3644
+ return parts.length ? " " + parts.join(" ") : "";
3645
+ }
3646
+ var modelFailure = (error, cfg) => error.startsWith("no verify model configured") || !!cfg && error.startsWith(`${cfg.model}:`);
3647
+ function registerTools(server2) {
3648
+ async function completeFulltext(paths, meta, requested) {
3649
+ const paper = { title: meta.title, pmid: meta.pmid, pmcid: meta.pmcid, doi: meta.doi, headLine: readHeadLine(paths) };
3650
+ const tried = /* @__PURE__ */ new Set();
3651
+ let ran = false;
3652
+ const { hit } = await fulltextCascade(paper, meta.attempts, (e) => {
3653
+ if (e.ok === "alert") {
3654
+ log("add", requested, `!! ${e.message}`);
3655
+ return;
3656
+ }
3657
+ if (e.ok === "try") tried.add(e.tier);
3658
+ if (e.ok === false && e.ephemeral) return;
3659
+ ran = true;
3660
+ if (e.ok === false) meta = recordAttempts(paths, meta, [attemptLabel(e)]);
3661
+ log(
3662
+ "add",
3663
+ requested,
3664
+ e.ok === true ? `tier ${e.tier} ok (${e.format}, ${e.version})` : e.ok === "try" ? `tier ${e.tier} ${e.message}` : e.skipped ? `tier ${e.tier} skipped: ${e.reason}` : tried.has(e.tier) ? `tier ${e.tier} failed` : `tier ${e.tier} failed: ${e.reason}`
3665
+ );
3666
+ });
3667
+ if (hit) meta = applyFulltext(paths, meta, hit, `${pkg.name} ${pkg.version}`);
3668
+ return { meta, ran };
3669
+ }
3670
+ async function addLabel(requested, setid, paths, status) {
3671
+ if (entryExists(paths)) {
3672
+ const meta = readMeta(paths);
3673
+ if (daysSince(meta.pubmed_checked) < settings().refresh_after_days) {
3674
+ log("add", requested, `exists level=full_text retrieved_by=dailymed ${meta.version ?? ""} ${meta.year ?? ""}`);
3675
+ return statusFromMeta(paths, meta, status);
3676
+ }
3677
+ try {
3678
+ const xml = await fetchSpl(setid);
3679
+ const s = parseSpl(xml);
3680
+ if (!s) throw new Error("dailymed: not an SPL document");
3681
+ const version = s.version ? `spl ${s.version}` : null;
3682
+ if (version === meta.version) {
3683
+ const next2 = { ...meta, pubmed_checked: today() };
3684
+ writeMeta(paths, next2);
3685
+ log("add", requested, `exists level=full_text retrieved_by=dailymed ${meta.version ?? ""} checked`);
3686
+ return statusFromMeta(paths, next2, status);
3687
+ }
3688
+ const next = writeLabelEntry(paths, s, xml, `${pkg.name} ${pkg.version}`);
3689
+ log("add", requested, `refreshed level=full_text retrieved_by=dailymed ${meta.version ?? "?"} \u2192 ${next.version} effective=${s.effective}`);
3690
+ return statusFromMeta(paths, next, status);
3691
+ } catch (e) {
3692
+ log("add", requested, `refresh failed: ${e instanceof Error ? e.message : String(e)}`);
3693
+ return statusFromMeta(paths, meta, status);
3694
+ }
3695
+ }
3696
+ try {
3697
+ const xml = await fetchSpl(setid);
3698
+ const s = parseSpl(xml);
3699
+ if (!s) {
3700
+ log("add", requested, "dailymed failed: not an SPL document");
3701
+ return { ...status, error: "dailymed: not an SPL document" };
3702
+ }
3703
+ const meta = writeLabelEntry(paths, s, xml, `${pkg.name} ${pkg.version}`);
3704
+ log("add", requested, `dailymed fetched level=full_text ${meta.version ?? ""} effective=${s.effective} ${s.brand ?? ""} (${s.generic ?? ""}) entry=${paths.entry}`);
3705
+ return statusFromMeta(paths, meta, status);
3706
+ } catch (e) {
3707
+ const msg = e instanceof Error ? e.message : String(e);
3708
+ log("add", requested, `dailymed failed: ${msg}`);
3709
+ return { ...status, error: msg };
3710
+ }
3711
+ }
3712
+ async function addStudy(requested, nctid, paths, status) {
3713
+ if (entryExists(paths)) {
3714
+ const meta = readMeta(paths);
3715
+ if (daysSince(meta.pubmed_checked) < settings().refresh_after_days) {
3716
+ log("add", requested, `exists level=full_text retrieved_by=ctgov updated ${meta.version ?? ""}`);
3717
+ return statusFromMeta(paths, meta, status);
3718
+ }
3719
+ try {
3720
+ const json2 = await fetchStudy(nctid);
3721
+ const s = parseStudy(json2);
3722
+ if (!s) throw new Error("ctgov: not a study record");
3723
+ if (s.lastUpdate === meta.version) {
3724
+ const next2 = { ...meta, pubmed_checked: today() };
3725
+ writeMeta(paths, next2);
3726
+ log("add", requested, `exists level=full_text retrieved_by=ctgov updated ${meta.version ?? ""} checked`);
3727
+ return statusFromMeta(paths, next2, status);
3728
+ }
3729
+ const next = writeStudyEntry(paths, s, json2, `${pkg.name} ${pkg.version}`);
3730
+ log("add", requested, `refreshed level=full_text retrieved_by=ctgov updated ${meta.version ?? "?"} \u2192 ${next.version} ${s.status ?? ""}${s.hasResults ? " results" : ""}`);
3731
+ return statusFromMeta(paths, next, status);
3732
+ } catch (e) {
3733
+ log("add", requested, `refresh failed: ${e instanceof Error ? e.message : String(e)}`);
3734
+ return statusFromMeta(paths, meta, status);
3735
+ }
3736
+ }
3737
+ try {
3738
+ const json2 = await fetchStudy(nctid);
3739
+ const s = parseStudy(json2);
3740
+ if (!s) {
3741
+ log("add", requested, "ctgov failed: not a study record");
3742
+ return { ...status, error: "ctgov: not a study record" };
3743
+ }
3744
+ const meta = writeStudyEntry(paths, s, json2, `${pkg.name} ${pkg.version}`);
3745
+ log("add", requested, `ctgov fetched level=full_text updated ${meta.version ?? ""} ${s.status ?? ""}${s.hasResults ? " results" : ""} ${s.acronym ?? ""} entry=${paths.entry}`);
3746
+ return statusFromMeta(paths, meta, status);
3747
+ } catch (e) {
3748
+ const msg = e instanceof Error ? e.message : String(e);
3749
+ log("add", requested, `ctgov failed: ${msg}`);
3750
+ return { ...status, error: msg };
3751
+ }
3752
+ }
3753
+ server2.tool(
3754
+ "add",
3755
+ "Download and store one record \u2014 a paper (pmid:, doi:), a drug label (dailymed:<set ID>) or a trial record (nct:<8 digits>) \u2014 and return its status JSON. A paper gets its record, abstract and correction notices from PubMed (Crossref for a DOI PubMed does not index), then the full text from the configured sources; a label or trial record is stored as a full text. A stored full text is returned without any download; an abstract-level record retries only sources that failed transiently; a record older than 30 days gets its notices re-read.",
3756
+ { id: idSchema },
3757
+ ({ id }) => addEntry(id)
3758
+ );
3759
+ const inflight = /* @__PURE__ */ new Map();
3760
+ function addEntry(id) {
3761
+ const key = parseId(id).requested;
3762
+ let p = inflight.get(key);
3763
+ if (!p) {
3764
+ p = addEntryNow(id).finally(() => inflight.delete(key));
3765
+ inflight.set(key, p);
3766
+ }
3767
+ return p;
3768
+ }
3769
+ async function addEntryNow(id) {
3770
+ const t0 = Date.now();
3771
+ let parsed = parseId(id);
3772
+ if (parsed.error) {
3773
+ log("add", parsed.requested, `error ${parsed.error}`);
3774
+ return json({ ...emptyStatus(parsed.requested, parsed.type, parsed.id, FH_LIBRARY_DIR), error: parsed.error });
3775
+ }
3776
+ let copyTo = null;
3777
+ if (parsed.type === "doi") {
3778
+ const doiPaths = entryPaths(FH_LIBRARY_DIR, "doi", parsed.id);
3779
+ let pmid = entryExists(doiPaths) ? readMeta(doiPaths).pmid || null : null;
3780
+ if (!pmid && !entryExists(doiPaths)) {
3781
+ try {
3782
+ pmid = await pmidForDoi(parsed.id);
3783
+ } catch (e) {
3784
+ log("add", parsed.requested, `pubmed lookup failed: ${e instanceof Error ? e.message : String(e)}`);
3785
+ }
3786
+ if (pmid && !entryExists(entryPaths(FH_LIBRARY_DIR, "pmid", pmid))) {
3787
+ try {
3788
+ if (!parsePubmedXml(await fetchPubmedXml(pmid))) {
3789
+ log("add", parsed.requested, `pmid:${pmid} has no readable PubMed record \u2014 kept as doi:`);
3790
+ pmid = null;
3791
+ }
3792
+ } catch {
3793
+ }
3794
+ }
3795
+ if (pmid) log("add", parsed.requested, `indexed in PubMed as pmid:${pmid}`);
3796
+ }
3797
+ if (pmid) {
3798
+ copyTo = doiPaths;
3799
+ parsed = { requested: parsed.requested, type: "pmid", id: pmid };
3800
+ }
3801
+ }
3802
+ const status = emptyStatus(parsed.requested, parsed.type, parsed.id, FH_LIBRARY_DIR);
3803
+ const isDoi = parsed.type === "doi";
3804
+ const paths = entryPaths(FH_LIBRARY_DIR, parsed.type, parsed.id);
3805
+ const finish = (m) => {
3806
+ if (!copyTo) return json(statusFromMeta(paths, m, status));
3807
+ const copy = copyEntry(paths, copyTo);
3808
+ return json(statusFromMeta(copyTo, copy, emptyStatus(parsed.requested, "doi", parseId(parsed.requested).id, FH_LIBRARY_DIR)));
3809
+ };
3810
+ if (parsed.type === "dailymed") return json(await addLabel(`dailymed:${parsed.id}`, parsed.id, paths, status));
3811
+ if (parsed.type === "nct") return json(await addStudy(`nct:${parsed.id}`, `NCT${parsed.id}`, paths, status));
3812
+ let meta, ran = false;
3813
+ const entryExistedBefore = entryExists(paths);
3814
+ if (entryExistedBefore) {
3815
+ meta = readMeta(paths);
3816
+ if (isDoi && daysSince(meta.pubmed_checked) >= settings().refresh_after_days) {
3817
+ try {
3818
+ const r = parseCrossref(await fetchCrossref(parsed.id) ?? "");
3819
+ if (r) {
3820
+ const before = noticeTag(meta);
3821
+ meta = { ...meta, corrections: r.corrections, retracted: r.corrections.some(isRetractionNotice), funding: meta.funding.length ? meta.funding : r.funding, pubmed_checked: today() };
3822
+ writeMeta(paths, meta);
3823
+ log("add", parsed.requested, `refreshed crossref${noticeTag(meta) !== before ? ` changed: ${before.trim() || "none"} \u2192 ${noticeTag(meta).trim() || "none"}` : ""}`);
3824
+ } else log("add", parsed.requested, "refresh failed: doi not found");
3825
+ } catch (e) {
3826
+ log("add", parsed.requested, `refresh failed: ${e instanceof Error ? e.message : String(e)}`);
3827
+ }
3828
+ } else if (daysSince(meta.pubmed_checked) >= settings().refresh_after_days) {
3829
+ try {
3830
+ const xml = await fetchPubmedXml(parsed.id);
3831
+ const record = parsePubmedXml(xml);
3832
+ if (record) {
3833
+ const before = noticeTag(meta);
3834
+ meta = refreshRecord(paths, meta, record, xml);
3835
+ log("add", parsed.requested, `refreshed pubmed_checked=${meta.pubmed_checked}${noticeTag(meta) !== before ? ` changed: ${before.trim() || "none"} \u2192 ${noticeTag(meta).trim() || "none"}` : ""}`);
3836
+ } else log("add", parsed.requested, "refresh failed: pmid not found");
3837
+ } catch (e) {
3838
+ log("add", parsed.requested, `refresh failed: ${e instanceof Error ? e.message : String(e)}`);
3839
+ }
3840
+ }
3841
+ if (meta.level === "full_text") {
3842
+ log("add", parsed.requested, `exists level=full_text retrieved_by=${meta.retrieved_by}${noticeTag(meta)} ${meta.doi ? `doi:${meta.doi}` : "no-doi"}`);
3843
+ return finish(meta);
3844
+ }
3845
+ log("add", parsed.requested, `exists level=${levelWord(meta)}${noticeTag(meta)} ${meta.doi ? `doi:${meta.doi}` : "no-doi"}`);
3846
+ } else if (isDoi) {
3847
+ let json_;
3848
+ try {
3849
+ json_ = await fetchCrossref(parsed.id);
3850
+ } catch (e) {
3851
+ const msg = e instanceof Error ? e.message : String(e);
3852
+ log("add", parsed.requested, `crossref failed: ${msg}`);
3853
+ return json({ ...status, error: msg });
3854
+ }
3855
+ let record = json_ ? parseCrossref(json_) : null;
3856
+ let recordFrom = "crossref";
3857
+ if (!record) {
3858
+ const csl = await fetchCsl(parsed.id);
3859
+ record = csl ? parseCsl(csl) : null;
3860
+ if (record) {
3861
+ json_ = csl;
3862
+ recordFrom = "doi.org";
3863
+ }
3864
+ }
3865
+ if (!record) {
3866
+ log("add", parsed.requested, "crossref failed: doi not found at Crossref or doi.org");
3867
+ return json({ ...status, error: "doi not found" });
3868
+ }
3869
+ if (!record.doi) record.doi = parsed.id;
3870
+ let abstractFrom = recordFrom;
3871
+ if (!record.abstract.length) {
3872
+ const t = await openAlexAbstract(parsed.id);
3873
+ if (t) {
3874
+ record.abstract = [{ label: null, text: t }];
3875
+ abstractFrom = "openalex";
3876
+ }
3877
+ }
3878
+ meta = writeDoiEntry(paths, record, json_, `${pkg.name} ${pkg.version}`, abstractFrom, recordFrom);
3879
+ log("add", parsed.requested, `${recordFrom === "crossref" ? "crossref" : "doi.org"} fetched level=${levelWord(meta)} \u2014 not in PubMed${noticeTag(meta)}${meta.has_abstract ? ` abstract from ${abstractFrom}` : ""} entry=${paths.entry}`);
3880
+ } else {
3881
+ let xml;
3882
+ try {
3883
+ xml = await fetchPubmedXml(parsed.id);
3884
+ } catch (e) {
3885
+ const msg = e instanceof Error ? e.message : String(e);
3886
+ log("add", parsed.requested, `pubmed failed: ${msg}`);
3887
+ return json({ ...status, error: msg });
3888
+ }
3889
+ const record = parsePubmedXml(xml);
3890
+ if (!record) {
3891
+ log("add", parsed.requested, "pubmed failed: pmid not found");
3892
+ return json({ ...status, error: "pmid not found" });
3893
+ }
3894
+ meta = writeEntry(paths, record, xml, `${pkg.name} ${pkg.version}`);
3895
+ log("add", parsed.requested, `pubmed fetched level=${levelWord(meta)}${noticeTag(meta)} ${meta.doi ? `doi:${meta.doi}` : "no-doi"}${meta.pmcid ? ` pmcid:${meta.pmcid}` : ""} entry=${paths.entry}`);
3896
+ }
3897
+ ({ meta, ran } = await completeFulltext(paths, meta, parsed.requested));
3898
+ if (entryExistedBefore && !ran) return finish(meta);
3899
+ const secs = `${((Date.now() - t0) / 1e3).toFixed(1).padStart(4, "0")}s`;
3900
+ log("add", parsed.requested, `done ${secs} level=${levelWord(meta)} retrieved_by=${meta.retrieved_by}`);
3901
+ return finish(meta);
3902
+ }
3903
+ server2.tool(
3904
+ "status",
3905
+ 'Read-only status of the library entry for one paper ID: the same status JSON as add, or level "missing" when there is no entry. Never fetches.',
3906
+ { id: idSchema },
3907
+ async ({ id }) => {
3908
+ const parsed = parseId(id);
3909
+ const status = emptyStatus(parsed.requested, parsed.type, parsed.id, FH_LIBRARY_DIR);
3910
+ if (parsed.error) {
3911
+ log("status", parsed.requested, `error ${parsed.error}`);
3912
+ return json({ ...status, error: parsed.error });
3913
+ }
3914
+ const paths = entryPaths(FH_LIBRARY_DIR, parsed.type, parsed.id);
3915
+ if (entryExists(paths)) {
3916
+ const s = readStatus(paths, status);
3917
+ log("status", parsed.requested, `level=${s.level}`);
3918
+ return json(s);
3919
+ }
3920
+ log("status", parsed.requested, "level=missing");
3921
+ return json({ ...status, entry: paths.entry, entry_dir: paths.entryDir, level: "missing" });
3922
+ }
3923
+ );
3924
+ server2.tool(
3925
+ "read",
3926
+ `One piece of a stored document's Markdown, never the whole. No section/query: the outline (headings with word counts; full text or abstract only). section: that heading's own text (case-insensitive substring; a repeated heading as "Participants [3/12]"), capped at max_chars, its subsections listed. query: passages holding every term, each a window of at most 1000 chars with its section, best first, at most max_hits; partial: true with missing_terms when no passage holds them all. section and query together: inside that section. A missing paper is added first (added: true).`,
3927
+ {
3928
+ id: idSchema,
3929
+ section: z.string().optional().describe("Heading from the outline (case-insensitive substring)."),
3930
+ query: z.string().optional().describe("Two or three distinctive terms the paper itself would use \u2014 a measure and a unit, an outcome and a group, a compound name \u2014 not the claim. Passages holding all of them are returned, else the best partial matches. With section: inside that section."),
3931
+ max_chars: z.number().int().positive().optional().describe("Cap for section text (default 6000); passages are capped at 1000 chars each regardless."),
3932
+ max_hits: z.number().int().positive().optional().describe("Cap for query passages (default 5).")
3933
+ },
3934
+ async ({ id, section: section2, query, max_chars, max_hits }) => {
3935
+ const parsed = parseId(id);
3936
+ const out = (o2) => ({ content: [{ type: "text", text: JSON.stringify(o2, null, 2) }] });
3937
+ if (parsed.error) {
3938
+ log("read", parsed.requested, `error ${parsed.error}`);
3939
+ return out({ requested: parsed.requested, error: parsed.error });
3940
+ }
3941
+ const paths = entryPaths(FH_LIBRARY_DIR, parsed.type, parsed.id);
3942
+ let added = false;
3943
+ if (!entryExists(paths)) {
3944
+ log("read", parsed.requested, "missing adding first");
3945
+ const r = JSON.parse((await addEntry(id)).content[0].text);
3946
+ if (!entryExists(paths)) {
3947
+ log("read", parsed.requested, `error ${r.error ?? "add failed"}`);
3948
+ return out({ requested: parsed.requested, entry: paths.entry, error: `not in the library and add failed: ${r.error ?? "unknown"}` });
3949
+ }
3950
+ added = true;
3951
+ }
3952
+ const full = existsSync8(paths.fulltextMd);
3953
+ const md = readFileSync7(full ? paths.fulltextMd : paths.md, "utf8");
3954
+ const text3 = full ? "full_text" : "abstract";
3955
+ const base = added ? { requested: parsed.requested, entry: paths.entry, text: text3, added: true } : { requested: parsed.requested, entry: paths.entry, text: text3 };
3956
+ const headings2 = () => outline(md, text3).sections;
3957
+ const describe2 = (r) => `${r.passages.length} passages${r.partial ? " partial" : ""}${r.passages.filter((x) => x.clipped).length ? ` ${r.passages.filter((x) => x.clipped).length} clipped` : ""}${r.missing_terms.length ? ` (missing: ${r.missing_terms.join(", ")})` : ""}`;
3958
+ if (section2 && query) {
3959
+ const found = findSections(md, section2);
3960
+ if (found.length !== 1) {
3961
+ log("read", parsed.requested, `section "${section2}" query "${query}" \u2192 ${found.length ? `${found.length} sections match` : "section not found"}`);
3962
+ if (!found.length) return out({ ...base, error: `no section matching "${section2}"`, outline: headings2() });
3963
+ return out({ ...base, error: `${found.length} sections match "${section2}" \u2014 name one, e.g. "${found[0].label}"`, ambiguous: found.map((s) => ({ heading: s.label, words: s.words })) });
3964
+ }
3965
+ const r = search(md, query, max_hits ?? 5, { scope: found[0] });
3966
+ log("read", parsed.requested, `section "${found[0].label}" query "${query}" \u2192 ${describe2(r)}`);
3967
+ return out({ ...base, section: found[0].label, query, ...r });
3968
+ }
3969
+ if (section2) {
3970
+ const cap = max_chars ?? 6e3;
3971
+ let sec = readSection(md, section2, cap);
3972
+ let via = "";
3973
+ if (!sec && /abstract/i.test(section2) && full && existsSync8(paths.md)) {
3974
+ sec = readSection(readFileSync7(paths.md, "utf8"), "abstract", cap);
3975
+ via = " (from the abstract file)";
3976
+ }
3977
+ if (!sec && /declaration/i.test(section2)) {
3978
+ sec = assembleDeclarations(md, cap);
3979
+ via = " (assembled)";
3980
+ }
3981
+ if (!sec) {
3982
+ log("read", parsed.requested, `section "${section2}" not found`);
3983
+ return out({ ...base, error: `no section matching "${section2}"`, outline: headings2() });
3984
+ }
3985
+ if ("ambiguous" in sec) {
3986
+ log("read", parsed.requested, `section "${section2}" \u2192 ${sec.ambiguous.length} sections match`);
3987
+ return out({ ...base, error: `${sec.ambiguous.length} sections match "${section2}" \u2014 name one, e.g. "${sec.ambiguous[0].heading}"`, ...sec });
3988
+ }
3989
+ log("read", parsed.requested, `section "${section2}" \u2192 "${sec.heading}" ${sec.words} words${sec.truncated ? ` truncated to ${sec.returned_words}` : ""}${sec.subsections ? ` ${sec.subsections.length} subsections` : ""}${via}`);
3990
+ return out({ ...base, ...sec });
3991
+ }
3992
+ if (query) {
3993
+ const r = search(md, query, max_hits ?? 5);
3994
+ log("read", parsed.requested, `query "${query}" \u2192 ${describe2(r)}`);
3995
+ if (!r.passages.length) return out({ ...base, query, ...r, sections: headings2().map((s) => s.heading) });
3996
+ return out({ ...base, query, ...r });
3997
+ }
3998
+ const o = outline(md, text3);
3999
+ log("read", parsed.requested, `outline ${o.sections.length} sections ${o.words} words`);
4000
+ return out({ ...base, ...o });
4001
+ }
4002
+ );
4003
+ server2.tool(
4004
+ "verify",
4005
+ "Does one source state one finding of a review? claim: the finding as a complete statement (subject, intervention, outcome, number), without citations or the review's framing, and only what this source reports. id: one source the review cites for it \u2014 paper, drug label (dailymed:\u2026) or trial record (nct:\u2026); added first when missing. The server reads the stored text and asks a decision model; nothing enters your context. verdict: pass (score is the probability) | fail (the full text does not state it; or only the title is stored \u2014 reason says so) | unverifiable (only the abstract is stored and does not state it \u2014 reason says so) | error (claim refused: under four words, a fragment, carrying citations; add failed; no model; model failure). basis: full_text | abstract | title. glyphs_unresolved: symbols the PDF font lost; above 0 a failed number may be the font. superseded: true when a stored correction replaced the claim's figure.",
4006
+ {
4007
+ claim: z.string().describe("One finding, verbatim from the review, without its citations."),
4008
+ id: idSchema
4009
+ },
4010
+ async ({ claim, id }) => {
4011
+ const t0 = Date.now();
4012
+ const cfg = verifyConfig();
4013
+ const pid = parseId(id);
4014
+ if (!pid.error && !entryExists(entryPaths(FH_LIBRARY_DIR, pid.type, pid.id))) {
4015
+ log("verify", pid.requested, "missing adding first");
4016
+ await addEntry(id);
4017
+ }
4018
+ const r = await verifyClaim(claim, id, cfg, cfg ? decider(cfg) : null);
4019
+ const secs = `${((Date.now() - t0) / 1e3).toFixed(1).padStart(4, "0")}s`;
4020
+ log("verify", r.id, r.error ? `${modelFailure(r.error, cfg) ? "!! " : ""}${secs} verdict=${"error".padEnd(12)} ${r.error} | ${claim}` : `${secs} verdict=${r.verdict.padEnd(12)} basis=${r.basis.padEnd(9)} score=${r.score === null ? "- " : r.score.toFixed(3)}${r.windows > 1 ? ` windows=${r.windows}` : ""}${r.superseded ? " superseded" : ""}${r.glyphs_unresolved ? ` glyphs=${r.glyphs_unresolved}` : ""} | ${claim}`);
4021
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
4022
+ }
4023
+ );
4024
+ server2.tool(
4025
+ "info",
4026
+ "The whole library as JSON: library directory and host; entry count and size on disk; per type (pmid, doi, dailymed, nct) the entries by level (full_text, abstract, title) and retractions; counts of retracted and not-citable entries and of errata, expressions of concern and updates; the retrieval sources in run order and which are off (tiers); the download ladder with the browsers and the Playwright version they must run (download); the configuration and where each value came from \u2014 environment, .env or default, never the value (config); the decision model behind verify and whether it answers right now (verify). ids=true also lists every entry ID.",
4027
+ { ids: z.boolean().optional().describe("Also list every entry ID (type:value).") },
4028
+ async ({ ids }) => {
4029
+ const info = libraryInfo(FH_LIBRARY_DIR, HOST, ids === true);
4030
+ const cfg = verifyConfig();
4031
+ if (info.verify && cfg) {
4032
+ const t0 = Date.now();
4033
+ try {
4034
+ const a = await decider(cfg)("The sky is blue.", { q: { type: "noul", instructions: "Is the sky blue?" } });
4035
+ info.verify = { ...info.verify, ok: typeof a.q?.noul === "number", ms: Date.now() - t0, error: null };
4036
+ } catch (e) {
4037
+ info.verify = { ...info.verify, ok: false, ms: Date.now() - t0, error: e instanceof Error ? e.message : String(e) };
4038
+ }
4039
+ }
4040
+ log("info", null, `entries=${info.entries}${info.verify ? ` verify ${info.verify.model} ${info.verify.ok ? `ok ${info.verify.ms} ms` : `down: ${info.verify.error}`}` : " verify not configured"}`);
4041
+ return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] };
4042
+ }
4043
+ );
4044
+ server2.tool(
4045
+ "version",
4046
+ "Get the running fh-library MCP server's package name and version, and the resolved library directory. Useful to confirm which build is loaded.",
4047
+ {},
4048
+ async () => {
4049
+ log("version", null, `${pkg.name} ${pkg.version}`);
4050
+ return { content: [{ type: "text", text: `${pkg.name} v${pkg.version} \xB7 host: ${HOST} \xB7 library_dir: ${FH_LIBRARY_DIR} \xB7 log: ${LOG_FILE}` }] };
4051
+ }
4052
+ );
4053
+ }
4054
+
4055
+ // src/index.ts
4056
+ var server = new McpServer(
4057
+ { name: pkg.name, version: pkg.version },
4058
+ { instructions: INSTRUCTIONS }
4059
+ );
4060
+ registerTools(server);
4061
+ for (const problem of resolveTiers().problems) log("startup", null, `!! ${problem}`);
4062
+ var transport = new StdioServerTransport();
4063
+ await server.connect(transport);