Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

326 lines
13 KiB
JavaScript

import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
export const EUTILS_BASE = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
export const SEARCH_COLUMNS = ['rank', 'pmid', 'title', 'authors', 'journal', 'year', 'article_type', 'doi', 'url'];
export const LINK_COLUMNS = ['rank', 'pmid', 'title', 'authors', 'journal', 'year', 'article_type', 'doi', 'url'];
export const RELATED_COLUMNS = ['rank', 'pmid', 'title', 'authors', 'journal', 'year', 'article_type', 'score', 'doi', 'url'];
let lastRequestAt = 0;
export function requireText(value, label) {
const text = String(value ?? '').trim();
if (!text) {
throw new ArgumentError(`pubmed ${label} cannot be empty`);
}
return text;
}
export function requirePmid(value, label = 'pmid') {
const pmid = requireText(value, label);
if (!/^\d+$/.test(pmid)) {
throw new ArgumentError(`pubmed ${label} must be a numeric PMID`, 'Example: 37780221');
}
return pmid;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const text = String(raw).trim();
if (!/^\d+$/.test(text)) {
throw new ArgumentError(`pubmed ${label} must be a positive integer`);
}
const n = Number(text);
if (!Number.isSafeInteger(n) || n < 1) {
throw new ArgumentError(`pubmed ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`pubmed ${label} must be <= ${maxValue}`);
}
return n;
}
export function requireYear(value, label) {
if (value === undefined || value === null || value === '') {
return undefined;
}
const year = requireBoundedInt(value, 1900, 3000, label);
if (year < 1800) {
throw new ArgumentError(`pubmed ${label} must be >= 1800`);
}
return year;
}
export function requireChoice(value, choices, label, defaultValue) {
const text = String(value ?? defaultValue).trim();
if (!choices.includes(text)) {
throw new ArgumentError(`pubmed ${label} must be one of: ${choices.join(', ')}`);
}
return text;
}
export function buildEutilsUrl(tool, params = {}) {
const searchParams = new URLSearchParams();
searchParams.set('db', 'pubmed');
if (!params.retmode) {
searchParams.set('retmode', 'json');
}
if (process.env.NCBI_API_KEY) {
searchParams.set('api_key', process.env.NCBI_API_KEY);
}
if (process.env.NCBI_EMAIL) {
searchParams.set('email', process.env.NCBI_EMAIL);
}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
searchParams.set(key, String(value));
}
}
return `${EUTILS_BASE}/${tool}.fcgi?${searchParams.toString()}`;
}
async function waitForRateLimit() {
if (process.env.NODE_ENV === 'test') {
return;
}
const delayMs = process.env.NCBI_API_KEY ? 110 : 360;
const now = Date.now();
const waitMs = Math.max(0, lastRequestAt + delayMs - now);
if (waitMs > 0) {
await new Promise(resolve => setTimeout(resolve, waitMs));
}
lastRequestAt = Date.now();
}
export async function eutilsFetch(tool, params = {}, { retmode = 'json', label = 'PubMed E-utilities' } = {}) {
const url = buildEutilsUrl(tool, { ...params, retmode });
await waitForRateLimit();
let response;
try {
response = await fetch(url);
}
catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new CommandExecutionError(`${label} request failed`, detail);
}
if (!response.ok) {
throw new CommandExecutionError(`${label} HTTP ${response.status}`, 'Check NCBI availability, request parameters, and optional NCBI_API_KEY.');
}
if (retmode === 'xml') {
return response.text();
}
try {
const json = await response.json();
assertNoEutilsError(json, label);
return json;
}
catch (error) {
if (error instanceof CommandExecutionError) {
throw error;
}
const detail = error instanceof Error ? error.message : String(error);
throw new CommandExecutionError(`${label} returned invalid JSON`, detail);
}
}
export function assertNoEutilsError(json, label = 'PubMed E-utilities') {
const error = json?.error
|| json?.esearchresult?.errorlist?.phrasesnotfound?.join(', ')
|| json?.esearchresult?.errorlist?.fieldsnotfound?.join(', ');
if (error) {
throw new CommandExecutionError(`${label} returned an error`, String(error));
}
}
export function buildPubMedUrl(pmid) {
return `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`;
}
export function decodeXmlEntities(value) {
return String(value ?? '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10)));
}
export function cleanText(value) {
return decodeXmlEntities(value).replace(/\s+/g, ' ').trim();
}
export function truncateText(value, maxLength) {
const text = cleanText(value);
if (!text || text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength - 3)}...`;
}
export function extractFirst(xml, tag) {
const match = String(xml ?? '').match(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'));
return match ? cleanText(match[1].replace(/<[^>]+>/g, ' ')) : '';
}
export function extractAll(xml, tag) {
const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'gi');
const out = [];
let match;
while ((match = re.exec(String(xml ?? ''))) !== null) {
out.push(cleanText(match[1].replace(/<[^>]+>/g, ' ')));
}
return out;
}
export function extractAttribute(xml, tag, attr) {
const match = String(xml ?? '').match(new RegExp(`<${tag}\\b[^>]*\\b${attr}="([^"]*)"`, 'i'));
return match ? decodeXmlEntities(match[1]) : '';
}
export function extractAuthors(authorList, maxAuthors = 3) {
if (!Array.isArray(authorList) || authorList.length === 0) {
return '';
}
const names = authorList.map(author => author?.name || author?.collectivename || [author?.lastname, author?.initials].filter(Boolean).join(' ')).filter(Boolean);
const shown = names.slice(0, maxAuthors);
if (names.length > maxAuthors) {
shown.push('et al.');
}
return shown.join(', ');
}
export function extractDoi(articleIds) {
if (!Array.isArray(articleIds)) {
return '';
}
const doi = articleIds.find(id => String(id?.idtype ?? '').toLowerCase() === 'doi');
return String(doi?.value ?? '').trim();
}
export function articleTypeFromList(types) {
const values = Array.isArray(types)
? types.map(type => typeof type === 'string' ? type : type?.value).filter(Boolean)
: [];
const priority = ['Systematic Review', 'Meta-Analysis', 'Review', 'Randomized Controlled Trial', 'Clinical Trial', 'Case Reports', 'Journal Article'];
for (const wanted of priority) {
const found = values.find(type => type.toLowerCase() === wanted.toLowerCase());
if (found) {
return found;
}
}
return values[0] || 'Journal Article';
}
export function summaryToRow(article, rank, pmid = article?.uid) {
const id = String(pmid ?? article?.uid ?? '').trim();
return {
rank,
pmid: id,
title: truncateText(String(article?.title ?? '').replace(/\.$/, ''), 120),
authors: extractAuthors(article?.authors, 3),
journal: truncateText(article?.fulljournalname || article?.source || '', 60),
year: String(article?.pubdate ?? '').split(' ')[0] || '',
article_type: articleTypeFromList(article?.pubtype),
doi: extractDoi(article?.articleids),
url: buildPubMedUrl(id),
};
}
export function ensureCompleteSummaryRows(pmids, result, commandLabel) {
if (!result || typeof result !== 'object' || !result.result || typeof result.result !== 'object') {
throw new CommandExecutionError(`${commandLabel} returned an unreadable summary payload`);
}
const rows = pmids.map((pmid, index) => {
const article = result.result[pmid];
if (!article) {
return null;
}
return summaryToRow(article, index + 1, pmid);
});
if (rows.some(row => row === null)) {
throw new CommandExecutionError(`${commandLabel} omitted summaries for one or more PMIDs`, 'Refusing to return a partial result set.');
}
return rows;
}
export function buildSearchQuery(query, filters = {}) {
const terms = [requireText(query, 'query')];
if (filters.author) terms.push(`${requireText(filters.author, 'author')}[Author]`);
if (filters.journal) terms.push(`${requireText(filters.journal, 'journal')}[Journal]`);
if (filters.yearFrom || filters.yearTo) {
const from = filters.yearFrom || 1800;
const to = filters.yearTo || new Date().getFullYear();
if (from > to) {
throw new ArgumentError('pubmed year-from must be <= year-to');
}
terms.push(`${from}:${to}[PDAT]`);
}
if (filters.articleType) terms.push(`${requireText(filters.articleType, 'article-type')}[PT]`);
if (filters.hasAbstract) terms.push('hasabstract[text]');
if (filters.hasFullText) terms.push('free full text[sb]');
if (filters.humanOnly) terms.push('humans[mesh]');
if (filters.englishOnly) terms.push('english[lang]');
return terms.join(' AND ');
}
export function parseArticleXml(xml, pmid) {
const text = String(xml ?? '');
if (!text || /<ERROR\b/i.test(text) || !/<PubmedArticle\b/i.test(text)) {
return null;
}
const returnedPmid = extractFirst(text, 'PMID');
if (!returnedPmid) {
throw new CommandExecutionError('pubmed article response did not include a PMID', 'PubMed EFetch response shape may have changed.');
}
if (returnedPmid !== pmid) {
throw new CommandExecutionError(`pubmed article response PMID ${returnedPmid} did not match requested PMID ${pmid}`, 'Refusing to return metadata for a different article.');
}
const articleBlock = text.match(/<Article\b[^>]*>([\s\S]*?)<\/Article>/i)?.[1] || text;
const journalBlock = articleBlock.match(/<Journal\b[^>]*>([\s\S]*?)<\/Journal>/i)?.[1] || '';
const journalIssue = journalBlock.match(/<JournalIssue\b[^>]*>([\s\S]*?)<\/JournalIssue>/i)?.[1] || '';
const pubDate = journalIssue.match(/<PubDate\b[^>]*>([\s\S]*?)<\/PubDate>/i)?.[1] || '';
const authorBlocks = [...text.matchAll(/<Author\b[^>]*>([\s\S]*?)<\/Author>/gi)].map(match => match[1]);
const authors = authorBlocks.map(block => {
const name = extractFirst(block, 'CollectiveName') || [extractFirst(block, 'LastName'), extractFirst(block, 'ForeName') || extractFirst(block, 'Initials')].filter(Boolean).join(' ');
return name;
}).filter(Boolean);
const abstract = extractAll(articleBlock, 'AbstractText').join(' ');
const pubTypes = extractAll(articleBlock, 'PublicationType');
const meshTerms = extractAll(text, 'DescriptorName');
const keywords = extractAll(text, 'Keyword');
const affiliations = extractAll(text, 'Affiliation');
const grantBlocks = [...text.matchAll(/<Grant\b[^>]*>([\s\S]*?)<\/Grant>/gi)].map(match => match[1]);
const grants = grantBlocks.map(block => {
const grantId = extractFirst(block, 'GrantID');
const agency = extractFirst(block, 'Agency');
return [grantId, agency].filter(Boolean).join(': ');
}).filter(Boolean);
const doi = text.match(/<ArticleId\b[^>]*IdType="doi"[^>]*>([\s\S]*?)<\/ArticleId>/i)?.[1] || '';
const pmc = text.match(/<ArticleId\b[^>]*IdType="pmc"[^>]*>([\s\S]*?)<\/ArticleId>/i)?.[1] || '';
return {
pmid,
title: extractFirst(articleBlock, 'ArticleTitle'),
abstract,
authors,
journal: extractFirst(journalBlock, 'Title') || extractFirst(journalBlock, 'ISOAbbreviation'),
year: extractFirst(pubDate, 'Year') || extractFirst(text, 'MedlineDate').slice(0, 4),
date: [extractFirst(pubDate, 'Year'), extractFirst(pubDate, 'Month'), extractFirst(pubDate, 'Day')].filter(Boolean).join(' '),
doi: cleanText(doi),
pmc: cleanText(pmc),
article_type: articleTypeFromList(pubTypes),
language: extractFirst(articleBlock, 'Language'),
affiliations: affiliations.slice(0, 10).join(' | '),
grants: grants.slice(0, 10).join(' | '),
mesh_terms: meshTerms.slice(0, 10).join(', '),
keywords: keywords.slice(0, 10).join(', '),
url: buildPubMedUrl(pmid),
};
}
export async function fetchSummaryRows(pmids, commandLabel) {
const result = await eutilsFetch('esummary', { id: pmids.join(',') }, { label: commandLabel });
return ensureCompleteSummaryRows(pmids, result, commandLabel);
}