@cyanheads/pubmed-mcp-server 2.5.6 → 2.6.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.
@@ -1,32 +1,29 @@
1
1
  /**
2
- * @fileoverview PMC full-text fetch tool. Retrieves full-text articles from PubMed
3
- * Central via NCBI EFetch with db=pmc. Supports PMCID input or PMID-to-PMCID resolution.
2
+ * @fileoverview Full-text fetch tool. Primary source is PubMed Central (NCBI
3
+ * EFetch, `db=pmc`). When a PMID has no PMC copy but does have a DOI,
4
+ * transparently falls back to Unpaywall to retrieve a legally-deposited
5
+ * open-access copy (HTML or PDF). Output uses a discriminated union on
6
+ * `source` so callers can reason about structural reliability per article.
4
7
  * @module src/mcp-server/tools/definitions/fetch-fulltext.tool
5
8
  */
6
9
  import { tool, z } from '@cyanheads/mcp-ts-core';
10
+ import { htmlExtractor, pdfParser } from '@cyanheads/mcp-ts-core/utils';
7
11
  import { getNcbiService } from '../../../services/ncbi/ncbi-service.js';
12
+ import { extractDoi, extractPmid } from '../../../services/ncbi/parsing/article-parser.js';
8
13
  import { parsePmcArticle } from '../../../services/ncbi/parsing/pmc-article-parser.js';
9
14
  import { findAll, findOne } from '../../../services/ncbi/parsing/pmc-xml-helpers.js';
15
+ import { ensureArray } from '../../../services/ncbi/parsing/xml-helpers.js';
16
+ import { getUnpaywallService } from '../../../services/unpaywall/unpaywall-service.js';
10
17
  import { conceptMeta, EDAM_DATA_RETRIEVAL, SCHEMA_SCHOLARLY_ARTICLE } from './_concepts.js';
11
18
  import { pmidStringSchema } from './_schemas.js';
12
19
  function normalizePmcId(id) {
13
20
  return id.replace(/^PMC/i, '');
14
21
  }
15
- async function resolvePmidsToPmcIds(pmids, signal) {
16
- const records = await getNcbiService().idConvert(pmids, 'pmid', signal ? { signal } : undefined);
17
- const resolved = new Map();
18
- for (const record of records) {
19
- if (record.pmid !== undefined && record.pmcid) {
20
- resolved.set(String(record.pmid), normalizePmcId(String(record.pmcid)));
21
- }
22
- }
23
- return { resolved, unavailable: pmids.filter((pmid) => !resolved.has(pmid)) };
24
- }
25
22
  function filterSections(sections, sectionFilter) {
26
23
  const lowerFilter = sectionFilter.map((s) => s.toLowerCase());
27
24
  return sections.filter((s) => s.title && lowerFilter.some((f) => s.title?.toLowerCase().includes(f)));
28
25
  }
29
- // ─── Tool Definition ─────────────────────────────────────────────────────────
26
+ // ─── Schemas ─────────────────────────────────────────────────────────────────
30
27
  const SubsectionSchema = z
31
28
  .object({
32
29
  title: z.string().optional().describe('Subsection heading'),
@@ -72,27 +69,82 @@ const PublicationDateSchema = z
72
69
  day: z.string().optional().describe('Publication day'),
73
70
  })
74
71
  .describe('Publication date');
