@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.
- package/README.md +11 -8
- package/dist/config/server-config.d.ts +2 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +11 -0
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts +45 -13
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js +434 -135
- package/dist/mcp-server/tools/definitions/fetch-fulltext.tool.js.map +1 -1
- package/dist/services/unpaywall/types.d.ts +55 -0
- package/dist/services/unpaywall/types.d.ts.map +1 -0
- package/dist/services/unpaywall/types.js +7 -0
- package/dist/services/unpaywall/types.js.map +1 -0
- package/dist/services/unpaywall/unpaywall-service.d.ts +42 -0
- package/dist/services/unpaywall/unpaywall-service.d.ts.map +1 -0
- package/dist/services/unpaywall/unpaywall-service.js +165 -0
- package/dist/services/unpaywall/unpaywall-service.js.map +1 -0
- package/package.json +5 -2
- package/server.json +15 -3
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview
|
|
3
|
-
*
|
|
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
|
-
// ───
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
|
112
|
-
includeReferences: z
|
|
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(
|
|
181
|
+
articles: z.array(ArticleSchema).describe('Full-text articles'),
|
|
127
182
|
totalReturned: z.number().describe('Number of articles returned'),
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
142
|
-
let
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
...(
|
|
189
|
-
...(
|
|
322
|
+
...(unavailable.length > 0 && { unavailable }),
|
|
323
|
+
...(unavailablePmcIds && { unavailablePmcIds }),
|
|
190
324
|
};
|
|
191
325
|
},
|
|
192
326
|
format: (result) => {
|
|
193
|
-
const lines = [`##
|
|
194
|
-
if (result.
|
|
195
|
-
lines.push(
|
|
196
|
-
|
|
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(
|
|
200
|
-
if (a.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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)
|