Connector: pubmed — PubMed via NCBI E-utilities
Tier 0 · category literature · code packages/connectors/src/connectors/pubmed/
Documentation verified 2026-09-08 (einfo: DbBuild Build-2026.09.07.18.09, 41.1 M records).
Source
| Organization | U.S. National Library of Medicine / NCBI |
| Base URL | https://eutils.ncbi.nlm.nih.gov/entrez/eutils |
| Auth | none; tool + email on every request (env NCBI_TOOL, NCBI_EMAIL); optional NCBI_API_KEY |
| Docs | E-utilities In-Depth https://www.ncbi.nlm.nih.gov/books/NBK25499/ · Usage guidelines https://www.ncbi.nlm.nih.gov/books/NBK25497/ |
| Terms | https://www.nlm.nih.gov/databases/download/terms_and_conditions.html · PubMed README https://ftp.ncbi.nlm.nih.gov/pubmed/README.txt |
Verified endpoints and parameters
esearch.fcgi—db=pubmed,term,rettype=count(count only) oruilist,retmax(≤ 10,000; "ESearch can only retrieve the first 10,000 records"),retmode=json,sort=pub_date(live check:sort=date→ "Unknown sort schema 'date' ignored"), date restriction eitherdatetype=pdat&mindate&maxdateor inside the term asYYYY/MM/DD:YYYY/MM/DD[dp](both verified to give identical counts). Response headers:x-ratelimit-limit: 3,x-ratelimit-remaining(logged by the connector).efetch.fcgi—db=pubmed,id=<comma list>,retmode=xml(PubmedArticleSet, DTDpubmed_250101); "If more than about 200 UIDs are to be provided, the request should be made using the HTTP POST method" — we send 50 per GET.einfo.fcgi?db=pubmed&retmode=json→dbbuild,lastupdate,count(health check).- MeSH UIs are not searchable (
D001943[mh]→ 0); descriptor names are ("Breast Neoplasms"[mh]→ 387,045).
Usage policy (verbatim, NBK25497)
"post no more than three URL requests per second" (without key) — with an API key "a site can post up to 10 requests per second by default. Higher rates are available by request"
"limit large jobs to either weekends or between 9:00 PM and 5:00 AM Eastern time during weekdays"
for extensive PubMed projects: "download a local copy of the database"
Policy limits are 3 req/s (no key) / 10 req/s (key), decided by effectiveRequestsPerSecond(). The
connector runs at 2.5 req/s (manifest) and switches to a dedicated 8.5 req/s client when
NCBI_API_KEY is set — 15 % headroom because token-bucket bursts at exactly 3 req/s produced 230 HTTP 429s
(all retried with Retry-After) in the first real run; concurrency 1. We deliberately do not mine the
4,290,331 neoplasms[mh] records through the API; that would require the annual baseline files.
Terms (verbatim)
PubMed README (Last Updated January 30, 2026):
"NLM freely provides PubMed data. Please note some abstracts may be protected by copyright."
Users of the data agree to: "acknowledge NLM as the source of the data in a clear and conspicuous manner", "NOT use the PubMed wordmark or the PubMed logo in association or in connection with user's or any other party's product or service", "NOT to indicate or imply that NLM/NIH/HHS has endorsed its products/services/applications."
Users who republish or redistribute the data agree to: "maintain the most current version of all distributed data, or make known in a clear and conspicuous manner that the products/services/applications do not reflect the most current/accurate data available from NLM."
NLM general terms: "acknowledge NLM as the source of the data by including the phrase 'Courtesy of the U.S. National Library of Medicine' in a clear and conspicuous manner".
What we store / redisplay. Bibliographic metadata (title, journal, dates, authors, MeSH, ids, publication
types) is stored and shown. Abstract text is stored in publications.abstract for indexing and
reconciliation only; the web layer must display at most the first ~300 characters followed by a link to
https://pubmed.ncbi.nlm.nih.gov/<pmid>/ (abstracts may be under publisher copyright). Every page showing
PubMed-derived numbers carries the attribution and the retrieval date (literature_counts.updated_at,
provenance retrieved_at). Manifest: licenseStatus approved, redistribution attribution.
Job (a): literature counts per cancer (literature_counts)
Research-activity metric (CLAUDE.md §31, §251). For every active, malignant canonical cancer with a defensible query:
Query construction (query.ts, the exact string is stored in literature_counts.query):
- MeSH-anchored when descriptor names are known for the cancer (
cancer_aliases.source_terminologymatchingmesh; a bare MeSH UI incancer_codesis not enough because[mh]needs names):("Breast Neoplasms"[mh]). - Otherwise title/abstract phrases: preferred name + abbreviation aliases + synonyms, each sanitized
(quotes/brackets removed), normalized length ≥ 4, not a generic label (
GENERIC_WORDS), not a qualified state (isQualifiedState), not a common word, distinct; at most 8 terms:("Pancreatic Ductal Adenocarcinoma"[tiab] OR "PDAC"[tiab]). - Cancers whose preferred name is a lone generic word ("Carcinoma", "Neoplasm") are skipped unless
top_level(8 of 798 on the OncoTree-only DB).
Windows (windowsFor(now), deterministic, tested with a fixed date; PubMed [dp] bounds):
| key | definition (now = 2026-09-08) |
|---|---|
all |
no date filter (window_end = retrieval day) |
12m |
2025/09/08:2026/09/08 |
5y |
last 5 full years + current year 2021/01/01:2026/12/31 |
5y_prior |
the 5 years before the last 12 months 2020/09/08:2025/09/07 (denominator of publication_growth) |
y2016 … y2025 |
the ten complete years before now |
Top tier (= top_level or curated NCIt anchor from TOP_LEVEL_CANCERS or entity_type in (cancer, cancer_family, hematologic_malignancy)) gets all 14 windows, refreshed when older than 7 days;
subtypes/other get all, 5y, 12m (3 requests) refreshed at most every 30 days. Targets are processed in a
deterministic order (top-level → top tier → rest, then id); the cursor counts.afterKey remembers the last
processed cancer so a run stopped by the time budget continues where it left off; a completed pass
resets it and increments counts.passes.
Each row: ON CONFLICT (cancer_id, window_key) DO UPDATE, with a fresh provenance row
(dataset PubMed, dataset_version = run date, evidence_type database, access_level open,
methodology esearch count; query stored on row, source_url = the esearch URL without the API key).
Feeds entity_counters.publication_count / _5y / _12m → rankings publications_5y, publications_12m,
publication_growth (= 12m / (5y_prior / 5)), research_gap.
Job (b): recent publications per top-level cancer (publications, publication_entity_edges)
Runs first in every run (bounded). For each top-level cancer with a query: esearch retmax=50 sort=pub_date
over the last 30 days → efetch XML in batches of 50 → parseEfetchXml() (fast-xml-parser; inline
markup in titles/abstracts stripped) → upsert publications on pmid (title, abstract, journal,
journalIso, pubDate/pubYear, publicationTypes, meshTerms {descriptor, ui, major, qualifiers}, authors
{name, affiliation, orcid}, language, doi/pmcid from ArticleIdList/ELocationID, nctIds = NCT\d{8}
in DataBankList + abstract + title, retracted = PublicationType "Retracted Publication" or
CommentsCorrections RefType=RetractionIn (notice kept in retraction_notice), isPreprint =
PublicationType "Preprint"). Source record ('article', pmid, parsedRecord). Cap 2,000 articles per run.
Edges (ON CONFLICT DO NOTHING on publication × entity × method):
| method | when | status | confidence |
|---|---|---|---|
mesh |
MeSH descriptor resolves via byCode('mesh', ui) or byLabel(descriptor, { allowMeshInversion: true }) |
validated |
0.95 major topic / 0.85 |
dictionary |
the query cancer's preferred name / query terms / abbreviations appear in the title (whole-word, case-insensitive, hyphen-tolerant) | candidate |
0.6 |
registry_reference |
an NCT id of the article exists in clinical_trials.nct_id (missing trials skipped) |
validated |
1.0 |
Restartability, health, credentials
- Cursor:
{ pubs: { lastRunAt, lastArticles }, counts: { afterKey, passStartedAt, lastPassCompletedAt, passes } }, persisted every 10 cancers and at the end. healthCheck→ einfoDbBuild/LastUpdate.credentialsMissing()→NCBI_EMAILis mandatory (the run is markedawaiting_credentialsotherwise).mode=dry_run: one esearch for the first top-level query + 3 cancers' counts logged, nothing written.
Tests (pubmed.test.ts, fixtures only)
Query builder (alias filtering, quotes/brackets escaping, generic skip, MeSH preference), window date math
with a fixed now, rate-limit choice (3 vs 10 rps by env) and key-free provenance URLs, esearch JSON
(count / ids / empty), efetch XML sample of 3 articles (one retracted with an NCT id in DataBankList and a
MeSH major topic, one un-indexed with a DOI only, one retracted Nature article), title dictionary match.
Limitations
- Title/abstract phrase queries over-count when a shorter name is contained in a longer one ("Hodgkin Lymphoma"[tiab] also matches "non-Hodgkin lymphoma": 26,033 vs 17,046 in the dry run) and under-count cancers known mainly by other phrasings. MeSH-anchored queries fix this as soon as MeSH descriptor names are attached to cancers by a terminology connector — the query kind is visible in the stored query string.
- With the OncoTree-only ontology only 4 top-level cancers are recognised (via curated NCIt anchors), so
job (b) is small until NCIt lands; in production the
top_levelflag drives it. - Counts are PubMed's own (
esearchresult.count) and reflect indexing lag;12mis a moving window. - No rate-limit key in
.envyet → 3 req/s; a full top-tier pass (≈ 220 cancers × 14 windows ≈ 3,000 requests) takes ≈ 25–40 min at the observed ≈ 1.3 req/s effective (network latency, concurrency 1).
Run report (cancerindex_b, 2026-09-08, OncoTree-only ontology)
--mode dry_run: 57 requests / 34 s; 798 targets (top-level 4 via curated NCIt anchors, top tier 217, 8 without a defensible query); counts logged for 3 cancers, one recent-publications esearch (50 PMIDs).--max-minutes 10(ING-PUBMED-20260908-000002, statuspartial, cursor saved atcounts.afterKey = 1|CI-CAN-00000049): 937 requests, 230 HTTP 429s (all retried successfully — the reason the keyless rate was lowered to 2.5 req/s afterwards), 823 records fetched/created:- job (b): 123
publicationsupserted (0 retracted, 4 with NCT ids), 62publication_entity_edges(56dictionary/candidate, 6registry_reference/validated, 0mesh— recent articles are not MeSH-indexed yet and OncoTree has no MeSH names); - job (a): 50 cancers × 14 windows = 700
literature_countsrows with 700provenancerows (e.g. Cutaneous Melanomaall10,831 /12m770 /5y3,930 /5y_prior3,459; Hodgkin Lymphomaall26,033).
- job (b): 123
pnpm cix rank→publications_5yeligible 41,publications_12m39,publication_growth31 (level=all). Top publications_5y: Hepatocellular Carcinoma 56,139 · Osteosarcoma 9,748 · Hodgkin Lymphoma 8,354 · Head and Neck Squamous Cell Carcinoma 7,917 · Nasopharyngeal Carcinoma 6,142.
Estimated full first pass at 2.5 req/s without a key: top tier 217 × 14 ≈ 3,000 requests ≈ 25 min, then
≈ 580 subtypes × 3 ≈ 1,750 requests ≈ 15 min, spread over nightly runs by the cursor; with NCBI_API_KEY
about 3× faster.