75
- const FulltextArticleSchema = z
72
+ const PmcArticleSchema = z
76
73
  .object({
74
+ source: z.literal('pmc').describe('Content came from PubMed Central as structured JATS XML'),
77
75
  pmcId: z.string().describe('PMC ID'),
78
76
  pmcUrl: z.string().describe('PMC URL'),
79
77
  pmid: z.string().optional().describe('PubMed ID'),
80
78
  pubmedUrl: z.string().optional().describe('PubMed URL'),
79
+ doi: z.string().optional().describe('DOI'),
81
80
  title: z.string().optional().describe('Article title'),
82
81
  abstract: z.string().optional().describe('Abstract'),
83
82
  authors: z.array(AuthorSchema).optional().describe('Authors'),
84
- doi: z.string().optional().describe('DOI'),
83
+ affiliations: z.array(z.string()).optional().describe('Author affiliations'),
85
84
  journal: JournalSchema.optional(),
86
85
  keywords: z.array(z.string()).optional().describe('Keywords'),
87
- sections: z.array(SectionSchema).describe('Article body sections'),
88
- references: z.array(ReferenceSchema).optional().describe('Reference list'),
89
86
  articleType: z.string().optional().describe('Article type'),
90
- affiliations: z.array(z.string()).optional().describe('Author affiliations'),
91
87
  publicationDate: PublicationDateSchema.optional(),
88
+ sections: z.array(SectionSchema).describe('Article body sections'),
89
+ references: z.array(ReferenceSchema).optional().describe('Reference list'),
90
+ })
91
+ .describe('Structured PMC full-text article — reliable section/reference structure');
92
+ const UnpaywallArticleSchema = z
93
+ .object({
94
+ source: z
95
+ .literal('unpaywall')
96
+ .describe('Content fetched from an open-access copy indexed by Unpaywall. Best-effort — structural fidelity depends on `contentFormat`.'),
97
+ contentFormat: z
98
+ .enum(['html-markdown', 'pdf-text'])
99
+ .describe('How `content` was extracted. html-markdown: Defuddle extracted Markdown from an HTML landing page; light section structure may survive but is not guaranteed. pdf-text: unpdf extracted plain text from a PDF; no section, reference, or heading structure.'),
100
+ pmid: z.string().describe('PubMed ID the article was resolved from'),
101
+ pubmedUrl: z.string().describe('PubMed URL'),
102
+ doi: z.string().describe('DOI used to locate the open-access copy'),
103
+ sourceUrl: z.string().describe('URL the content was fetched from'),
104
+ title: z.string().optional().describe('Detected article title when present'),
105
+ content: z.string().describe('Full article text — Markdown or plain text per `contentFormat`'),
106
+ wordCount: z
107
+ .number()
108
+ .optional()
109
+ .describe('Approximate word count reported by the HTML extractor; absent for PDFs'),
110
+ totalPages: z
111
+ .number()
112
+ .optional()
113
+ .describe('Page count reported by the PDF extractor; absent for HTML'),
114
+ license: z.string().optional().describe('License identifier from Unpaywall (e.g. cc-by, cc0)'),
115
+ hostType: z
116
+ .string()
117
+ .optional()
118
+ .describe('`publisher` or `repository` — where the OA copy is hosted'),
119
+ version: z
120
+ .string()
121
+ .optional()
122
+ .describe('OA version: submittedVersion | acceptedVersion | publishedVersion'),
123
+ })
124
+ .describe('Best-effort full text from an open-access copy');
125
+ const ArticleSchema = z
126
+ .discriminatedUnion('source', [PmcArticleSchema, UnpaywallArticleSchema])
127
+ .describe('Full-text article; shape depends on `source` (pmc = structured, unpaywall = best-effort)');
128
+ const UnavailableReasonSchema = z
129
+ .enum([
130
+ 'no-pmc-fallback-disabled',
131
+ 'no-doi',
132
+ 'no-oa',
133
+ 'fetch-failed',
134
+ 'parse-failed',
135
+ 'service-error',
136
+ ])
137
+ .describe(`Why the PMID has no full text. no-pmc-fallback-disabled: not in PMC and UNPAYWALL_EMAIL is unset so the Unpaywall fallback is off. no-doi: not in PMC and the ID Converter returned no DOI to try Unpaywall with. no-oa: DOI exists but Unpaywall has no open-access copy indexed. fetch-failed: OA location found but the content could not be downloaded. parse-failed: content was downloaded but text extraction produced nothing usable. service-error: Unpaywall or an upstream host returned a server error.`);
138
+ const UnavailableSchema = z
139
+ .object({
140
+ pmid: z.string().describe('PMID full text could not be returned for'),
141
+ reason: UnavailableReasonSchema,
142
+ detail: z.string().optional().describe('Additional context when available'),
92
143
  })
93
- .describe('Full-text PMC article');
144
+ .describe('One PMID that could not be returned with an explanation of why');
145
+ // ─── Tool Definition ─────────────────────────────────────────────────────────
94
146
  export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
95
- description: 'Fetch full-text articles from PubMed Central (PMC). Returns complete article body text, sections, and references for open-access articles. Accepts PMC IDs directly or PubMed IDs (auto-resolved via the PMC ID Converter).',
147
+ description: 'Fetch full-text articles. Primary source is PubMed Central (structured JATS with sections and references). When a PMID has no PMC copy, transparently falls back to Unpaywall (open-access copies hosted by publishers or institutional repositories) and returns best-effort HTML-as-Markdown or PDF-as-text. Set UNPAYWALL_EMAIL to enable the fallback. Accepts PMC IDs directly or PubMed IDs (auto-resolved via the PMC ID Converter).',
96
148
  annotations: { readOnlyHint: true, openWorldHint: true },
97
149
  _meta: conceptMeta([SCHEMA_SCHOLARLY_ARTICLE, EDAM_DATA_RETRIEVAL]),
98
150
  sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/fetch-fulltext.tool.ts',
@@ -108,28 +160,37 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
108
160
  .min(1)
109
161
  .max(10)
110
162
  .optional()
111
- .describe('PubMed IDs to resolve to PMC full text. Provide this OR pmcids, not both. Only works for open-access articles available in PMC.'),
112
- includeReferences: z.boolean().default(false).describe('Include reference list'),
163
+ .describe('PubMed IDs. Articles in PMC are returned as structured JATS. Articles not in PMC are retrieved from Unpaywall when UNPAYWALL_EMAIL is set and a DOI is available. Provide this OR pmcids, not both.'),
164
+ includeReferences: z
165
+ .boolean()
166
+ .default(false)
167
+ .describe('Include reference list. Applies to `source=pmc` results only.'),
113
168
  maxSections: z
114
169
  .number()
115
170
  .int()
116
171
  .min(1)
117
172
  .max(50)
118
173
  .optional()
119
- .describe('Maximum top-level body sections'),
174
+ .describe('Maximum top-level body sections. Applies to `source=pmc` results only.'),
120
175
  sections: z
121
176
  .array(z.string())
122
177
  .optional()
123
- .describe('Filter to specific sections by title, case-insensitive (e.g. ["Introduction", "Methods", "Results", "Discussion"])'),
178
+ .describe('Filter to specific sections by title, case-insensitive (e.g. ["Introduction", "Methods", "Results", "Discussion"]). Applies to `source=pmc` results only.'),
124
179
  }),
125
180
  output: z.object({
126
- articles: z.array(FulltextArticleSchema).describe('Full-text articles'),
181
+ articles: z.array(ArticleSchema).describe('Full-text articles'),
127
182
  totalReturned: z.number().describe('Number of articles returned'),
128
- unavailablePmids: z.array(z.string()).optional().describe('PMIDs not available in PMC'),
129
- unavailablePmcIds: z.array(z.string()).optional().describe('PMC IDs that returned no data'),
183
+ unavailable: z
184
+ .array(UnavailableSchema)
185
+ .optional()
186
+ .describe('Per-PMID explanations for any requested PMIDs with no returnable full text'),
187
+ unavailablePmcIds: z
188
+ .array(z.string())
189
+ .optional()
190
+ .describe('PMC IDs requested directly that returned no data'),
130
191
  }),
131
192
  async handler(input, ctx) {
132
- ctx.log.info('Executing pubmed_pmc_fetch', {
193
+ ctx.log.info('Executing pubmed_fetch_fulltext', {
133
194
  hasPmcids: !!input.pmcids,
134
195
  hasPmids: !!input.pmids,
135
196
  idCount: (input.pmcids ?? input.pmids)?.length,
@@ -138,135 +199,373 @@ export const fetchFulltextTool = tool('pubmed_fetch_fulltext', {
138
199
  throw new Error('Either pmcids or pmids must be provided');
139
200
  if (input.pmcids && input.pmids)
140
201
  throw new Error('Provide pmcids or pmids, not both');
141
- let pmcIds;
142
- let unavailablePmids;
202
+ // ── PMID path: resolve to PMC + collect unresolved PMIDs for Unpaywall fallback ──
203
+ let pmcIds = [];
204
+ let fallbackCandidates = [];
143
205
  if (input.pmids) {
144
- const resolution = await resolvePmidsToPmcIds(input.pmids, ctx.signal);
145
- if (resolution.resolved.size === 0) {
146
- return { articles: [], totalReturned: 0, unavailablePmids: input.pmids };
206
+ const records = await getNcbiService().idConvert(input.pmids, 'pmid', ctx.signal ? { signal: ctx.signal } : undefined);
207
+ const seen = new Set();
208
+ for (const r of records) {
209
+ if (r.pmid === undefined)
210
+ continue;
211
+ const pmid = String(r.pmid);
212
+ seen.add(pmid);
213
+ if (r.pmcid) {
214
+ pmcIds.push(normalizePmcId(String(r.pmcid)));
215
+ }
216
+ else {
217
+ fallbackCandidates.push({ pmid, ...(r.doi && { doi: r.doi }) });
218
+ }
219
+ }
220
+ // Any requested PMIDs the converter didn't return at all: treat as fallback candidates.
221
+ for (const requested of input.pmids) {
222
+ if (!seen.has(requested))
223
+ fallbackCandidates.push({ pmid: requested });
147
224
  }
148
- pmcIds = [...resolution.resolved.values()];
149
- if (resolution.unavailable.length > 0)
150
- unavailablePmids = resolution.unavailable;
151
225
  }
152
226
  else {
153
227
  pmcIds = (input.pmcids ?? []).map(normalizePmcId);
154
228
  }
155
- const xmlData = await getNcbiService().eFetch({ db: 'pmc', id: pmcIds.join(','), retmode: 'xml' }, {
156
- retmode: 'xml',
157
- useOrderedParser: true,
158
- usePost: pmcIds.length > 5,
159
- signal: ctx.signal,
160
- });
161
- const articleSet = findOne(xmlData, 'pmc-articleset');
162
- if (!articleSet) {
163
- throw new Error('Invalid PMC EFetch response: missing pmc-articleset');
229
+ // ── PMC fetch ───────────────────────────────────────────────────────────
230
+ let pmcArticles = [];
231
+ let unavailablePmcIds;
232
+ if (pmcIds.length > 0) {
233
+ const xmlData = await getNcbiService().eFetch({ db: 'pmc', id: pmcIds.join(','), retmode: 'xml' }, {
234
+ retmode: 'xml',
235
+ useOrderedParser: true,
236
+ usePost: pmcIds.length > 5,
237
+ signal: ctx.signal,
238
+ });
239
+ const articleSet = findOne(xmlData, 'pmc-articleset');
240
+ if (!articleSet)
241
+ throw new Error('Invalid PMC EFetch response: missing pmc-articleset');
242
+ let parsed = findAll(articleSet, 'article').map(parsePmcArticle);
243
+ if (input.sections?.length) {
244
+ const sectionFilter = input.sections;
245
+ parsed = parsed.map((a) => ({ ...a, sections: filterSections(a.sections, sectionFilter) }));
246
+ }
247
+ if (input.maxSections !== undefined) {
248
+ parsed = parsed.map((a) => ({ ...a, sections: a.sections.slice(0, input.maxSections) }));
249
+ }
250
+ if (!input.includeReferences) {
251
+ parsed = parsed.map(({ references: _, ...rest }) => rest);
252
+ }
253
+ pmcArticles = parsed.map((a) => ({ source: 'pmc', ...a }));
254
+ const returnedPmcIds = new Set(pmcArticles.map((a) => a.pmcId));
255
+ const missing = pmcIds.map((id) => `PMC${id}`).filter((id) => !returnedPmcIds.has(id));
256
+ if (missing.length > 0)
257
+ unavailablePmcIds = missing;
164
258
  }
165
- let articles = findAll(articleSet, 'article').map(parsePmcArticle);
166
- if (input.sections?.length) {
167
- const sectionFilter = input.sections;
168
- articles = articles.map((a) => ({
169
- ...a,
170
- sections: filterSections(a.sections, sectionFilter),
171
- }));
259
+ // ── Unpaywall fallback for PMIDs not in PMC ─────────────────────────────
260
+ const unpaywall = getUnpaywallService();
261
+ const unavailable = [];
262
+ const fallbackArticles = [];
263
+ if (fallbackCandidates.length > 0) {
264
+ if (!unpaywall) {
265
+ for (const c of fallbackCandidates) {
266
+ unavailable.push({
267
+ pmid: c.pmid,
268
+ reason: 'no-pmc-fallback-disabled',
269
+ detail: 'Article not in PMC and UNPAYWALL_EMAIL is not set',
270
+ });
271
+ }
272
+ }
273
+ else {
274
+ // The PMC ID Converter only returns DOIs for articles it has in PMC, so
275
+ // candidates here are missing DOIs by default. Pull them from PubMed
276
+ // metadata (db=pubmed) before dispatching to Unpaywall.
277
+ const needDoi = fallbackCandidates.filter((c) => !c.doi).map((c) => c.pmid);
278
+ if (needDoi.length > 0) {
279
+ try {
280
+ const doiMap = await fetchPubmedDois(needDoi, ctx.signal);
281
+ fallbackCandidates = fallbackCandidates.map((c) => {
282
+ if (c.doi)
283
+ return c;
284
+ const doi = doiMap.get(c.pmid);
285
+ return doi ? { ...c, doi } : c;
286
+ });
287
+ }
288
+ catch (error) {
289
+ ctx.log.warning('Failed to batch-fetch DOIs from PubMed for Unpaywall fallback', {
290
+ error: error instanceof Error ? error.message : String(error),
291
+ pmidCount: needDoi.length,
292
+ });
293
+ }
294
+ }
295
+ const outcomes = await Promise.allSettled(fallbackCandidates.map((c) => resolveViaUnpaywall(c, ctx)));
296
+ for (const outcome of outcomes) {
297
+ if (outcome.status === 'fulfilled') {
298
+ if ('article' in outcome.value)
299
+ fallbackArticles.push(outcome.value.article);
300
+ else
301
+ unavailable.push(outcome.value.unavailable);
302
+ }
303
+ else {
304
+ ctx.log.warning('Unpaywall fallback crashed unexpectedly', {
305
+ error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason),
306
+ });
307
+ }
308
+ }
309
+ }
172
310
  }
173
- if (input.maxSections !== undefined)
174
- articles = articles.map((a) => ({ ...a, sections: a.sections.slice(0, input.maxSections) }));
175
- if (!input.includeReferences)
176
- articles = articles.map(({ references: _, ...rest }) => rest);
177
- const returnedPmcIds = new Set(articles.map((a) => a.pmcId));
178
- const missingPmcIds = pmcIds
179
- .map((id) => (id.startsWith('PMC') ? id : `PMC${id}`))
180
- .filter((id) => !returnedPmcIds.has(id));
181
- ctx.log.info('pubmed_pmc_fetch completed', {
182
- requested: pmcIds.length,
311
+ const articles = [...pmcArticles, ...fallbackArticles];
312
+ ctx.log.info('pubmed_fetch_fulltext completed', {
313
+ requested: (input.pmids ?? input.pmcids)?.length ?? 0,
183
314
  returned: articles.length,
315
+ pmcHits: pmcArticles.length,
316
+ unpaywallHits: fallbackArticles.length,
317
+ unavailable: unavailable.length,
184
318
  });
185
319
  return {
186
320
  articles,
187
321
  totalReturned: articles.length,
188
- ...(unavailablePmids && { unavailablePmids }),
189
- ...(missingPmcIds.length > 0 && { unavailablePmcIds: missingPmcIds }),
322
+ ...(unavailable.length > 0 && { unavailable }),
323
+ ...(unavailablePmcIds && { unavailablePmcIds }),
190
324
  };
191
325
  },
192
326
  format: (result) => {
193
- const lines = [`## PMC Full-Text Articles`, `**Articles Returned:** ${result.totalReturned}`];
194
- if (result.unavailablePmids?.length)
195
- lines.push(`**Unavailable PMIDs:** ${result.unavailablePmids.join(', ')}`);
196
- if (result.unavailablePmcIds?.length)
327
+ const lines = [`## Full-Text Articles`, `**Articles Returned:** ${result.totalReturned}`];
328
+ if (result.unavailable?.length) {
329
+ lines.push(`\n**Unavailable PMIDs (${result.unavailable.length}):**`);
330
+ for (const u of result.unavailable) {
331
+ lines.push(`- ${u.pmid} — ${u.reason}${u.detail ? `: ${u.detail}` : ''}`);
332
+ }
333
+ }
334
+ if (result.unavailablePmcIds?.length) {
197
335
  lines.push(`**Unavailable PMC IDs:** ${result.unavailablePmcIds.join(', ')}`);
336
+ }
198
337
  for (const a of result.articles) {
199
- lines.push(`\n### ${a.title ?? a.pmcId}`);
200
- if (a.authors?.length) {
201
- lines.push(`\n**Authors (${a.authors.length}):**`);
202
- for (const au of a.authors) {
203
- lines.push(`- ${formatPmcAuthor(au)}`);
204
- }
205
- }
206
- if (a.affiliations?.length) {
207
- lines.push(`\n**Affiliations:**`);
208
- for (const [i, aff] of a.affiliations.entries()) {
209
- lines.push(`${i + 1}. ${aff}`);
210
- }
211
- }
212
- if (a.journal) {
213
- const parts = [];
214
- if (a.journal.title)
215
- parts.push(a.journal.title);
216
- if (a.journal.volume)
217
- parts.push(`**${a.journal.volume}**${a.journal.issue ? `(${a.journal.issue})` : ''}`);
218
- if (a.journal.pages)
219
- parts.push(a.journal.pages);
220
- if (a.journal.issn)
221
- parts.push(`ISSN ${a.journal.issn}`);
222
- if (parts.length)
223
- lines.push(`\n**Journal:** ${parts.join(', ')}`);
224
- }
225
- if (a.articleType)
226
- lines.push(`**Type:** ${a.articleType}`);
227
- if (a.publicationDate) {
228
- const d = a.publicationDate;
229
- const dateParts = [d.year, d.month, d.day].filter(Boolean);
230
- if (dateParts.length)
231
- lines.push(`**Published:** ${dateParts.join('-')}`);
232
- }
233
- lines.push(`**PMCID:** ${a.pmcId}`);
234
- if (a.pmid)
235
- lines.push(`**PMID:** ${a.pmid}`);
236
- if (a.doi)
237
- lines.push(`**DOI:** ${a.doi}`);
238
- lines.push(`**PMC:** ${a.pmcUrl}`);
239
- if (a.pubmedUrl)
240
- lines.push(`**PubMed:** ${a.pubmedUrl}`);
241
- if (a.keywords?.length)
242
- lines.push(`**Keywords:** ${a.keywords.join(', ')}`);
243
- if (a.abstract)
244
- lines.push(`\n#### Abstract\n${a.abstract}`);
245
- for (const sec of a.sections) {
246
- if (sec.title)
247
- lines.push(`\n#### ${formatHeading(sec.label, sec.title)}`);
248
- if (sec.text)
249
- lines.push(sec.text);
250
- if (sec.subsections?.length) {
251
- for (const sub of sec.subsections) {
252
- if (sub.title)
253
- lines.push(`\n##### ${formatHeading(sub.label, sub.title)}`);
254
- if (sub.text)
255
- lines.push(sub.text);
256
- }
257
- }
258
- }
259
- if (a.references?.length) {
260
- lines.push(`\n#### References (${a.references.length})`);
261
- for (const ref of a.references) {
262
- const tag = [ref.label, ref.id].filter(Boolean).join(' ');
263
- lines.push(`- ${tag ? `[${tag}] ` : ''}${ref.citation}`);
264
- }
265
- }
338
+ lines.push('');
339
+ if (a.source === 'pmc')
340
+ formatPmcArticle(a, lines);
341
+ else
342
+ formatUnpaywallArticle(a, lines);
266
343
  }
267
344
  return [{ type: 'text', text: lines.join('\n') }];
268
345
  },
269
346
  });
347
+ /**
348
+ * Batch-fetch DOIs from PubMed metadata for PMIDs that lack one after the PMC
349
+ * ID Converter roundtrip. The Converter only returns DOIs for articles already
350
+ * in PMC, so non-PMC PMIDs arrive here with `doi: undefined` — yet the DOI is
351
+ * present in PubMed's own record (ELocationID / ArticleIdList) and is required
352
+ * to query Unpaywall. One eFetch call covers the whole batch.
353
+ */
354
+ async function fetchPubmedDois(pmids, signal) {
355
+ const out = new Map();
356
+ if (pmids.length === 0)
357
+ return out;
358
+ const xmlData = await getNcbiService().eFetch({ db: 'pubmed', id: pmids.join(','), retmode: 'xml' }, { retmode: 'xml', usePost: pmids.length >= 100, ...(signal && { signal }) });
359
+ const articles = xmlData?.PubmedArticleSet?.PubmedArticle
360
+ ? ensureArray(xmlData.PubmedArticleSet.PubmedArticle)
361
+ : [];
362
+ for (const article of articles) {
363
+ if (!article?.MedlineCitation)
364
+ continue;
365
+ const pmid = extractPmid(article.MedlineCitation);
366
+ if (!pmid)
367
+ continue;
368
+ const doi = extractDoi(article.MedlineCitation.Article, article.PubmedData?.ArticleIdList);
369
+ if (doi)
370
+ out.set(pmid, doi);
371
+ }
372
+ return out;
373
+ }
374
+ async function resolveViaUnpaywall(candidate, ctx) {
375
+ const { pmid, doi } = candidate;
376
+ const service = getUnpaywallService();
377
+ if (!doi)
378
+ return { unavailable: { pmid, reason: 'no-doi' } };
379
+ if (!service)
380
+ return { unavailable: { pmid, reason: 'no-pmc-fallback-disabled' } };
381
+ let resolution;
382
+ try {
383
+ resolution = await service.resolve(doi, ctx.signal);
384
+ }
385
+ catch (error) {
386
+ const detail = error instanceof Error ? error.message : String(error);
387
+ return { unavailable: { pmid, reason: 'service-error', detail } };
388
+ }
389
+ if (resolution.kind === 'no-oa') {
390
+ return { unavailable: { pmid, reason: 'no-oa', detail: resolution.reason } };
391
+ }
392
+ let content;
393
+ try {
394
+ content = await service.fetchContent(resolution.location, ctx.signal);
395
+ }
396
+ catch (error) {
397
+ const detail = error instanceof Error ? error.message : String(error);
398
+ return { unavailable: { pmid, reason: 'fetch-failed', detail } };
399
+ }
400
+ try {
401
+ if (content.kind === 'html') {
402
+ const extracted = await htmlExtractor.extract(content.body, {
403
+ url: content.fetchedUrl,
404
+ format: 'markdown',
405
+ });
406
+ const body = extracted.content.trim();
407
+ if (!body) {
408
+ return {
409
+ unavailable: {
410
+ pmid,
411
+ reason: 'parse-failed',
412
+ detail: 'HTML extraction produced empty content',
413
+ },
414
+ };
415
+ }
416
+ return {
417
+ article: buildUnpaywallArticle({
418
+ pmid,
419
+ doi,
420
+ sourceUrl: content.fetchedUrl,
421
+ location: resolution.location,
422
+ contentFormat: 'html-markdown',
423
+ content: body,
424
+ title: extracted.title,
425
+ wordCount: extracted.wordCount,
426
+ }),
427
+ };
428
+ }
429
+ const extracted = await pdfParser.extractText(content.body, { mergePages: true });
430
+ const text = typeof extracted.text === 'string' ? extracted.text.trim() : '';
431
+ if (!text) {
432
+ return {
433
+ unavailable: {
434
+ pmid,
435
+ reason: 'parse-failed',
436
+ detail: 'PDF extraction produced empty text',
437
+ },
438
+ };
439
+ }
440
+ return {
441
+ article: buildUnpaywallArticle({
442
+ pmid,
443
+ doi,
444
+ sourceUrl: content.fetchedUrl,
445
+ location: resolution.location,
446
+ contentFormat: 'pdf-text',
447
+ content: text,
448
+ totalPages: extracted.totalPages,
449
+ }),
450
+ };
451
+ }
452
+ catch (error) {
453
+ const detail = error instanceof Error ? error.message : String(error);
454
+ ctx.log.warning('Unpaywall content extraction failed', { pmid, doi, detail });
455
+ return { unavailable: { pmid, reason: 'parse-failed', detail } };
456
+ }
457
+ }
458
+ function buildUnpaywallArticle(args) {
459
+ const { location } = args;
460
+ return {
461
+ source: 'unpaywall',
462
+ contentFormat: args.contentFormat,
463
+ pmid: args.pmid,
464
+ pubmedUrl: `https://pubmed.ncbi.nlm.nih.gov/${args.pmid}/`,
465
+ doi: args.doi,
466
+ sourceUrl: args.sourceUrl,
467
+ content: args.content,
468
+ ...(args.title && { title: args.title }),
469
+ ...(args.wordCount !== undefined && { wordCount: args.wordCount }),
470
+ ...(args.totalPages !== undefined && { totalPages: args.totalPages }),
471
+ ...(location.license && { license: location.license }),
472
+ ...(location.host_type && { hostType: location.host_type }),
473
+ ...(location.version && { version: location.version }),
474
+ };
475
+ }
476
+ // ─── format() helpers ────────────────────────────────────────────────────────
477
+ function formatPmcArticle(a, lines) {
478
+ lines.push(`### ${a.title ?? a.pmcId}`);
479
+ lines.push(`**Source:** PMC (structured JATS)`);
480
+ if (a.authors?.length) {
481
+ lines.push(`\n**Authors (${a.authors.length}):**`);
482
+ for (const au of a.authors)
483
+ lines.push(`- ${formatPmcAuthor(au)}`);
484
+ }
485
+ if (a.affiliations?.length) {
486
+ lines.push(`\n**Affiliations:**`);
487
+ for (const [i, aff] of a.affiliations.entries())
488
+ lines.push(`${i + 1}. ${aff}`);
489
+ }
490
+ if (a.journal) {
491
+ const parts = [];
492
+ if (a.journal.title)
493
+ parts.push(a.journal.title);
494
+ if (a.journal.volume)
495
+ parts.push(`**${a.journal.volume}**${a.journal.issue ? `(${a.journal.issue})` : ''}`);
496
+ if (a.journal.pages)
497
+ parts.push(a.journal.pages);
498
+ if (a.journal.issn)
499
+ parts.push(`ISSN ${a.journal.issn}`);
500
+ if (parts.length)
501
+ lines.push(`\n**Journal:** ${parts.join(', ')}`);
502
+ }
503
+ if (a.articleType)
504
+ lines.push(`**Type:** ${a.articleType}`);
505
+ if (a.publicationDate) {
506
+ const d = a.publicationDate;
507
+ const dateParts = [d.year, d.month, d.day].filter(Boolean);
508
+ if (dateParts.length)
509
+ lines.push(`**Published:** ${dateParts.join('-')}`);
510
+ }
511
+ lines.push(`**PMCID:** ${a.pmcId}`);
512
+ if (a.pmid)
513
+ lines.push(`**PMID:** ${a.pmid}`);
514
+ if (a.doi)
515
+ lines.push(`**DOI:** ${a.doi}`);
516
+ lines.push(`**PMC:** ${a.pmcUrl}`);
517
+ if (a.pubmedUrl)
518
+ lines.push(`**PubMed:** ${a.pubmedUrl}`);
519
+ if (a.keywords?.length)
520
+ lines.push(`**Keywords:** ${a.keywords.join(', ')}`);
521
+ if (a.abstract)
522
+ lines.push(`\n#### Abstract\n${a.abstract}`);
523
+ for (const sec of a.sections) {
524
+ if (sec.title)
525
+ lines.push(`\n#### ${formatHeading(sec.label, sec.title)}`);
526
+ if (sec.text)
527
+ lines.push(sec.text);
528
+ if (sec.subsections?.length) {
529
+ for (const sub of sec.subsections) {
530
+ if (sub.title)
531
+ lines.push(`\n##### ${formatHeading(sub.label, sub.title)}`);
532
+ if (sub.text)
533
+ lines.push(sub.text);
534
+ }
535
+ }
536
+ }
537
+ if (a.references?.length) {
538
+ lines.push(`\n#### References (${a.references.length})`);
539
+ for (const ref of a.references) {
540
+ const tag = [ref.label, ref.id].filter(Boolean).join(' ');
541
+ lines.push(`- ${tag ? `[${tag}] ` : ''}${ref.citation}`);
542
+ }
543
+ }
544
+ }
545
+ function formatUnpaywallArticle(a, lines) {
546
+ const heading = a.title ?? `PMID ${a.pmid}`;
547
+ const formatLabel = a.contentFormat === 'html-markdown'
548
+ ? 'Unpaywall (HTML → Markdown, best-effort)'
549
+ : 'Unpaywall (PDF → plain text)';
550
+ lines.push(`### ${heading}`);
551
+ lines.push(`**Source:** ${formatLabel}`);
552
+ lines.push(`**PMID:** ${a.pmid}`);
553
+ lines.push(`**DOI:** ${a.doi}`);
554
+ lines.push(`**PubMed:** ${a.pubmedUrl}`);
555
+ lines.push(`**OA Copy:** ${a.sourceUrl}`);
556
+ if (a.license)
557
+ lines.push(`**License:** ${a.license}`);
558
+ if (a.hostType)
559
+ lines.push(`**Host Type:** ${a.hostType}`);
560
+ if (a.version)
561
+ lines.push(`**Version:** ${a.version}`);
562
+ if (a.wordCount !== undefined)
563
+ lines.push(`**Word Count:** ${a.wordCount}`);
564
+ if (a.totalPages !== undefined)
565
+ lines.push(`**Pages:** ${a.totalPages}`);
566
+ lines.push(`\n> Section structure is not guaranteed for this source. Treat the content as best-effort raw text. OA location metadata courtesy of Unpaywall (https://unpaywall.org).`);
567
+ lines.push(`\n#### Full Text\n${a.content}`);
568
+ }
270
569
  function formatPmcAuthor(au) {
271
570
  const parts = [];
272
571
  if (au.collectiveName)