fetchpdf 0.1.0__tar.gz → 0.1.4__tar.gz
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.
- fetchpdf-0.1.4/PKG-INFO +1477 -0
- fetchpdf-0.1.4/README.md +1438 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/__init__.py +1 -1
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/__main__.py +3 -1
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/_env.py +18 -8
- fetchpdf-0.1.4/fetchpdf/_http.py +101 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/fetchpdf.py +1524 -435
- fetchpdf-0.1.4/fetchpdf/get_cookies.py +760 -0
- fetchpdf-0.1.4/fetchpdf/retrieval/blocked.py +97 -0
- fetchpdf-0.1.4/fetchpdf/retrieval/byline.py +257 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/chunk.py +16 -3
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/engine.py +26 -5
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/extract_images.py +12 -6
- fetchpdf-0.1.4/fetchpdf/retrieval/figures.py +829 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/http.py +11 -2
- fetchpdf-0.1.4/fetchpdf/retrieval/institutional.py +743 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/ladder.json +38 -2
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/llm_agent_retrieval.py +94 -13
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/normalize.py +144 -29
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/pdf_identity.py +106 -2
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/pdf_text.py +24 -6
- fetchpdf-0.1.4/fetchpdf/retrieval/pubpeer.py +279 -0
- fetchpdf-0.1.4/fetchpdf/retrieval/references.py +280 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/repository_waf.py +6 -2
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/request_drafts.py +2 -2
- fetchpdf-0.1.4/fetchpdf/retrieval/sources/epmc_repo.py +162 -0
- fetchpdf-0.1.4/fetchpdf/retrieval/sources/pmc_html.py +163 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_graph.py +27 -19
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_index.py +28 -14
- fetchpdf-0.1.4/fetchpdf/retrieval/supplement_jci.py +49 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplementary.py +65 -19
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/tiers.py +22 -1
- fetchpdf-0.1.4/fetchpdf/retrieval/to_html.py +656 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/to_markdown.py +465 -88
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/validate.py +100 -11
- fetchpdf-0.1.4/fetchpdf.egg-info/PKG-INFO +1477 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf.egg-info/SOURCES.txt +33 -1
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf.egg-info/entry_points.txt +2 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf.egg-info/requires.txt +10 -10
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/pyproject.toml +34 -20
- fetchpdf-0.1.4/tests/test_blocked.py +165 -0
- fetchpdf-0.1.4/tests/test_cookie_offer.py +175 -0
- fetchpdf-0.1.4/tests/test_datacite_supplement_identity.py +30 -0
- fetchpdf-0.1.4/tests/test_entrypoints.py +27 -0
- fetchpdf-0.1.4/tests/test_epmc_repo.py +281 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_extract_images.py +2 -2
- fetchpdf-0.1.4/tests/test_figures.py +617 -0
- fetchpdf-0.1.4/tests/test_get_cookies.py +104 -0
- fetchpdf-0.1.4/tests/test_institutional.py +348 -0
- fetchpdf-0.1.4/tests/test_jci_supplements.py +38 -0
- fetchpdf-0.1.4/tests/test_json_output.py +272 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_llm_agent_retrieval.py +82 -3
- fetchpdf-0.1.4/tests/test_normalize_cells.py +88 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_pdf_identity.py +59 -1
- fetchpdf-0.1.4/tests/test_pdf_trailer.py +76 -0
- fetchpdf-0.1.4/tests/test_pmc_page.py +232 -0
- fetchpdf-0.1.4/tests/test_polite_ua.py +121 -0
- fetchpdf-0.1.4/tests/test_pubmed_identity.py +50 -0
- fetchpdf-0.1.4/tests/test_pubpeer.py +183 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_pull_for_record_call_sites.py +25 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_pure.py +182 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_retrieval.py +408 -4
- fetchpdf-0.1.4/tests/test_screening_conversion.py +42 -0
- fetchpdf-0.1.4/tests/test_serpapi_scholar.py +238 -0
- fetchpdf-0.1.4/tests/test_short_conference_xml.py +118 -0
- fetchpdf-0.1.4/tests/test_supplement_asset_identity.py +31 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_supplementary.py +162 -30
- fetchpdf-0.1.4/tests/test_to_html.py +352 -0
- fetchpdf-0.1.0/PKG-INFO +0 -1392
- fetchpdf-0.1.0/README.md +0 -1353
- fetchpdf-0.1.0/fetchpdf/_http.py +0 -40
- fetchpdf-0.1.0/fetchpdf.egg-info/PKG-INFO +0 -1392
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/LICENSE +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/MANIFEST.in +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/fetch_abstract_from_doi.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/fetch_metadata_from_doi.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/__init__.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/_util.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/artifact.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/backends/__init__.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/backends/claude_cli.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/backends/openrouter.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/cache.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/classify.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/context.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/corresponding.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/fulltext_scan.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/goals.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/identifiers.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/linked_artifacts.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/llm_adjudicate.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/provenance.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/ratelimit.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/resolve.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/__init__.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/arxiv.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/core_text.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/crossref_tdm.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/epmc.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/landing.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/legacy_pdf.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/pmc.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/preprint.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/publisher_html.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/sources/supplements.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_atypon.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_dspace.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_pmc.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/supplement_publishers.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf/retrieval/verify.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf.egg-info/dependency_links.txt +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/fetchpdf.egg-info/top_level.txt +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/requirements.txt +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/setup.cfg +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_agent_backends.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_corresponding.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_dspace.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_folder_encoding.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_fulltext_scan.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_landing_candidates.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_landing_page_detection.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_last_resort_reachability.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_linked_artifacts.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_llm_adjudicate.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_request_drafts.py +0 -0
- {fetchpdf-0.1.0 → fetchpdf-0.1.4}/tests/test_scan_record.py +0 -0
fetchpdf-0.1.4/PKG-INFO
ADDED
|
@@ -0,0 +1,1477 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fetchpdf
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: A comprehensive tool to download PDFs from DOIs using multiple sources
|
|
5
|
+
Author-email: Dan Elton <delton17@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/The-Metascience-Observatory/fetchpdf
|
|
8
|
+
Project-URL: Repository, https://github.com/The-Metascience-Observatory/fetchpdf
|
|
9
|
+
Project-URL: Issues, https://github.com/The-Metascience-Observatory/fetchpdf/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests>=2.31.0
|
|
22
|
+
Requires-Dist: urllib3>=1.26
|
|
23
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
24
|
+
Requires-Dist: browser_cookie3>=0.19
|
|
25
|
+
Requires-Dist: pypdf>=4.0
|
|
26
|
+
Requires-Dist: lxml>=4.9
|
|
27
|
+
Provides-Extra: browser
|
|
28
|
+
Requires-Dist: playwright>=1.40.0; extra == "browser"
|
|
29
|
+
Provides-Extra: better-text-images
|
|
30
|
+
Requires-Dist: pymupdf>=1.24; extra == "better-text-images"
|
|
31
|
+
Provides-Extra: render
|
|
32
|
+
Requires-Dist: markdown-it-py>=3.0; extra == "render"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-xdist>=3.0; extra == "dev"
|
|
36
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# fetchpdf
|
|
41
|
+
|
|
42
|
+
A comprehensive Python package to download academic papers (PDFs) from DOIs (or PMIDs resolved to DOIs) using multiple fallback sources.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## Table of Contents
|
|
46
|
+
|
|
47
|
+
- [Features](#features)
|
|
48
|
+
- [Installation](#installation)
|
|
49
|
+
- [Quick Start](#quick-start)
|
|
50
|
+
- [Getting more than the PDF](#getting-more-than-the-pdf) — XML/HTML, supplementary material, linked datasets
|
|
51
|
+
- [PMID Support](#pmid-support)
|
|
52
|
+
- [Other commands](#other-commands)
|
|
53
|
+
- [Usage Examples](#usage-examples)
|
|
54
|
+
- [Batch Processing & Parallel Execution](#batch-processing--parallel-execution)
|
|
55
|
+
- [Format-Prioritized Retrieval](#format-prioritized-retrieval)
|
|
56
|
+
- [Supplementary Material](#supplementary-material)
|
|
57
|
+
- [`--pull-everything`: the second layer](#--pull-everything-the-second-layer)
|
|
58
|
+
- [Figure Images](#figure-images)
|
|
59
|
+
- [Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions) — papers your library subscribes to (`fetchpdf fetchcookies`)
|
|
60
|
+
- [Calling fetchpdf from a script or an agent](#calling-fetchpdf-from-a-script-or-an-agent) — `--json`
|
|
61
|
+
- [API Reference](#api-reference)
|
|
62
|
+
- [Download Sources](#download-sources-in-order-of-priority)
|
|
63
|
+
- [Requirements](#requirements)
|
|
64
|
+
- [Troubleshooting](#troubleshooting)
|
|
65
|
+
- [Ethical Considerations](#ethical-considerations)
|
|
66
|
+
|
|
67
|
+
## Features
|
|
68
|
+
|
|
69
|
+
- 🔍 **Multiple Sources**: Automatically tries a chain of sources in order ([full list](#download-sources-in-order-of-priority)), including:
|
|
70
|
+
- OSF (Open Science Framework) - Projects & Preprints
|
|
71
|
+
- PubMed Central / Europe PMC
|
|
72
|
+
- Unpaywall
|
|
73
|
+
- Crossref
|
|
74
|
+
- Semantic Scholar
|
|
75
|
+
- OpenAlex
|
|
76
|
+
- CORE
|
|
77
|
+
- Figshare
|
|
78
|
+
- SSRN (Social Science Research Network)
|
|
79
|
+
- Google Scholar via SerpApi (optional)
|
|
80
|
+
|
|
81
|
+
- 🔄 **Smart Fallback**: If one source fails, automatically tries the next
|
|
82
|
+
- 🧬 **[Format Prioritization](#format-prioritized-retrieval)**: `--prioritize-xml` prefers structured full text (JATS/TEI) over PDF
|
|
83
|
+
- 📎 **[Supplementary Material](#supplementary-material)**: `--pull-supplementary` fetches every supplementary file alongside the paper
|
|
84
|
+
- 🖼️ **[Figure Images](#figure-images)**: `--pull-figures` fetches the article's published figures from PMC (or the Elsevier object API) with a manifest of labels, captions and hashes
|
|
85
|
+
- 🖨️ **[XML to HTML / PDF](#rendering-the-xml-as-html-or-pdf)**: `--generate-html-from-xml` / `--generate-pdf-from-xml` render a retrieved XML as one self-contained page, figures and tables embedded
|
|
86
|
+
- 🔗 **[Linked Datasets & Code](#linked-datasets-and-code-stem_linked_artifactsjson)**: the same pass discovers each paper's external datasets/software via ScholeXplorer, Europe PMC and DataCite, downloads ownership-confirmed deposits from figshare/Zenodo/OSF/Dryad/Dataverse, and records every link in a sidecar
|
|
87
|
+
- 🤖 **[LLM-assisted retrieval](#--pull-everything-the-second-layer)**: `--pull-everything` adds a second layer — a model reads the paper itself and goes after the SI and datasets the APIs missed
|
|
88
|
+
- 🔐 **[Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions)** (opt-in): a one-time `fetchpdf fetchcookies` lets fetchpdf download papers your library subscribes to
|
|
89
|
+
- 🚀 **Batch Processing**: Process multiple DOIs/PMIDs from CSV files or lists
|
|
90
|
+
- ⚡ **Parallel Execution**: Download multiple papers simultaneously with configurable workers
|
|
91
|
+
|
|
92
|
+
## Installation
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install fetchpdf
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or, to work from source:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
git clone https://github.com/The-Metascience-Observatory/fetchpdf.git
|
|
102
|
+
cd fetchpdf
|
|
103
|
+
pip install -e .
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Optional: Install Playwright and Chromium for Browser Automation
|
|
107
|
+
|
|
108
|
+
Install Playwright and Chromium only if you want to try additional scrapers enabled by `--add-playwright`. The scapers are for SSRN, OSF (if direct download fails), Wiley 10.1111/ DOIs, HTML table retrieval (second route), and Atypon supplementary material (PNAS, Science, SAGE, T&F, Annual Reviews).
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install -e ".[browser]" # or: pip install playwright
|
|
112
|
+
playwright install chromium
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
In practice this is not very useful.
|
|
116
|
+
|
|
117
|
+
### Optional extras
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install 'fetchpdf[better-text-images]' # PyMuPDF: a faster PDF text engine (pypdf is always installed), plus --extract-images and fetchpdf-images
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Configuration: `.env.local`
|
|
124
|
+
|
|
125
|
+
Create a `.env.local` file to configure API keys and settings. For a source
|
|
126
|
+
checkout, put it in the repository root; for a `pip install`, put it in (or
|
|
127
|
+
above) the directory you run `fetchpdf` from. Plain environment variables work
|
|
128
|
+
too. API keys are **optional** but improve rate limits and reliability:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Required — used by Unpaywall (mandatory) and Crossref's polite pool
|
|
132
|
+
EMAIL=your@email.com
|
|
133
|
+
|
|
134
|
+
# Optional — improves rate limits / avoids throttling
|
|
135
|
+
OPENALEXAPIKEY=your_openalex_key # OpenAlex: fewer 429 rate-limit errors
|
|
136
|
+
SEMANTIC_SCHOLAR_API_KEY=your_s2_key # Semantic Scholar: 1 → 10 req/s
|
|
137
|
+
ENTREZ_EUTILS_API_KEY=your_ncbi_key # NCBI E-utilities: 3 → 10 req/s
|
|
138
|
+
COREAPIKEY=your_core_key # CORE: 40M+ OA papers from core.ac.uk
|
|
139
|
+
SERPAPI_API_KEY=your_serpapi_key # Optional Google Scholar PDF fallback
|
|
140
|
+
|
|
141
|
+
# Optional — publisher-specific
|
|
142
|
+
ELSEVIER_TDM_API_KEY=your_elsevier_key # Elsevier text/data-mining access
|
|
143
|
+
SCOPUS_API_KEY=your_scopus_key # Scopus abstract lookup during identifier resolution
|
|
144
|
+
|
|
145
|
+
# Optional — only for --pull-everything --llm-backend openrouter
|
|
146
|
+
OPENROUTER_API_KEY=your_openrouter_key # the retrieval agent's second backend
|
|
147
|
+
|
|
148
|
+
# Required by fetchpdf-pubpeer — PubPeer rejects keyless requests
|
|
149
|
+
PUBPEER_DEVKEY=your_pubpeer_key
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**What the keys change** (the rates are fetchpdf's own per-host throttles, set in
|
|
153
|
+
[`ladder.json`](fetchpdf/retrieval/ladder.json)):
|
|
154
|
+
|
|
155
|
+
| API | Without Key | With Key | How to Get |
|
|
156
|
+
|-----|-------------|----------|------------|
|
|
157
|
+
| Crossref | 10 req/s | 10 req/s; `EMAIL` puts requests in Crossref's polite pool | Just set `EMAIL` |
|
|
158
|
+
| OpenAlex | 5 req/s, more 429s | 5 req/s | [openalex.org/users](https://openalex.org/users) |
|
|
159
|
+
| Semantic Scholar | 1 req/s | 10 req/s | [semanticscholar.org/product/api](https://www.semanticscholar.org/product/api) |
|
|
160
|
+
| NCBI E-utilities | 3 req/s | 10 req/s | [ncbi.nlm.nih.gov/account](https://www.ncbi.nlm.nih.gov/account/) |
|
|
161
|
+
| CORE | Skipped | 2 req/s, 500 requests/day quota | [core.ac.uk/services/api](https://core.ac.uk/services/api) |
|
|
162
|
+
| SerpApi Google Scholar | Skipped | Account search quota | [serpapi.com/google-scholar-api](https://serpapi.com/google-scholar-api) |
|
|
163
|
+
|
|
164
|
+
## Quick Start
|
|
165
|
+
|
|
166
|
+
### Command Line Interface
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Batch download from CSV (auto-detected by .csv extension)
|
|
170
|
+
fetchpdf papers.csv -o ./pdfs
|
|
171
|
+
fetchpdf papers.csv -o ./pdfs -w 4 # 4 parallel workers
|
|
172
|
+
|
|
173
|
+
# Single DOI or PMID
|
|
174
|
+
fetchpdf 10.1038/nature12373 # saves to ./pdfs/
|
|
175
|
+
fetchpdf 10.1038/nature12373 -o ./papers # custom output dir
|
|
176
|
+
fetchpdf 10.1038/nature12373 output.pdf # custom filename
|
|
177
|
+
fetchpdf 33262244 # PMID auto-resolved to DOI
|
|
178
|
+
|
|
179
|
+
# Comma-separated list of DOIs/PMIDs: a batch, 8 parallel workers by default
|
|
180
|
+
fetchpdf "10.1038/nature12373,10.1126/science.1241224,33262244" -o ./pdfs
|
|
181
|
+
fetchpdf "10.1038/nature12373,10.1126/science.1241224" -w 2 # override workers
|
|
182
|
+
fetchpdf 10.1038/nature12373, 10.1126/science.1241224 # unquoted works too...
|
|
183
|
+
fetchpdf '10.1002/(SICI)1521-3773(19981116)37:21<2922::AID-ANIE2922>3.0.CO;2-1, 10.1021/ar9900820'
|
|
184
|
+
# ...but single-quote any list holding ( ) < > ; -- the shell would read them as syntax
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**At a university?** Run `fetchpdf fetchcookies` once and fetchpdf will also fetch
|
|
188
|
+
papers your library subscribes to. See
|
|
189
|
+
[Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions).
|
|
190
|
+
|
|
191
|
+
### Getting more than the PDF
|
|
192
|
+
|
|
193
|
+
By default a record yields one file: the best PDF the source chain finds. Three
|
|
194
|
+
flags widen that.
|
|
195
|
+
|
|
196
|
+
1. **Structured full text** — `--get-xml-or-html` keeps a JATS/TEI XML (else
|
|
197
|
+
publisher HTML) copy alongside the PDF; `--to-markdown` also renders it to
|
|
198
|
+
`{stem}_from_xml.md` (or `{stem}_from_html.md`). XML and HTML are great
|
|
199
|
+
formats for using with AI (LLMs): complex tables usually survive better than
|
|
200
|
+
in a PDF converted to Markdown, though a
|
|
201
|
+
PDF can win when tables are published as images. Stricter variants and the
|
|
202
|
+
format ladder: [Format-Prioritized Retrieval](#format-prioritized-retrieval).
|
|
203
|
+
2. **Supplementary material (SI/SM)** — `--pull-supplementary` saves every
|
|
204
|
+
supplementary file as numbered siblings with a manifest recording what each
|
|
205
|
+
number is. See [Supplementary Material](#supplementary-material).
|
|
206
|
+
3. **Linked datasets and code** — the supplementary pass also records every
|
|
207
|
+
dataset/software link in `{stem}_linked_artifacts.json` and downloads deposits
|
|
208
|
+
identified as the paper's own into `{stem}_data_artifacts/`;
|
|
209
|
+
`--download-data-artifacts` widens that. See
|
|
210
|
+
[Linked datasets and code](#linked-datasets-and-code-stem_linked_artifactsjson).
|
|
211
|
+
|
|
212
|
+
**All three at once**, which is the usual corpus-building invocation:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
fetchpdf papers.csv -o ./out -w 4 \
|
|
216
|
+
--get-xml-or-html --to-markdown \
|
|
217
|
+
--download-data-artifacts \
|
|
218
|
+
--make-subfolder --provenance
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
out/10.1371--journal.pone.0000308/
|
|
223
|
+
10.1371--journal.pone.0000308.pdf <- the paper
|
|
224
|
+
10.1371--journal.pone.0000308.xml <- structured full text
|
|
225
|
+
10.1371--journal.pone.0000308_from_xml.md <- prose + HTML tables
|
|
226
|
+
10.1371--journal.pone.0000308_supplementary_info_1.xls
|
|
227
|
+
10.1371--journal.pone.0000308_supplementary_info_2.doc
|
|
228
|
+
10.1371--journal.pone.0000308_supplementary_info.json <- what each number is
|
|
229
|
+
10.1371--journal.pone.0000308_data_artifacts/ <- linked deposits, original filenames
|
|
230
|
+
10.5061_dryad.xxxxx/data.csv (one folder per deposit)
|
|
231
|
+
10.1371--journal.pone.0000308_linked_artifacts.json <- every dataset/code link
|
|
232
|
+
10.1371--journal.pone.0000308.provenance.json <- source, tier, hashes
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Supplements and datasets never change whether a record counts as a success —
|
|
236
|
+
see [below](#it-never-changes-whether-a-record-succeeded).
|
|
237
|
+
|
|
238
|
+
### Python API
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
import os
|
|
242
|
+
from fetchpdf import fetch_pdf
|
|
243
|
+
|
|
244
|
+
# Download a PDF from a DOI (PMID also supported)
|
|
245
|
+
doi = "10.1038/nature12373"
|
|
246
|
+
save_path = "./papers/nature_paper.pdf"
|
|
247
|
+
|
|
248
|
+
os.makedirs("./papers", exist_ok=True) # fetch_pdf does not create directories
|
|
249
|
+
|
|
250
|
+
result = fetch_pdf(
|
|
251
|
+
doi=doi,
|
|
252
|
+
save_path=save_path,
|
|
253
|
+
verbose=True,
|
|
254
|
+
delay=0.1 # Polite delay between API calls
|
|
255
|
+
)
|
|
256
|
+
# -> the saved path, or None. It can be a .xml/.html path when only structured
|
|
257
|
+
# full text was available.
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`fetch_pdf` has no supplementary option. The extra formats and the supplementary
|
|
261
|
+
pass are keyword arguments on the batch entry point, which works for a single DOI
|
|
262
|
+
too:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from fetchpdf import batch_fetch_pdfs
|
|
266
|
+
|
|
267
|
+
results = batch_fetch_pdfs(
|
|
268
|
+
dois=["10.1371/journal.pone.0000308"],
|
|
269
|
+
output_dir="./out",
|
|
270
|
+
get_xml_or_html=True, # structured copy AND the PDF
|
|
271
|
+
to_markdown=True, # plus {stem}_from_xml.md
|
|
272
|
+
pull_supplementary=True, # SI/SM as numbered siblings + manifest
|
|
273
|
+
download_data_artifacts=True, # and the paper's linked deposits
|
|
274
|
+
max_supplementary_bytes=300 * 1024 * 1024,
|
|
275
|
+
want_provenance=True,
|
|
276
|
+
verbose=True,
|
|
277
|
+
)
|
|
278
|
+
# -> [(doi, success, save_path), ...]
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
To collect supplements for one record you already have on disk, call
|
|
282
|
+
`pull_supplementary_for` directly — see [API Reference](#api-reference).
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
### PMID Support
|
|
287
|
+
|
|
288
|
+
`fetchpdf`, `fetch_pdf` and `batch_fetch_pdfs` accept a PMID wherever they accept a DOI. The tool first resolves PMID → DOI (NCBI, then Europe PMC and a Crossref title search as fallbacks), then runs the normal DOI download flow. (`fetch_metadata_from_doi` and `fetchpdf-pubpeer` take DOIs only.)
|
|
289
|
+
|
|
290
|
+
**CLI examples:**
|
|
291
|
+
```bash
|
|
292
|
+
# Plain PMID
|
|
293
|
+
fetchpdf "33262244" -o ./pdfs -v
|
|
294
|
+
|
|
295
|
+
# PMID with prefix
|
|
296
|
+
fetchpdf "PMID:33262244" -o ./pdfs
|
|
297
|
+
|
|
298
|
+
# PubMed URL
|
|
299
|
+
fetchpdf "https://pubmed.ncbi.nlm.nih.gov/33262244/" -o ./pdfs
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
If a PMID cannot be resolved to a DOI, the tool still tries PMID-native sources (the PubMed page's `citation_pdf_url`); if those fail too, the record is reported as failed.
|
|
303
|
+
|
|
304
|
+
### Other commands
|
|
305
|
+
|
|
306
|
+
| Command | What it does |
|
|
307
|
+
|---|---|
|
|
308
|
+
| `fetchpdf-md ./out` | Render XML/HTML artifacts already on disk to `{stem}_from_xml.md` / `{stem}_from_html.md`, without re-downloading. Reads one directory, not its subfolders — after a `--make-subfolder` run, point it at each record's folder |
|
|
309
|
+
| `fetchpdf-render ./out --pdf` | Render article XML already on disk to `{stem}_from_xml.html` (default) and/or `{stem}_from_xml.pdf`; `--fetch-figures` runs the figures pass first. Walks subfolders (`pip install 'fetchpdf[render]'`, plus `[browser]` for `--pdf`) |
|
|
310
|
+
| `fetchpdf-images` | Dump the original embedded image bitstreams from PDFs already on disk (`pip install 'fetchpdf[better-text-images]'`) |
|
|
311
|
+
| `fetchpdf-verify` | Offline check that supplementary files on disk still match the hashes their `_supplementary_info.json` manifests recorded |
|
|
312
|
+
| `fetchpdf-pubpeer` | Capture a dated record of a paper's PubPeer comments (an empty record when there are none). DOIs only; needs `PUBPEER_DEVKEY` |
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
## Usage Examples
|
|
316
|
+
|
|
317
|
+
### Example 1: Download with Metadata
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
from fetchpdf import fetch_pdf, fetch_metadata_from_doi
|
|
321
|
+
import os
|
|
322
|
+
|
|
323
|
+
doi = "10.1038/nature12373"
|
|
324
|
+
|
|
325
|
+
# Get metadata first (any field can be None if no source had it)
|
|
326
|
+
metadata = fetch_metadata_from_doi(doi)
|
|
327
|
+
title = metadata['title'] or doi
|
|
328
|
+
print(f"Downloading: {title}")
|
|
329
|
+
|
|
330
|
+
# Use metadata for filename
|
|
331
|
+
safe_title = title[:50].replace(" ", "_").replace("/", "_")
|
|
332
|
+
os.makedirs("./papers", exist_ok=True)
|
|
333
|
+
save_path = f"./papers/{safe_title}.pdf"
|
|
334
|
+
|
|
335
|
+
# Download PDF
|
|
336
|
+
result = fetch_pdf(doi, save_path)
|
|
337
|
+
|
|
338
|
+
if result:
|
|
339
|
+
print(f"✅ Downloaded: {title}")
|
|
340
|
+
print(f" Authors: {metadata['authors']}")
|
|
341
|
+
print(f" Year: {metadata['year']}")
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Example 2: Using the Missing PDFs Report
|
|
345
|
+
|
|
346
|
+
Batch runs write `missing_pdfs.html` to the output directory, adding a row as each record fails:
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
from fetchpdf import batch_fetch_pdfs
|
|
350
|
+
|
|
351
|
+
results = batch_fetch_pdfs(
|
|
352
|
+
dois="papers.csv",
|
|
353
|
+
output_dir="./papers",
|
|
354
|
+
workers=4
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# After completion, check ./papers/missing_pdfs.html
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Each failed record gets a row with its identifier, linked to doi.org or PubMed for
|
|
361
|
+
a manual download, and the filename to save it under so a re-run picks it up. At
|
|
362
|
+
the end of the run a second table lists
|
|
363
|
+
[supplementary material and figures that could not be obtained](#refused-is-not-absent).
|
|
364
|
+
|
|
365
|
+
To disable the report, pass `create_missing_report=False`, or on the command line:
|
|
366
|
+
```bash
|
|
367
|
+
fetchpdf papers.csv -o ./papers --no-missing-report
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Batch Processing & Parallel Execution
|
|
371
|
+
|
|
372
|
+
`batch_fetch_pdfs` (and the CLI) accepts either a list of DOI/PMID identifiers or
|
|
373
|
+
a path to a CSV file. It returns `[(id, success, save_path), ...]`, where `id` is
|
|
374
|
+
the resolved DOI (lowercased) or the bare PMID and `save_path` is `None` on
|
|
375
|
+
failure; with `track_source=True` each tuple gains a fourth element, the source
|
|
376
|
+
that succeeded.
|
|
377
|
+
|
|
378
|
+
**CSV format** — one identifier per row under a `DOI` column:
|
|
379
|
+
```csv
|
|
380
|
+
DOI
|
|
381
|
+
10.1038/nature12373
|
|
382
|
+
10.1126/science.1241224
|
|
383
|
+
10.1016/j.cell.2019.05.031
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
If your CSV uses a different column name:
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
fetchpdf data.csv --doi-column "paper_doi" -o ./pdfs
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Parallel execution** — `-w N` on the command line, `workers=N` in Python;
|
|
393
|
+
`delay` is the pause between API calls *per worker*. Without `-w`, a CSV runs
|
|
394
|
+
sequentially and a comma-separated list on the command line uses 8 workers:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
fetchpdf papers.csv -o ./papers -w 4
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
```python
|
|
401
|
+
from fetchpdf import batch_fetch_pdfs
|
|
402
|
+
|
|
403
|
+
dois = ["10.1038/nature12373", "10.1126/science.1241224", "10.1016/j.cell.2019.05.031"]
|
|
404
|
+
|
|
405
|
+
results = batch_fetch_pdfs(
|
|
406
|
+
dois=dois, # or "papers.csv"
|
|
407
|
+
output_dir="./papers",
|
|
408
|
+
workers=4, # 1 = sequential (default)
|
|
409
|
+
delay=0.2,
|
|
410
|
+
)
|
|
411
|
+
# -> [(doi, success, save_path), ...]
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Best Practices
|
|
415
|
+
|
|
416
|
+
- **Sequential is fine for small batches** (< 20 identifiers).
|
|
417
|
+
- **Use 2–4 workers for large batches.** More runs into API rate limits, is less
|
|
418
|
+
polite to servers, and gives diminishing returns beyond 4–8 workers.
|
|
419
|
+
- **Raise `delay` as you add workers** (e.g. `workers=8, delay=0.5`) — more
|
|
420
|
+
workers means more simultaneous requests.
|
|
421
|
+
- **Retry from `failed_dois.csv`.** Every failure is written to
|
|
422
|
+
`failed_dois.csv` in the output directory. To retry just those, pass it back
|
|
423
|
+
in: `fetchpdf ./papers/failed_dois.csv -o ./papers`. Re-running the original
|
|
424
|
+
command also works, since records already on disk are skipped.
|
|
425
|
+
|
|
426
|
+
## Format-Prioritized Retrieval
|
|
427
|
+
|
|
428
|
+
The default chain walks *sources* and takes the first PDF each one yields. That
|
|
429
|
+
is right for a paper you want to read, and wrong for feeding a table-extraction
|
|
430
|
+
pipeline: what breaks there is not character accuracy but row/column/header
|
|
431
|
+
association, and that only survives losslessly in markup.
|
|
432
|
+
|
|
433
|
+
`--prioritize-xml` inverts the loop — format tiers outer, sources inner — so a
|
|
434
|
+
JATS copy from a worse-ranked source beats a PDF from a better-ranked one.
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
# Prefer structured full text, fall back through the normal chain
|
|
438
|
+
fetchpdf papers.csv -o ./out --prioritize-xml
|
|
439
|
+
|
|
440
|
+
# Structured only — fail the record rather than write a PDF
|
|
441
|
+
fetchpdf papers.csv -o ./out --xml-html-only # XML or publisher HTML
|
|
442
|
+
fetchpdf papers.csv -o ./out --xml-only # XML, nothing else
|
|
443
|
+
|
|
444
|
+
# A structured copy AND the PDF for every record
|
|
445
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html
|
|
446
|
+
|
|
447
|
+
# ...and a {stem}_from_xml.md / _from_html.md for each XML/HTML artifact
|
|
448
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html --to-markdown
|
|
449
|
+
|
|
450
|
+
# Convert artifacts you already have, without re-downloading
|
|
451
|
+
fetchpdf-md ./out
|
|
452
|
+
|
|
453
|
+
# Re-run over an existing directory, writing only genuine upgrades
|
|
454
|
+
fetchpdf papers.csv -o ./out --upgrade-existing
|
|
455
|
+
|
|
456
|
+
# Audit sidecar per record
|
|
457
|
+
fetchpdf papers.csv -o ./out --prioritize-xml --provenance
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Artifacts are written as suffixed siblings, one per tier: `{stem}.xml`,
|
|
461
|
+
`{stem}.fulltext.html`, `{stem}.source.tar.gz`, `{stem}.suppl.zip` (a single-file
|
|
462
|
+
supplement keeps its own extension, e.g. `{stem}.xlsx`), `{stem}.pdf`,
|
|
463
|
+
`{stem}.txt` and `{stem}.landing.html` — plus `{stem}_from_xml.md` /
|
|
464
|
+
`{stem}_from_html.md` under `--to-markdown`.
|
|
465
|
+
|
|
466
|
+
`--get-xml-or-html` doubles as a backfill: pointed at a directory of existing
|
|
467
|
+
PDFs it fetches only the missing structured half and re-downloads nothing.
|
|
468
|
+
|
|
469
|
+
### Choosing a flag
|
|
470
|
+
|
|
471
|
+
| Flag | Accepts | Falls back to PDF? |
|
|
472
|
+
|------|---------|--------------------|
|
|
473
|
+
| `--prioritize-xml` | everything, best format first | yes |
|
|
474
|
+
| `--xml-html-only` | T1 XML, T2 HTML | **no** — record fails |
|
|
475
|
+
| `--xml-only` | T1 XML | **no** — record fails |
|
|
476
|
+
| `--get-xml-or-html` | structured **and** PDF, both kept | yes, and keeps it |
|
|
477
|
+
|
|
478
|
+
`--xml-only` and `--xml-html-only` both imply `--prioritize-xml`. Given both,
|
|
479
|
+
`--xml-only` is stricter and wins (with a warning).
|
|
480
|
+
|
|
481
|
+
### The tier ladder
|
|
482
|
+
|
|
483
|
+
| Tier | Format | Why it sits here |
|
|
484
|
+
|------|--------|------------------|
|
|
485
|
+
| T1 | JATS/TEI XML | Cells, spans, headers and footnotes are markup. The only lossless tier. Its last rung is Europe PMC's render repository (`epmc_repo_xml`), which serves full JATS for records Europe PMC displays but does not list as open access; see [docs/retrieval.md](docs/retrieval.md#europe-pmcs-render-repository). |
|
|
486
|
+
| T2 | Publisher HTML | Real `<table>` with `<th>` and spans; at most OA publishers generated from the same JATS. Includes the PMC article page, tried when both PMC XML routes come up empty — measured over a 105-paper corpus, all six records the ladder had settled on a PDF for had a PMCID and a complete PMC page waiting. |
|
|
487
|
+
| T3 | LaTeX / e-print source | Lossless in principle, macro-hostile in practice. |
|
|
488
|
+
| T4 | Structured supplements | Often the underlying data; coverage is partial. |
|
|
489
|
+
| T5 | PDF | Structure must be reconstructed; introduces undetectable numeric corruption. |
|
|
490
|
+
| T6 | Plain text | Flattening destroys row/column association **silently**. Screening only. |
|
|
491
|
+
| T7 | Landing page | Last resort. |
|
|
492
|
+
|
|
493
|
+
`--target-task` selects the ladder. `screening` ranks plain text 4th;
|
|
494
|
+
`extraction` refuses it outright rather than degrading into it — every number is
|
|
495
|
+
present but anchored to nothing, which yields confident, plausible, wrong
|
|
496
|
+
arm-to-outcome assignments.
|
|
497
|
+
|
|
498
|
+
### Markdown output
|
|
499
|
+
|
|
500
|
+
JATS conversion includes the abstract, body, labeled publication/history dates,
|
|
501
|
+
author and funding notes, and back matter such as ethics statements, acknowledgments,
|
|
502
|
+
appendices, and supplementary-material descriptions. Bibliographic reference lists
|
|
503
|
+
are omitted. Tables remain embedded HTML with spans, captions, and footnotes;
|
|
504
|
+
external figures and supplementary files are referenced rather than fetched.
|
|
505
|
+
Partial dates retain their original precision. An abstract without a body is still
|
|
506
|
+
reported as a conversion failure, not a full-text success.
|
|
507
|
+
|
|
508
|
+
**Markdown output keeps tables as HTML, deliberately.** Markdown has no span
|
|
509
|
+
mechanism, so a `<th colspan="2">` over two treatment arms collapses and every
|
|
510
|
+
value to its right shifts one column — silently, in a way that still parses as a
|
|
511
|
+
valid table. Prose becomes Markdown; every table stays canonical minimal HTML at
|
|
512
|
+
its position in the document, with its caption and footnotes attached. Inline
|
|
513
|
+
HTML is part of the CommonMark spec, so this is ordinary Markdown, not a hybrid.
|
|
514
|
+
Elsevier full-text XML (`ce:` prose, CALS `tgroup`/`entry` tables with
|
|
515
|
+
`namest`/`nameend`/`morerows`) goes through the same walker, so its tables
|
|
516
|
+
arrive as the same canonical HTML with the spans resolved.
|
|
517
|
+
|
|
518
|
+
Every table is HTML, including simple ones with no spans. Mixing pipes and HTML
|
|
519
|
+
would make a three-column header ambiguous — no spans, or spans lost in
|
|
520
|
+
conversion? — and that ambiguity is worse than either format alone.
|
|
521
|
+
|
|
522
|
+
Each Markdown file opens with front matter recording its source and conversion time, table count, tables that were
|
|
523
|
+
published as images (not machine-readable), figures referenced but not included,
|
|
524
|
+
and separate prose/table token counts:
|
|
525
|
+
|
|
526
|
+
```yaml
|
|
527
|
+
---
|
|
528
|
+
source_artifact: 10.3390--biom12111676.xml
|
|
529
|
+
converted_at: 2026-09-14T10:02:31Z
|
|
530
|
+
table_format: canonical-html
|
|
531
|
+
tables: 2
|
|
532
|
+
tables_not_machine_readable: 0
|
|
533
|
+
figures_referenced_not_included: 1
|
|
534
|
+
prose_tokens: 11980
|
|
535
|
+
table_tokens: 189
|
|
536
|
+
---
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
JATS references images and never contains them, so figures appear as visible
|
|
540
|
+
placeholders rather than vanishing — a record whose key outcome sits in a forest
|
|
541
|
+
plot should look incomplete rather than complete.
|
|
542
|
+
|
|
543
|
+
### Re-running over a directory you already have
|
|
544
|
+
|
|
545
|
+
There is no ledger and nothing records which flags built a directory. Skipping
|
|
546
|
+
is decided per record from the filesystem: if an artifact already sits at the
|
|
547
|
+
record's stem, that record is skipped. On the default path that means a `.pdf`
|
|
548
|
+
or `.xml`; on the tiered (`--prioritize-xml`) path, any tier's suffix. This is
|
|
549
|
+
what makes an interrupted run resumable — re-run the same command and it picks
|
|
550
|
+
up where it stopped. It works the same flat or under `--make-subfolder`, but not
|
|
551
|
+
across the two: a subfolder run does not see files from an earlier flat run, and
|
|
552
|
+
re-downloads them.
|
|
553
|
+
|
|
554
|
+
The silent part is that a skip also skips the *choice*. A directory of papers
|
|
555
|
+
you fetched last month can gain its supplementary material without re-fetching a
|
|
556
|
+
single PDF, because the SI pass runs on the skip branch — but nothing says so.
|
|
557
|
+
So a CSV batch re-run that is about to skip records asks:
|
|
558
|
+
|
|
559
|
+
```
|
|
560
|
+
📁 1,284 of 5,000 records already have artifacts in ./out.
|
|
561
|
+
|
|
562
|
+
[s] skip them download only the 3,716 still missing (default)
|
|
563
|
+
[m] skip, but pull SI/SM keep the papers, fetch supplementary material for all of them
|
|
564
|
+
[a] abort
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Answering `m` is exactly `--pull-supplementary`: every PDF already on disk stays
|
|
568
|
+
untouched, and records whose SI manifest is already written cost zero requests.
|
|
569
|
+
|
|
570
|
+
`--on-existing {skip,supplement,ask}` pre-answers it. **The prompt never appears
|
|
571
|
+
unless stdin and stdout are both terminals** — pipes, cron and CI get a one-line
|
|
572
|
+
count and today's behaviour, so no unattended run can block on it. It is also
|
|
573
|
+
suppressed when the answer is already known: any flag that turns the
|
|
574
|
+
supplementary pass on (`--pull-supplementary`, `--refresh-supplementary`,
|
|
575
|
+
`--download-data-artifacts`, `--pull-everything`, `--llm-agent-retrieval`), an
|
|
576
|
+
explicit `--on-existing skip` or `supplement`, `--abstract-only`, or the
|
|
577
|
+
goal-aware flags (`--get-xml-or-html`, `--upgrade-existing` on the tiered path),
|
|
578
|
+
which do not skip records at all. Single-identifier runs never ask.
|
|
579
|
+
|
|
580
|
+
What is *not* remembered between runs: failures. `failed_dois.csv` is a report,
|
|
581
|
+
never read back, so every re-run retries every previous failure. Feed it back in
|
|
582
|
+
deliberately if you want only the retries.
|
|
583
|
+
|
|
584
|
+
### Retrieval and validation
|
|
585
|
+
|
|
586
|
+
FetchPDF checks downloaded PDFs for article identity and signs of truncation,
|
|
587
|
+
then tries other candidates when a file is rejected. If metadata cannot be
|
|
588
|
+
retrieved, the PDF is kept and reported as unverified.
|
|
589
|
+
|
|
590
|
+
When a PDF is unavailable, FetchPDF tries XML or HTML full text automatically.
|
|
591
|
+
Use `--no-xml-fallback` to disable this fallback.
|
|
592
|
+
|
|
593
|
+
Publisher HTML (T2) uses lxml, which is installed with fetchpdf.
|
|
594
|
+
|
|
595
|
+
Advanced users can customize retrieval order, source capabilities, and host rate
|
|
596
|
+
limits by copying [`ladder.json`](fetchpdf/retrieval/ladder.json) and setting
|
|
597
|
+
`FETCHPDF_LADDER` to the copy's path.
|
|
598
|
+
|
|
599
|
+
See [Retrieval and validation details](docs/retrieval.md) for validation rules,
|
|
600
|
+
examples, measurements, and rate-limit handling.
|
|
601
|
+
|
|
602
|
+
## Supplementary Material
|
|
603
|
+
|
|
604
|
+
`--pull-supplementary` fetches everything the authors deposited *alongside* the
|
|
605
|
+
paper — supplementary PDFs, spreadsheets, documents, images, archives, raw data —
|
|
606
|
+
and saves it as flat, numbered siblings of the main artifact. (Deposits reached
|
|
607
|
+
through link services go in a separate folder — see
|
|
608
|
+
[Linked datasets and code](#linked-datasets-and-code-stem_linked_artifactsjson).)
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
fetchpdf papers.csv -o ./out --pull-supplementary
|
|
612
|
+
fetchpdf papers.csv -o ./out --pull-supplementary -w 4
|
|
613
|
+
fetchpdf "10.1371/journal.pone.0000308" -o ./out --pull-supplementary
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
```
|
|
617
|
+
out/
|
|
618
|
+
10.1371--journal.pone.0000308.pdf
|
|
619
|
+
10.1371--journal.pone.0000308_supplementary_info_1.doc
|
|
620
|
+
10.1371--journal.pone.0000308_supplementary_info_2.doc
|
|
621
|
+
10.1371--journal.pone.0000308_supplementary_info_3.txt
|
|
622
|
+
10.1371--journal.pone.0000308_supplementary_info_4.xls
|
|
623
|
+
10.1371--journal.pone.0000308_supplementary_info.json <- the manifest
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
It works with or without `--prioritize-xml`, and it runs for records whose PDF is
|
|
627
|
+
already on disk — so pointing it at a corpus you downloaded months ago collects
|
|
628
|
+
the supplements for all of it without re-fetching a single paper.
|
|
629
|
+
|
|
630
|
+
### The manifest is not optional reading
|
|
631
|
+
|
|
632
|
+
Flat numbering discards the original filenames, so
|
|
633
|
+
`{stem}_supplementary_info.json` is the only record of what `_4` actually is:
|
|
634
|
+
|
|
635
|
+
```json
|
|
636
|
+
{"index": 4, "filename": "..._supplementary_info_4.xls",
|
|
637
|
+
"original_name": "pone.0000308.s004.xls", "label": "Table S1",
|
|
638
|
+
"provider": "europepmc_supplements", "container": "PMC1817752_SupplementaryFiles.zip",
|
|
639
|
+
"bytes": 42496, "sha256": "9f2c...", "role": "supplement"}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
It also records what was *not* taken and why — `too-large` with the declared byte
|
|
643
|
+
count, `duplicate-of` with the index it duplicates, `not-a-document` for a body
|
|
644
|
+
that is not a plausible file, and `blocked` for a refusal or a bot-challenge page
|
|
645
|
+
served as HTTP 200 ([below](#refused-is-not-absent)). "This record has no supplementary
|
|
646
|
+
material" and "one 4 GB HDF5 was refused" are different facts, and the manifest is
|
|
647
|
+
where they stay distinguishable.
|
|
648
|
+
|
|
649
|
+
The manifest is also the skip signal: a second run over the same output directory
|
|
650
|
+
costs zero HTTP requests. Delete a numbered file and the next run restores it *at
|
|
651
|
+
its original index*, so removing `_2` never renumbers `_3`. Use
|
|
652
|
+
`--refresh-supplementary` to re-enumerate and pick up newly deposited files.
|
|
653
|
+
|
|
654
|
+
### Where the files come from
|
|
655
|
+
|
|
656
|
+
Twenty routes, tried in a fixed order so the numbering is reproducible:
|
|
657
|
+
|
|
658
|
+
| Route | Applies to |
|
|
659
|
+
|---|---|
|
|
660
|
+
| JATS `<supplementary-material>` | any PMC record — *classifies*, telling supplements from figures |
|
|
661
|
+
| Europe PMC `supplementaryFiles` | any PMCID (asked with `includeInlineImage=no`, or it returns every figure too) |
|
|
662
|
+
| PMC AWS Open Data | the PMC OA subset; declares size and md5 before transfer |
|
|
663
|
+
| Elsevier object API | Elsevier DOIs — the only ScienceDirect route that needs no browser |
|
|
664
|
+
| Springer / Nature ESM | `10.1038`, `10.1007`, `10.1186`, … |
|
|
665
|
+
| PLOS | `10.1371` |
|
|
666
|
+
| bioRxiv / medRxiv | `10.1101` |
|
|
667
|
+
| APA supplemental | `10.1037` |
|
|
668
|
+
| Figshare / Zenodo / OSF | repository DOIs, and datasets reached through link services |
|
|
669
|
+
| DataCite reverse relations | any DOI — finds deposits whose own DataCite record points at the article through a non-citation relation (e.g. `IsSupplementTo`); deposits that merely cite it are dropped |
|
|
670
|
+
| Crossref component DOIs | publishers that register components |
|
|
671
|
+
| JATS-declared URLs | absolute URLs the JATS itself declares (e.g. LWW permalinks) — recovery of last resort |
|
|
672
|
+
| OpenAIRE ScholeXplorer | any DOI — Scholix publication→dataset/software links |
|
|
673
|
+
| Europe PMC datalinks | any PMID — text-mined accessions and data citations |
|
|
674
|
+
| Full-text scan | deposits the paper names in its own prose — only under `--download-data-artifacts` |
|
|
675
|
+
| Atypon publisher SI | PNAS, Science, Annual Reviews, SAGE, T&F — only when Europe PMC withholds the SI or the record has no PMCID; drives a headed browser, so it needs Playwright, Chromium and a display (or Xvfb) |
|
|
676
|
+
| LLM retrieval agent | only under [`--pull-everything`](#--pull-everything-the-second-layer) or `--llm-agent-retrieval` |
|
|
677
|
+
| JCI | `10.1172/jci…` |
|
|
678
|
+
|
|
679
|
+
Wiley, ACS and MDPI serve HTTP 403 to plain requests and are not yet covered;
|
|
680
|
+
arXiv ancillary files are not yet covered either. Dryad (`10.5061`) and Harvard
|
|
681
|
+
Dataverse (`10.7910/DVN`) deposits are reached through the link services rather
|
|
682
|
+
than as top-level providers.
|
|
683
|
+
|
|
684
|
+
### Refused is not absent
|
|
685
|
+
|
|
686
|
+
A 401, 403 or 429, or a bot-challenge page served with a 200, says **nothing**
|
|
687
|
+
about whether the file exists. Those are recorded as `"reason": "blocked"` with
|
|
688
|
+
the URL that produced them, never as a download failure and never left to fall
|
|
689
|
+
through to `none_found` — Atypon returns 403 for
|
|
690
|
+
`/doi/suppl/10.1161/STROKEAHA.111.628537` while a browser fetches the file in
|
|
691
|
+
seconds, and that record used to come out of a run as "nothing published".
|
|
692
|
+
|
|
693
|
+
Blocked records appear in `missing_pdfs.html` with the URL to open, because the
|
|
694
|
+
fix is a person and not a retry:
|
|
695
|
+
|
|
696
|
+
```
|
|
697
|
+
Missing supplementary material and figures — 2026-09-07 12:00
|
|
698
|
+
[CLICK-THROUGH] 10.1161/strokeaha.111.628537 partial — fetch by hand
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
429 keeps its retries — transience is read off the status, which is where
|
|
702
|
+
"later" is actually written — and 404 stays a plain download failure, because
|
|
703
|
+
that one *is* an answer about the file. Old manifests are healed on the next
|
|
704
|
+
run: a 403 recorded before this distinction existed is reclassified in place,
|
|
705
|
+
with the original reason kept under `"was"`.
|
|
706
|
+
|
|
707
|
+
### Size cap
|
|
708
|
+
|
|
709
|
+
`--max-supplementary-mb` defaults to 300, per file. A file whose `Content-Length`
|
|
710
|
+
exceeds it is skipped without downloading anything; a server that under-reports is
|
|
711
|
+
aborted mid-stream and its partial removed. An archive such as the Europe PMC
|
|
712
|
+
bundle can only be opened once it has fully arrived, so it downloads under a
|
|
713
|
+
larger cap (4× the per-file cap, 1,200 MB by default) and the per-file cap is
|
|
714
|
+
then applied to each member. Nothing is ever truncated — a
|
|
715
|
+
truncated `.xlsx` is a corrupt zip that some readers open far enough to yield
|
|
716
|
+
wrong numbers, which is worse than not having the file.
|
|
717
|
+
|
|
718
|
+
### It never changes whether a record succeeded
|
|
719
|
+
|
|
720
|
+
A paper with no supplementary material is not a failure, and a repository serving
|
|
721
|
+
malformed JSON or timing out cannot turn a successful download into a failed one.
|
|
722
|
+
Supplementary files never appear in `failed_dois.csv`, in `source_tracking.csv`,
|
|
723
|
+
or in the format-composition tally; the missing-PDFs report lists gaps in them
|
|
724
|
+
only in its own separate section. The same holds for linked datasets,
|
|
725
|
+
[figures](#figure-images) and the
|
|
726
|
+
[`--pull-everything`](#--pull-everything-the-second-layer) agent.
|
|
727
|
+
|
|
728
|
+
### Linked datasets and code: `{stem}_linked_artifacts.json`
|
|
729
|
+
|
|
730
|
+
The link services (ScholeXplorer, Europe PMC datalinks) discover more than they
|
|
731
|
+
download, and the rest lands in a second sidecar. Every publication→dataset and
|
|
732
|
+
publication→software link they returned is recorded there with a
|
|
733
|
+
classification; the full-text scan and the retrieval agent record their
|
|
734
|
+
candidates there too. (The DataCite reverse query does not write to the
|
|
735
|
+
sidecar.)
|
|
736
|
+
|
|
737
|
+
- `owned` — affirmatively the paper's own material: a supplement-grade
|
|
738
|
+
relation (`IsSupplementTo` and friends), or a repository deposit whose own
|
|
739
|
+
DataCite record names this article
|
|
740
|
+
- `related` — everything citation-grade: datasets/software the paper cites,
|
|
741
|
+
text-mined data citations that no metadata ties back to the article, and
|
|
742
|
+
repositories with no file enumerator
|
|
743
|
+
- `tool_citation` — "xgboost software on GitHub" and friends: somebody's tool,
|
|
744
|
+
not this paper's code. Recorded, never downloaded.
|
|
745
|
+
- `registry` — ClinicalTrials.gov and other registrations; a link, not a file
|
|
746
|
+
|
|
747
|
+
**By default only `owned` links are downloaded.** A "cites" link cannot
|
|
748
|
+
distinguish the paper's own deposit from a dataset the paper merely cites, so a
|
|
749
|
+
citation-grade link is promoted to `owned` only when the deposit's DataCite
|
|
750
|
+
record names the article. Under `--download-data-artifacts`, `related` links are
|
|
751
|
+
downloaded too, unless the deposit's own metadata says it belongs to a
|
|
752
|
+
*different* article (`--download-related-unverified` drops even that check).
|
|
753
|
+
Routed deposits in Figshare, Zenodo, OSF, Dryad or Harvard Dataverse go to those
|
|
754
|
+
enumerators and land under `{stem}_data_artifacts/<deposit>/` with their
|
|
755
|
+
original filenames (`routed_to_download: true` in the sidecar); they still get
|
|
756
|
+
an entry in the supplementary manifest. The same deposit named by several link
|
|
757
|
+
services is listed and downloaded once.
|
|
758
|
+
|
|
759
|
+
```bash
|
|
760
|
+
# Supplements + the paper's own linked deposits (Zenodo, Dryad, OSF, figshare, Dataverse)
|
|
761
|
+
fetchpdf papers.csv -o ./out --pull-supplementary
|
|
762
|
+
|
|
763
|
+
# Also take `related` deposits that do not claim another article,
|
|
764
|
+
# and repositories named in the paper's own full text
|
|
765
|
+
fetchpdf papers.csv -o ./out --download-data-artifacts
|
|
766
|
+
|
|
767
|
+
# Per-record dataset cap (default 500 MB), counted separately from supplements
|
|
768
|
+
fetchpdf papers.csv -o ./out --download-data-artifacts --max-data-artifact-mb 2000
|
|
769
|
+
|
|
770
|
+
# Expand replication-package zips (off by default — they have their own layout)
|
|
771
|
+
fetchpdf papers.csv -o ./out --download-data-artifacts --unpack-data-artifacts
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
`--download-data-artifacts` implies `--pull-supplementary`. What is *not*
|
|
775
|
+
downloaded still gets recorded: cited-but-not-owned datasets, tool citations,
|
|
776
|
+
trial registrations.
|
|
777
|
+
|
|
778
|
+
Empty answers are recorded too — `"queried": [...]` with zero links is a
|
|
779
|
+
different fact from a sidecar that does not exist. On the corpora this was
|
|
780
|
+
measured against (2026-08), most papers have no links at all: ~30% of a recent
|
|
781
|
+
biomedical batch had any ScholeXplorer link, an older clinical-trial corpus had
|
|
782
|
+
none, and Europe PMC's text-mined accessions were the widest net. The sidecar
|
|
783
|
+
is written by the same `--pull-supplementary` pass, after the manifest; a run
|
|
784
|
+
that skips enumeration (manifest already present, no `--refresh-supplementary`)
|
|
785
|
+
does not rewrite it.
|
|
786
|
+
|
|
787
|
+
#### Where dataset links come from, and what can be downloaded
|
|
788
|
+
|
|
789
|
+
Discovery — the link services queried per record:
|
|
790
|
+
|
|
791
|
+
| Service | What it contributes |
|
|
792
|
+
|---|---|
|
|
793
|
+
| OpenAIRE ScholeXplorer | Scholix links aggregated from Crossref, DataCite, EMBL-EBI, repositories, plus OpenAIRE's text-mining of ~14M PDFs |
|
|
794
|
+
| Europe PMC datalinks | Text-mined accessions (trial registrations, GEO/SRA/PDB…), DOI data citations, BioStudies deposits — the widest net for biomedical papers (needs a PMID) |
|
|
795
|
+
| DataCite reverse query | Deposits whose own metadata names the article — depositor-declared ground truth |
|
|
796
|
+
|
|
797
|
+
Download — repositories with file enumerators (links that pass the routing rules above):
|
|
798
|
+
|
|
799
|
+
| Repository | Routed on | Notes |
|
|
800
|
+
|---|---|---|
|
|
801
|
+
| figshare | `10.6084` / "figshare" | declared size + md5 before transfer |
|
|
802
|
+
| Zenodo | `10.5281` / "zenodo" | restricted records skipped |
|
|
803
|
+
| OSF | `10.17605` / "osf.io" | walks folder trees (depth/node caps) |
|
|
804
|
+
| Dryad | `10.5061` / "dryad" | declared size + sha-256, paginated |
|
|
805
|
+
| Harvard Dataverse | `10.7910/DVN` | declared md5; restricted files skipped |
|
|
806
|
+
| BioStudies | — | content arrives via the Europe PMC supplementary bundle instead, never fetched twice |
|
|
807
|
+
|
|
808
|
+
Not yet downloadable (recorded in the sidecar, left to the operator):
|
|
809
|
+
Mendeley Data (`10.17632`), non-Harvard Dataverse installations, subject
|
|
810
|
+
databases (GEO, SRA, PDB, UniProt — accession records, not file bundles),
|
|
811
|
+
GitLab repositories, and the institutional-repository long tail.
|
|
812
|
+
Adding an enumerator is the same small pattern as `enumerate_dryad` /
|
|
813
|
+
`enumerate_dataverse` in `fetchpdf/retrieval/supplement_index.py`.
|
|
814
|
+
|
|
815
|
+
## `--pull-everything`: the second layer
|
|
816
|
+
|
|
817
|
+
Everything above is layer 1 — APIs, indexes and enumerators. It is very good at
|
|
818
|
+
what publishers and repositories *declare*, and it cannot read. Layer 2 is a
|
|
819
|
+
model that reads the paper itself and goes after what layer 1 did not get.
|
|
820
|
+
|
|
821
|
+
```bash
|
|
822
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html --make-subfolder --provenance \
|
|
823
|
+
--pull-everything
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
`--pull-everything` implies `--pull-supplementary` and
|
|
827
|
+
`--download-data-artifacts`, turns on adjudication of the deterministic scan's
|
|
828
|
+
uncertain candidates, and adds the retrieval agent. Files land exactly where
|
|
829
|
+
they always did: supplements as numbered siblings of the PDF, linked deposits
|
|
830
|
+
in `{stem}_data_artifacts/`.
|
|
831
|
+
|
|
832
|
+
**It is not literally everything, and the run says so.** The publishers listed
|
|
833
|
+
as uncovered under [Where the files come from](#where-the-files-come-from) and
|
|
834
|
+
the repositories under [Not yet downloadable](#where-dataset-links-come-from-and-what-can-be-downloaded)
|
|
835
|
+
are recorded in the sidecar, not fetched.
|
|
836
|
+
|
|
837
|
+
### What the agent is told
|
|
838
|
+
|
|
839
|
+
Three things a model does not have and layer 1 does:
|
|
840
|
+
|
|
841
|
+
1. the paper's own text, weighted toward its availability statements;
|
|
842
|
+
2. what has already been obtained, so it does not fetch a second copy;
|
|
843
|
+
3. **what the link services found and declined, with their reasons.** That is
|
|
844
|
+
the richest input it gets. Measured over four corpora, those services
|
|
845
|
+
returned 3,005 `related` links that were never routed, and of 280 `owned`
|
|
846
|
+
ones 264 were BioStudies mirrors of supplements already in hand.
|
|
847
|
+
|
|
848
|
+
It proposes; the existing rules dispose. Before a proposal naming a repository
|
|
849
|
+
or a GitHub project is enumerated, it must pass three checks the deterministic
|
|
850
|
+
path already applies:
|
|
851
|
+
- **how the paper itself mentions it.** If the full-text scan finds the deposit
|
|
852
|
+
only inside a reference entry, attached to a preregistration, or as a
|
|
853
|
+
preprint DOI, the proposal is refused;
|
|
854
|
+
- **known tool and library GitHub orgs** are refused;
|
|
855
|
+
- **a deposit whose DataCite record names a different article** is refused.
|
|
856
|
+
|
|
857
|
+
A deposit the paper never names, which the agent found by navigating, is not
|
|
858
|
+
refused for that alone; finding those is its job. Refusals are recorded in the
|
|
859
|
+
sidecar as `related`, with the reason. Plain file URLs the agent proposes are
|
|
860
|
+
downloaded without an ownership check, so review what lands in
|
|
861
|
+
`{stem}_data_artifacts/`. Anything it saves meets the same per-file size cap,
|
|
862
|
+
sha256 deduplication and challenge-page sniff as every other file. No failure of
|
|
863
|
+
it can change whether a record succeeded.
|
|
864
|
+
|
|
865
|
+
### `--llm-backend`: two sandboxes, one result
|
|
866
|
+
|
|
867
|
+
| | `claude-cli` (default) | `openrouter` |
|
|
868
|
+
|---|---|---|
|
|
869
|
+
| auth | whatever the local Claude Code CLI already has | `OPENROUTER_API_KEY` in `.env.local` or the environment |
|
|
870
|
+
| `--llm-model` | `haiku`, `sonnet`, … | the same names, translated to slugs |
|
|
871
|
+
| tools | WebFetch only | `fetch_url` and `download`, both implemented here |
|
|
872
|
+
| downloads? | **no** — it navigates and reports URLs, fetchpdf transfers | yes, into a staging directory |
|
|
873
|
+
|
|
874
|
+
The asymmetry is not a preference, it is a measurement. Against Claude Code
|
|
875
|
+
2.1.240:
|
|
876
|
+
|
|
877
|
+
- **`--allowed-tools` grants permission; it does not restrict the tool set.** A
|
|
878
|
+
subprocess given `--allowed-tools "Bash(echo:*)"` ran
|
|
879
|
+
`cat ../outside_canary.txt` and returned the contents. So did
|
|
880
|
+
`--allowed-tools WebFetch`. With and without `--permission-mode
|
|
881
|
+
bypassPermissions`.
|
|
882
|
+
- **`--disallowed-tools` does remove tools**, and was the only lever that
|
|
883
|
+
worked.
|
|
884
|
+
- **A default subprocess inherits the operator's own MCP servers.** On the
|
|
885
|
+
machine this was written on, that put Gmail, Google Drive, Calendar and a
|
|
886
|
+
brokerage account in scope for a subprocess whose job is downloading
|
|
887
|
+
spreadsheets. `--strict-mcp-config` with an empty config removes them.
|
|
888
|
+
|
|
889
|
+
So the CLI backend is locked to WebFetch and nothing else — verified by asking a
|
|
890
|
+
locked-down subprocess to list its own tools, which answered `WebFetch` — and a
|
|
891
|
+
process with no file tools cannot save a file. It reports URLs instead, which
|
|
892
|
+
loses nothing: navigating is the part that needs a model, and transferring bytes
|
|
893
|
+
under a cap with a hash is the part this package already does. The OpenRouter
|
|
894
|
+
backend has no general-purpose harness to lock down, because its two tools are
|
|
895
|
+
ours, so it downloads directly.
|
|
896
|
+
|
|
897
|
+
`--llm-backend` selects the retrieval agent's backend only. The adjudication of
|
|
898
|
+
uncertain full-text-scan candidates (on under `--pull-everything`, or with
|
|
899
|
+
`--llm-adjudicate-artifacts`) always calls the Claude Code CLI, as a single-turn
|
|
900
|
+
call with `--allowed-tools ""`; it does not get the `--disallowed-tools` /
|
|
901
|
+
`--strict-mcp-config` lockdown described above.
|
|
902
|
+
|
|
903
|
+
### What it costs, and what to expect
|
|
904
|
+
|
|
905
|
+
One agent session per record, of up to 12 model turns. `--max-llm-records N`
|
|
906
|
+
is the ceiling that stops an overnight batch spending without bound (a record
|
|
907
|
+
with no readable full text still uses up a slot, without a model call); records
|
|
908
|
+
past it still get every API route, and `{stem}_linked_artifacts.json` records
|
|
909
|
+
that the agent was *skipped* rather than that it found nothing.
|
|
910
|
+
|
|
911
|
+
Measured on the corpora this was built against, **most calls will correctly find
|
|
912
|
+
nothing**, and that is not a defect in the agent. Of 606 PDF-only records, 6
|
|
913
|
+
name a repository at all and 17 carry a data-availability statement — the
|
|
914
|
+
biomedical sets skew to 1991–2006, before data-sharing norms existed. Expect the
|
|
915
|
+
yield on modern and social-science corpora instead: `fulltext_scan` alone
|
|
916
|
+
produced 83 OSF files from ten such records, and the link indexes had surfaced
|
|
917
|
+
none of the fourteen deposits those articles named in their own prose.
|
|
918
|
+
|
|
919
|
+
Recall is also **model-dependent and not deterministic**. On one record whose
|
|
920
|
+
GitHub URL is broken across a line by PDF layout — invisible to any regex —
|
|
921
|
+
`haiku` recovered it on two runs out of four and returned nothing on the other
|
|
922
|
+
two. Treat the agent as a second pass that sometimes finds what layer 1 missed,
|
|
923
|
+
not as a guarantee.
|
|
924
|
+
|
|
925
|
+
## Figure Images
|
|
926
|
+
|
|
927
|
+
`--pull-figures` fetches the article's own figure images — the files the
|
|
928
|
+
publisher deposited, one per `<fig>` — into `{stem}_figures/`, with a
|
|
929
|
+
`{stem}_figures.json` manifest.
|
|
930
|
+
|
|
931
|
+
```bash
|
|
932
|
+
fetchpdf papers.csv -o ./out --pull-figures
|
|
933
|
+
fetchpdf "10.1371/journal.pone.0000308" -o ./out --pull-figures
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
```
|
|
937
|
+
out/
|
|
938
|
+
10.1371--journal.pone.0000308.pdf
|
|
939
|
+
10.1371--journal.pone.0000308_figures/pone.0000308.g001.jpg
|
|
940
|
+
10.1371--journal.pone.0000308_figures/pone.0000308.g002.jpg
|
|
941
|
+
10.1371--journal.pone.0000308_figures.json <- the manifest
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
**This is not `--extract-images`.** That flag dumps the bitstreams embedded
|
|
945
|
+
*inside a PDF*, for byte-identity work that any re-encoding destroys. This one
|
|
946
|
+
fetches the publisher's image files, which is the only route available when
|
|
947
|
+
there is no PDF at all. Neither substitutes for the other, and they write to
|
|
948
|
+
separate directories.
|
|
949
|
+
|
|
950
|
+
**PMC, and Elsevier, and it says so.** The two PMC routes are PMC's AWS Open Data mirror —
|
|
951
|
+
preferred, because the package holds the figure files themselves and declares
|
|
952
|
+
each object's size and md5 before a byte moves — and the PMC blob CDN, whose
|
|
953
|
+
per-file paths are listed nowhere but the article page. A file from the mirror
|
|
954
|
+
is recorded `"provenance": "original"`; one from the CDN is `"render"`, because
|
|
955
|
+
it is a re-encoded copy and calling it original would invite byte-identity
|
|
956
|
+
conclusions about PMC's pipeline dressed up as conclusions about the authors'.
|
|
957
|
+
A `{stem}.fulltext.html` already on disk is read instead of re-requesting the
|
|
958
|
+
page.
|
|
959
|
+
|
|
960
|
+
**Every refusal is named, per figure and per record**, and a manifest with zero
|
|
961
|
+
figures is still written — "we asked and PMC listed nothing" and "we never
|
|
962
|
+
asked" are different facts, and a missing file cannot tell them apart:
|
|
963
|
+
|
|
964
|
+
```json
|
|
965
|
+
{"id": "pone-0000308-g001", "figure_label": "Figure 1",
|
|
966
|
+
"caption": "The 41 clinical trial publications which publicly shared…",
|
|
967
|
+
"href": "pone.0000308.g001", "provenance": "original",
|
|
968
|
+
"url": "https://pmc-oa-opendata.s3.amazonaws.com/PMC1817752.1/pone.0000308.g001.jpg",
|
|
969
|
+
"file": "pone.0000308.g001.jpg", "sha256": "…", "bytes": 84120,
|
|
970
|
+
"content_type": "image/jpeg", "status": "ok"}
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
**Elsevier** records — Elsevier XML on disk, or a `10.1016` DOI with no PMCID —
|
|
974
|
+
go to the Elsevier object API instead (needs `ELSEVIER_TDM_API_KEY`). The XML's
|
|
975
|
+
`<ce:link locator="gr1">` names each figure and the API's downsampled JPEG for
|
|
976
|
+
that `@ref` is fetched, marked `render`. The listing answers from anywhere; the
|
|
977
|
+
images of a subscription article answer 401 off an entitled network and are
|
|
978
|
+
recorded `blocked:401`, so re-run from the institution's network.
|
|
979
|
+
|
|
980
|
+
Record-level refusals are `no_pmcid`, `no_jats_or_page`,
|
|
981
|
+
`page_without_figure_links`, `no_elsevier_key`, `elsevier_objects_unavailable`
|
|
982
|
+
and `elsevier_objects_without_figures`; per-figure ones are `figure_not_on_page`,
|
|
983
|
+
`download_failed:<status>` and `blocked:<status>` — a publisher or CDN that
|
|
984
|
+
refuses this client while serving a person has not said the figure does not
|
|
985
|
+
exist, it has said to send a person.
|
|
986
|
+
|
|
987
|
+
Like the supplementary pass, this runs beside retrieval and never changes
|
|
988
|
+
whether a record succeeded, it runs for records already on disk, and a manifest
|
|
989
|
+
that fetched something makes a re-run free.
|
|
990
|
+
|
|
991
|
+
## Rendering the XML as HTML or PDF
|
|
992
|
+
|
|
993
|
+
`--generate-html-from-xml` writes `{stem}_from_xml.html` for every record with
|
|
994
|
+
an `.xml` (JATS, or Elsevier full-text XML): one file, figures embedded as
|
|
995
|
+
`data:` URIs, tables as HTML with `colspan`/`rowspan` intact, nothing loaded
|
|
996
|
+
from the network. `--generate-pdf-from-xml` prints the same page to
|
|
997
|
+
`{stem}_from_xml.pdf` with Chromium. Give both to keep both. Either implies
|
|
998
|
+
`--pull-figures`, which is where the images come from.
|
|
999
|
+
|
|
1000
|
+
```bash
|
|
1001
|
+
pip install 'fetchpdf[render,browser]' && playwright install chromium
|
|
1002
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html --generate-pdf-from-xml
|
|
1003
|
+
fetchpdf-render ./out --html --pdf --fetch-figures # XML you already have
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
**A rendering, not the publisher's PDF**, and never written as `{stem}.pdf`:
|
|
1007
|
+
that name means the typeset article to the skip check and the validators. The
|
|
1008
|
+
page opens with a banner naming the source file, the fetchpdf version and the
|
|
1009
|
+
DOI, plus a note for anything that could not be shown: figures without an image,
|
|
1010
|
+
tables published only as an image that is missing, or no reference list. A figure whose image could not be had keeps a visible
|
|
1011
|
+
placeholder, so an incomplete rendering looks incomplete. The prose and
|
|
1012
|
+
tables come from the same walker as `--to-markdown`, so the two agree, except
|
|
1013
|
+
that the page ends with the reference list (with DOI links), which the Markdown
|
|
1014
|
+
leaves out. Formulas stay as text. The XML never says how large a figure was
|
|
1015
|
+
typeset, so each is given about the same area (a square figure ~10.5 cm across),
|
|
1016
|
+
shaped by its aspect ratio and capped at the column width. Under the title come
|
|
1017
|
+
the authors, their affiliations and the journal citation (volume, issue, pages).
|
|
1018
|
+
Tables always fit the page width in the PDF; very wide ones print smaller.
|
|
1019
|
+
|
|
1020
|
+
Only records whose XML was kept are rendered — add `--get-xml-or-html` to keep
|
|
1021
|
+
the XML beside the PDF.
|
|
1022
|
+
|
|
1023
|
+
## Institutional access via cookies (ie using your library's subscriptions)
|
|
1024
|
+
|
|
1025
|
+
**Off until you set it up.** fetchpdf normally uses only open-access sources.
|
|
1026
|
+
If you belong to a university or institute whose library subscribes to
|
|
1027
|
+
journals, a one-time setup lets fetchpdf also download papers through your
|
|
1028
|
+
library, for the papers that have no open-access copy.
|
|
1029
|
+
|
|
1030
|
+
### Set it up (once, about three minutes)
|
|
1031
|
+
|
|
1032
|
+
Browser cookie support is included in the standard fetchpdf installation.
|
|
1033
|
+
|
|
1034
|
+
```bash
|
|
1035
|
+
fetchpdf fetchcookies
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
The wizard asks two questions, then opens some tabs:
|
|
1039
|
+
|
|
1040
|
+
1. **Your library.** Pick it from the list (Harvard Library is built in). For
|
|
1041
|
+
any other library, paste its OpenAthens sign-in link
|
|
1042
|
+
(`https://go.openathens.net/redirector/<your-library-domain>?url=`; your
|
|
1043
|
+
library's website calls it a "redirector" or "bookmarkable link"), or choose
|
|
1044
|
+
to sign in with each publisher's **Access through your institution** button.
|
|
1045
|
+
2. **The browser you use every day.** Chrome, Firefox, Edge, Brave or Chromium.
|
|
1046
|
+
3. **Sign in.** One tab per publisher opens in **that** browser. Sign in on the
|
|
1047
|
+
first with your institutional account (HarvardKey, for Harvard). Your
|
|
1048
|
+
library's single sign-on lets you straight into the rest, so reload any tab
|
|
1049
|
+
that loaded before you signed in. Check that each one opens the PDF, then
|
|
1050
|
+
press Enter.
|
|
1051
|
+
|
|
1052
|
+
The wizard then tests every publisher and prints what works:
|
|
1053
|
+
|
|
1054
|
+
```
|
|
1055
|
+
Wiley ✅ ok
|
|
1056
|
+
Taylor & Francis ✅ ok
|
|
1057
|
+
SAGE ✅ ok
|
|
1058
|
+
Springer ⚠️ no access
|
|
1059
|
+
Hogrefe ❌ not signed in
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
Offer to retry the ones that did not work, or leave them. `no access` means a
|
|
1063
|
+
session exists but the test article was refused: the session lapsed, or your
|
|
1064
|
+
library does not take that particular journal. Access is turned on only if at
|
|
1065
|
+
least one publisher works.
|
|
1066
|
+
|
|
1067
|
+
**Cookies often expire within hours**. You likely will have to rerun `fetchpdf fetchcookies` periodically to restore access.
|
|
1068
|
+
|
|
1069
|
+
### Living with it
|
|
1070
|
+
|
|
1071
|
+
| You want to | Run |
|
|
1072
|
+
|---|---|
|
|
1073
|
+
| See which publishers work right now | `fetchpdf cookies check` |
|
|
1074
|
+
| Fix publishers whose session lapsed | `fetchpdf cookies refresh` (reopens just those tabs; usually no password; `--publishers wiley,sage` to check only some) |
|
|
1075
|
+
| Skip it for one run | `fetchpdf ... --no-cookies` |
|
|
1076
|
+
| Retry only through your library, e.g. for DOIs that already failed everywhere else | `fetchpdf failed.csv -o pdfs --cookies-only` |
|
|
1077
|
+
| Turn it off | `fetchpdf cookies disable` |
|
|
1078
|
+
|
|
1079
|
+
Sessions lapse on the publisher's schedule. When a publisher refuses every
|
|
1080
|
+
request in a run, the run ends by saying so:
|
|
1081
|
+
|
|
1082
|
+
```
|
|
1083
|
+
🔐 Institutional access:
|
|
1084
|
+
wiley.com: 0 of 12 PDFs -- the session has probably expired (or was never made). Run `fetchpdf cookies refresh`.
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
You don't have to remember any of this. When a run ends with failures at a
|
|
1088
|
+
publisher the library route covers, fetchpdf says how many there are and where,
|
|
1089
|
+
and offers to sign you in to **only those publishers**: one failed Wiley paper
|
|
1090
|
+
opens one Wiley tab, not six. If access is not set up yet, this is the setup;
|
|
1091
|
+
if it is, it is a refresh of those publishers' sign-ins. Answer `y` and it
|
|
1092
|
+
signs you in, then retries just those papers through your library in the same
|
|
1093
|
+
run. This works the same for a single DOI and for a batch:
|
|
1094
|
+
|
|
1095
|
+
```
|
|
1096
|
+
🔐 3 failed papers (Wiley 2, Taylor & Francis 1) may be available through your library's subscriptions, by importing your browser's cookies.
|
|
1097
|
+
Sign in to Wiley and Taylor & Francis through your library and retry them? [y/N]:
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
Runs that cannot be asked (pipes, cron, `--json`) print the command instead
|
|
1101
|
+
(`fetchpdf fetchcookies --publishers wiley,tandf`, or `refresh --publishers ...`).
|
|
1102
|
+
`--no-cookies` and `--cookies FILE` turn the offer off. Royal Society papers are
|
|
1103
|
+
not offered, because there is no test article to sign in on.
|
|
1104
|
+
|
|
1105
|
+
Because cookies are read from your browser at the start of each run, keeping
|
|
1106
|
+
the browser signed in (simply using it) keeps fetchpdf signed in too. Clearing
|
|
1107
|
+
your browser's cookies signs fetchpdf out as well.
|
|
1108
|
+
|
|
1109
|
+
### What it covers, and where it sits in the chain
|
|
1110
|
+
|
|
1111
|
+
Publishers whose PDF address follows from the DOI: **Wiley** (`10.1002`,
|
|
1112
|
+
`10.1111`), **Taylor & Francis** (`10.1080`), **SAGE** (`10.1177`),
|
|
1113
|
+
**Springer** (`10.1007`, `10.1057`), the **Royal Society** (`10.1098`),
|
|
1114
|
+
**Hogrefe** (`10.1027`) and **INFORMS** (`10.1287`). Other DOIs are untouched
|
|
1115
|
+
and produce no extra output.
|
|
1116
|
+
|
|
1117
|
+
It runs right after the fast open-access lookups (repositories, PMC,
|
|
1118
|
+
Unpaywall), so a paper that is free anywhere is fetched from there and never
|
|
1119
|
+
spends a subscription download. It runs before the slow long tail (Crossref
|
|
1120
|
+
landing pages, CORE, DOAJ, DataCite, ...), where a subscription answers in a
|
|
1121
|
+
second or two. The PDF meets the same identity check as every other source and
|
|
1122
|
+
is reported as `institutional_cookies` in source tracking and `--json`. It also
|
|
1123
|
+
fills the PDF half of `--get-xml-or-html` runs.
|
|
1124
|
+
|
|
1125
|
+
### Sign-in, the VPN, and Cloudflare
|
|
1126
|
+
|
|
1127
|
+
- **Your own browser, not one fetchpdf drives.** Sign-in pages refuse a browser
|
|
1128
|
+
they can tell is automated: HarvardKey answers "Unable to sign in". That is
|
|
1129
|
+
why setup opens tabs in your everyday browser and reads its cookies (via
|
|
1130
|
+
`browser_cookie3`) instead. `fetchpdf cookies window` keeps the automated variant
|
|
1131
|
+
for libraries whose sign-in tolerates it.
|
|
1132
|
+
- **No VPN needed.** A session made through your library's sign-in belongs to
|
|
1133
|
+
your account, not your IP address, so it works off the institutional VPN.
|
|
1134
|
+
That matters when the VPN blocks other sources. Access you get only from
|
|
1135
|
+
being on the campus network or VPN does not carry over.
|
|
1136
|
+
- **Cloudflare.** Wiley, Taylor & Francis and SAGE refuse a plain HTTP client.
|
|
1137
|
+
With your browser's cookies and its User-Agent the request usually gets
|
|
1138
|
+
through; when a challenge is served anyway, fetchpdf retries in a headed
|
|
1139
|
+
browser on a private Xvfb display. Set `FETCHPDF_INSTITUTIONAL_VISIBLE=1` to
|
|
1140
|
+
watch it on your own screen.
|
|
1141
|
+
- **Known gap:** Springer's "Access through your institution" loops for some
|
|
1142
|
+
libraries (Harvard among them), leaving no session to use.
|
|
1143
|
+
|
|
1144
|
+
### Privacy and your licence
|
|
1145
|
+
|
|
1146
|
+
- Only cookies for the covered publishers' own domains are read from your
|
|
1147
|
+
browser, written (owner-only, under `~/.config/fetchpdf/`), or sent. A
|
|
1148
|
+
redirect to another host carries none; secure cookies stay on https; expired
|
|
1149
|
+
ones are skipped. Nothing but URLs and HTTP statuses is logged. Treat the
|
|
1150
|
+
files as passwords and keep them out of synced folders.
|
|
1151
|
+
- **Stay within your licence.** Most subscription licences allow downloading
|
|
1152
|
+
for your own research and forbid systematic or bulk downloading; publishers
|
|
1153
|
+
enforce that by blocking the whole institution. fetchpdf stops after 5,000
|
|
1154
|
+
library downloads per run (`--cookies-max N` changes it). That is a
|
|
1155
|
+
backstop; keeping to your licence is up to you.
|
|
1156
|
+
|
|
1157
|
+
### Without setup: a cookie file
|
|
1158
|
+
|
|
1159
|
+
`--cookies FILE` uses a cookie file for one run instead of the configured
|
|
1160
|
+
browser. Make one with `fetchpdf cookies export --browser chrome`, or with a browser
|
|
1161
|
+
cookie-export extension (a Netscape `cookies.txt`, JSON, or Playwright
|
|
1162
|
+
`storage_state`).
|
|
1163
|
+
|
|
1164
|
+
## Calling fetchpdf from a script or an agent
|
|
1165
|
+
|
|
1166
|
+
`--json` puts the result on stdout as JSON and moves everything else — progress
|
|
1167
|
+
lines, warnings, tallies, the per-source table — to stderr. It never prompts.
|
|
1168
|
+
One object for a single identifier; for a batch, one object per record as
|
|
1169
|
+
[JSON Lines](https://jsonlines.org/), written as each record finishes rather
|
|
1170
|
+
than collected at the end.
|
|
1171
|
+
|
|
1172
|
+
```bash
|
|
1173
|
+
fetchpdf "10.1371/journal.pone.0004215" -o ./pdfs --json
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
```json
|
|
1177
|
+
{"identifier": "10.1371/journal.pone.0004215", "doi": "10.1371/journal.pone.0004215", "success": true, "status": "downloaded", "path": "/abs/pdfs/10.1371--journal.pone.0004215.pdf", "format": "pdf", "source": "unpaywall", "identity": "verified", "reasons": [], "paths": []}
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
Run it a second time into the same directory and the record says so. Nothing
|
|
1181
|
+
was fetched and nothing re-read the file, so `identity` is null: this branch
|
|
1182
|
+
cannot tell a stale or wrong artifact from a good one, which is the fact
|
|
1183
|
+
`status` exists to report.
|
|
1184
|
+
|
|
1185
|
+
```json
|
|
1186
|
+
{"identifier": "10.1371/journal.pone.0004215", "doi": "10.1371/journal.pone.0004215", "success": true, "status": "already_on_disk", "path": "/abs/pdfs/10.1371--journal.pone.0004215.pdf", "format": "pdf", "source": "existing", "identity": null, "reasons": [], "paths": []}
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
A PDF that was fetched and then refused by the identity check reports the
|
|
1190
|
+
refusal, not a bare failure:
|
|
1191
|
+
|
|
1192
|
+
```json
|
|
1193
|
+
{"identifier": "10.1234/example", "doi": "10.1234/example", "success": false, "status": "failed", "path": null, "format": null, "source": null, "identity": "wrong_article", "reasons": ["PDF does not contain the requested DOI or the title \"An example article title\""], "paths": []}
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
### Fields
|
|
1197
|
+
|
|
1198
|
+
| Key | Type | Meaning |
|
|
1199
|
+
|---|---|---|
|
|
1200
|
+
| `identifier` | str | Exactly what you passed in — DOI, PMID or CSV cell. |
|
|
1201
|
+
| `doi` | str \| null | The DOI it resolved to; null when resolution failed. |
|
|
1202
|
+
| `success` | bool | True for `downloaded` and `already_on_disk`, false for `failed`. |
|
|
1203
|
+
| `status` | str | `downloaded`, `already_on_disk` or `failed`. See below. |
|
|
1204
|
+
| `path` | str \| null | Absolute path to the artifact, or null. |
|
|
1205
|
+
| `format` | str | From the artifact actually written: `pdf`, `xml`, `html`, `latex`, `landing`, `suppl`, `abstract`, `text`, `other`. **A successful record is not necessarily a PDF** — with the default XML fallback a record can succeed as `xml`. |
|
|
1206
|
+
| `source` | str \| null | The route that produced it (`unpaywall`, `core`, `osf`, …), `existing` for a skip, `structured_fallback` when no PDF was available and structured full text was saved instead. |
|
|
1207
|
+
| `identity` | str \| null | The verdict on the PDF **fetched in this run and reported in `path`**: `verified`, or a refusal — `wrong_article`, `truncated`, `unreadable`, `no_reference`, `no_engine`. Null when nothing was judged: an XML artifact, a file already on disk, or any run under the tiered flags (`--prioritize-xml`, `--get-xml-or-html`, `--xml-only`, `--xml-html-only`), where the engine validates per tier instead. A success with a non-`verified` identity is a PDF that was kept rather than deleted because the verdict could not be trusted — see [the identity check](#format-prioritized-retrieval) — not one that passed. |
|
|
1208
|
+
| `reasons` | list[str] | Why a record failed, or why a kept PDF could not be verified. Empty on a verified success. |
|
|
1209
|
+
| `paths` | list[str] | The other artifacts a tiered run wrote (`--get-xml-or-html`), empty otherwise. `path` is not repeated here. |
|
|
1210
|
+
| `supplementary` | object | Only with `--pull-supplementary` (or a flag that implies it): `status` (`ok`, `none_found`, `partial`, `incomplete`, `error`, `skipped`), `written`, `skipped`, `manifest` (absolute path or null), `missing_declared` (files the article declares that were not obtained) and `blocked_urls` (refused to this client; open them in a browser). |
|
|
1211
|
+
| `figures` | object | Only with `--pull-figures`: `status`, `written`, `failed`, `refusal` and `manifest`. |
|
|
1212
|
+
| `images` | object | Only with `--extract-images`: `status`, `written` and `manifest`. |
|
|
1213
|
+
|
|
1214
|
+
A side-pass key is absent when its flag was off and present, possibly with
|
|
1215
|
+
nothing obtained, when it was on — so "not asked" and "asked, found nothing"
|
|
1216
|
+
stay distinguishable. None of them changes `success`.
|
|
1217
|
+
|
|
1218
|
+
The three statuses are separated because a caller has three different jobs to
|
|
1219
|
+
do: use the file, use the file knowing nothing re-checked it, or handle the
|
|
1220
|
+
failure. `success` alone cannot express the middle one.
|
|
1221
|
+
|
|
1222
|
+
### Exit codes
|
|
1223
|
+
|
|
1224
|
+
Unchanged by the flag.
|
|
1225
|
+
|
|
1226
|
+
- Single identifier: `0` if an artifact was obtained (including one already on
|
|
1227
|
+
disk), `1` if not.
|
|
1228
|
+
- Batch: `0` only if **every** record succeeded, `1` otherwise. Read the
|
|
1229
|
+
per-record objects, not the exit code.
|
|
1230
|
+
- `2` for a contradictory combination of flags, and argparse's own `2` for a
|
|
1231
|
+
usage error. Both write their message to stderr under `--json`.
|
|
1232
|
+
|
|
1233
|
+
### Notes
|
|
1234
|
+
|
|
1235
|
+
- The ten core keys are stable and always present; a key whose value is unknown
|
|
1236
|
+
is null or empty rather than absent. Output is ASCII, with non-ASCII
|
|
1237
|
+
characters escaped, so no console encoding can break it.
|
|
1238
|
+
- A single-identifier run that stops early — an unexpected exception, or an
|
|
1239
|
+
error exit before its result — still writes one `failed` object whose
|
|
1240
|
+
`reasons` point to stderr. A batch that stops before its first record (a
|
|
1241
|
+
missing CSV, an unknown column) writes nothing to stdout and exits nonzero.
|
|
1242
|
+
- stdout is the results channel only. Anything that is not a result — including
|
|
1243
|
+
the `EMAIL not set` warning printed at import — goes to stderr.
|
|
1244
|
+
- ANSI colour is dropped from warnings when stderr is not a terminal. The
|
|
1245
|
+
`EMAIL not set` warning keeps its colour: it prints at import, before any
|
|
1246
|
+
flag has been read.
|
|
1247
|
+
- `--on-existing ask` never asks under `--json`; the run is treated as
|
|
1248
|
+
non-interactive and skips, as it does in cron and pipes.
|
|
1249
|
+
- `--migrate-folders` and `--migrate-dry-run` report to stderr and write no
|
|
1250
|
+
JSON: they rename directories and download nothing.
|
|
1251
|
+
- With `--abstract-if-no-pdf`, a record that yielded only an abstract is still
|
|
1252
|
+
reported `failed` with a null `path`, matching what the batch counts as a
|
|
1253
|
+
failure. The abstract is on disk as `{stem}_abstract.md`.
|
|
1254
|
+
|
|
1255
|
+
```bash
|
|
1256
|
+
fetchpdf papers.csv -o ./pdfs -w 4 --json 2>run.log | while read -r line; do
|
|
1257
|
+
jq -r 'select(.success) | .path' <<<"$line"
|
|
1258
|
+
done
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
## API Reference
|
|
1262
|
+
|
|
1263
|
+
### `fetch_pdf(doi, save_path, email=None, verbose=False, delay=0.1, ...)`
|
|
1264
|
+
|
|
1265
|
+
Download a PDF from a DOI (or a PMID) using multiple fallback sources. `fetch_pdf` does not create `save_path`'s directory; create it first.
|
|
1266
|
+
|
|
1267
|
+
**Parameters:**
|
|
1268
|
+
- `doi` (str): DOI or PMID identifier to download
|
|
1269
|
+
- `save_path` (str): Path where the PDF should be saved
|
|
1270
|
+
- `email` (str, optional): Email for API calls (default: `EMAIL` from `.env.local`)
|
|
1271
|
+
- `verbose` (bool, optional): Print detailed progress (default: False)
|
|
1272
|
+
- `delay` (float, optional): Delay between API calls in seconds (default: 0.1)
|
|
1273
|
+
|
|
1274
|
+
Further keyword arguments mirror the format flags of the CLI (`prioritize_xml`,
|
|
1275
|
+
`xml_only`, `xml_html_only`, `get_xml_or_html`, `to_markdown`, `target_task`,
|
|
1276
|
+
`upgrade_existing`, `want_provenance`, `allow_xml_fallback`, `use_playwright`) —
|
|
1277
|
+
see `help(fetch_pdf)`.
|
|
1278
|
+
|
|
1279
|
+
**Returns:**
|
|
1280
|
+
- `str`: Path to the saved file if successful, `None` otherwise. Usually the PDF,
|
|
1281
|
+
but it can be a `.xml` or `.html` path when only structured full text was
|
|
1282
|
+
available (disable that with `allow_xml_fallback=False`)
|
|
1283
|
+
|
|
1284
|
+
### `batch_fetch_pdfs(dois, output_dir, email=None, verbose=False, delay=0.1, workers=1, create_missing_report=True, ...)`
|
|
1285
|
+
|
|
1286
|
+
Download PDFs for multiple DOI/PMID identifiers with optional parallel processing.
|
|
1287
|
+
|
|
1288
|
+
**Parameters:**
|
|
1289
|
+
- `dois` (list or str): List of DOI/PMID identifiers or path to CSV file
|
|
1290
|
+
- `output_dir` (str): Directory to save PDFs
|
|
1291
|
+
- `email` (str, optional): Email for API calls (default: `EMAIL` from `.env.local`)
|
|
1292
|
+
- `verbose` (bool, optional): Print detailed progress (default: False)
|
|
1293
|
+
- `delay` (float, optional): Delay between API calls per worker (default: 0.1)
|
|
1294
|
+
- `workers` (int, optional): Number of parallel workers; 1 = sequential (default: 1)
|
|
1295
|
+
- `create_missing_report` (bool, optional): Create HTML report for failed downloads (default: True)
|
|
1296
|
+
|
|
1297
|
+
Most retrieval flags of the CLI have a keyword argument of the same name in
|
|
1298
|
+
snake_case — e.g. `pull_supplementary`, `download_data_artifacts`, `pull_figures`,
|
|
1299
|
+
`make_subfolder`, `want_provenance` (`--provenance`), and
|
|
1300
|
+
`max_supplementary_bytes` (bytes, where the CLI's `--max-supplementary-mb` takes
|
|
1301
|
+
MB). See `help(batch_fetch_pdfs)` for the full list.
|
|
1302
|
+
|
|
1303
|
+
**Returns:**
|
|
1304
|
+
- `list`: List of tuples `(id, success, save_path)` for each identifier processed —
|
|
1305
|
+
`id` is the resolved DOI or bare PMID, `save_path` is `None` on failure. With
|
|
1306
|
+
`track_source=True`, 4-tuples `(id, success, save_path, source)`.
|
|
1307
|
+
|
|
1308
|
+
**Side Effects:**
|
|
1309
|
+
- Creates `output_dir` if needed
|
|
1310
|
+
- Writes `missing_pdfs.html` in `output_dir` when downloads fail, or when
|
|
1311
|
+
supplementary material or figures could not be obtained (unless
|
|
1312
|
+
`create_missing_report=False`)
|
|
1313
|
+
|
|
1314
|
+
### `pull_supplementary_for(raw_identifier, doi, save_path, ...)`
|
|
1315
|
+
|
|
1316
|
+
Fetch every supplementary file for one record, as flat siblings of `save_path`.
|
|
1317
|
+
|
|
1318
|
+
A separate call rather than a keyword on `fetch_pdf`, deliberately: the
|
|
1319
|
+
tiered engine re-enters that function with a temporary path, so a flag threaded
|
|
1320
|
+
through it would write supplementary siblings next to a file that is about to be
|
|
1321
|
+
deleted. Two calls make that impossible rather than merely discouraged.
|
|
1322
|
+
|
|
1323
|
+
```python
|
|
1324
|
+
import os
|
|
1325
|
+
from fetchpdf import fetch_pdf, pull_supplementary_for
|
|
1326
|
+
|
|
1327
|
+
doi = "10.1371/journal.pone.0000308"
|
|
1328
|
+
os.makedirs("out", exist_ok=True)
|
|
1329
|
+
path = fetch_pdf(doi, "out/paper.pdf")
|
|
1330
|
+
summary = pull_supplementary_for(doi, doi=doi, save_path="out/paper.pdf")
|
|
1331
|
+
print(summary.status, summary.written, summary.manifest_path)
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
**Key parameters:**
|
|
1335
|
+
- `raw_identifier` (str): the identifier as given (DOI or PMID)
|
|
1336
|
+
- `doi` (str, optional): the resolved DOI, when known
|
|
1337
|
+
- `save_path` (str): the main artifact's path; siblings and the manifest derive from its stem
|
|
1338
|
+
- `max_file_bytes` (int, optional): per-file cap (default 300 MB)
|
|
1339
|
+
- `refresh` (bool, optional): re-enumerate even when a manifest exists (default: False)
|
|
1340
|
+
- `resolver` / `http` (optional): reuse a batch's `BatchResolver` / `HttpClient` so the per-host rate budget stays shared
|
|
1341
|
+
|
|
1342
|
+
**Returns:**
|
|
1343
|
+
- `SupplementarySummary`: `.status` (`ok` | `none_found` | `partial` | `incomplete` | `error` | `skipped`), `.written`, `.skipped`, `.bytes_written`, `.manifest_path`, `.paths`, `.missing_declared`, `.blocked_urls` (URLs a publisher refused this client but serves to a person)
|
|
1344
|
+
|
|
1345
|
+
Never raises for a network or provider failure, and its result must not be used to
|
|
1346
|
+
decide whether the record succeeded.
|
|
1347
|
+
|
|
1348
|
+
### `fetch_metadata_from_doi(doi, email, delay)`
|
|
1349
|
+
|
|
1350
|
+
Fetch metadata for a paper from its DOI.
|
|
1351
|
+
|
|
1352
|
+
**Parameters:**
|
|
1353
|
+
- `doi` (str): The DOI to fetch metadata for
|
|
1354
|
+
- `email` (str, optional): Email for API calls (default: `EMAIL` from `.env.local`)
|
|
1355
|
+
- `delay` (float, optional): Delay between API calls in seconds (default: 0.2)
|
|
1356
|
+
|
|
1357
|
+
**Returns:**
|
|
1358
|
+
- `dict`: Metadata dictionary with keys: `authors`, `title`, `journal`, `volume`, `issue`, `pages`, `year`, `url`
|
|
1359
|
+
|
|
1360
|
+
## Download Sources (in order of priority)
|
|
1361
|
+
|
|
1362
|
+
On the default path the tool walks the chain below (`_fetch_pdf_chain` in
|
|
1363
|
+
`fetchpdf/fetchpdf.py`) until a file is accepted. Every PDF is checked against the
|
|
1364
|
+
record before it is accepted ([details](docs/retrieval.md)). The order puts faster,
|
|
1365
|
+
more reliable sources first and saves rate-limited quotas for the records that
|
|
1366
|
+
need them. Under `--prioritize-xml` and the other format flags, the tier ladder in
|
|
1367
|
+
[`ladder.json`](fetchpdf/retrieval/ladder.json) governs the order instead — see
|
|
1368
|
+
[Format-Prioritized Retrieval](#format-prioritized-retrieval).
|
|
1369
|
+
|
|
1370
|
+
### Special DOI Handlers (Pattern-Matched)
|
|
1371
|
+
These run first when the DOI matches (by prefix, or by the name appearing anywhere in the DOI):
|
|
1372
|
+
|
|
1373
|
+
- **OSF** (Open Science Framework) - Projects (`10.17605/osf.io/*`), Preprints (`10.31234/osf.io/*`), any `osf.io` DOI
|
|
1374
|
+
- Direct download URLs, Playwright browser automation, API fallback
|
|
1375
|
+
- **SSRN** (Social Science Research Network) - `10.2139/*`
|
|
1376
|
+
- Playwright with Cloudflare handling, download button detection
|
|
1377
|
+
- **Figshare** - `10.6084/*`
|
|
1378
|
+
- Figshare API for file metadata and download URLs
|
|
1379
|
+
- **PsychArchives** - `10.23668/*`
|
|
1380
|
+
- Leibniz psychology repository bitstream extraction
|
|
1381
|
+
- **Zenodo** - `10.5281/*`
|
|
1382
|
+
- Files listed by Zenodo's InvenioRDM API
|
|
1383
|
+
|
|
1384
|
+
### Standard Fallback Chain (All DOIs)
|
|
1385
|
+
|
|
1386
|
+
1. **PubMed Central (PMC)**
|
|
1387
|
+
- NCBI ID converter → Europe PMC `?pdf=render`
|
|
1388
|
+
- If there is no PDF: Europe PMC JATS, then NCBI efetch JATS (skipped with `--no-xml-fallback`)
|
|
1389
|
+
2. **eLife XML** — eLife DOIs only; skipped with `--no-xml-fallback`
|
|
1390
|
+
3. **eScholarship** — via PubMed LinkOut
|
|
1391
|
+
4. **Unpaywall** — legal open-access aggregator; requires `EMAIL`
|
|
1392
|
+
- → **[Institutional access](#institutional-access-via-cookies-ie-using-your-librarys-subscriptions)** runs here, if set up, for the publishers it covers
|
|
1393
|
+
5. **Crossref**
|
|
1394
|
+
- Direct PDF links in Crossref metadata, and text-mining XML links
|
|
1395
|
+
- Landing page scraping (`citation_pdf_url` and similar), plus a direct `/doi/pdf/` try for Taylor & Francis
|
|
1396
|
+
- Re-enters the chain on a related preprint DOI (PsyArXiv, bioRxiv, …), reported as `crossref_preprint`
|
|
1397
|
+
6. **Europe PMC** — search API
|
|
1398
|
+
7. **Semantic Scholar**
|
|
1399
|
+
- `openAccessPdf` field, with a landing page fallback for OJS sites
|
|
1400
|
+
- PsyArXiv → OSF URL conversion
|
|
1401
|
+
8. **OpenAlex** — open-access locations; placed after Semantic Scholar to conserve OpenAlex's download quota
|
|
1402
|
+
9. **CORE** (core.ac.uk) — 40M+ repository papers; needs `COREAPIKEY`
|
|
1403
|
+
10. **DOAJ** — open-access full-text links
|
|
1404
|
+
11. **DataCite content URLs** — direct content and related resource links
|
|
1405
|
+
12. **Wiley rendered PDF** — only with `--add-playwright`
|
|
1406
|
+
13. **APA supplemental** — APA supplemental files, converting `.doc`/`.docx` to PDF when available
|
|
1407
|
+
14. **Direct DOI resolver**
|
|
1408
|
+
- Follows `https://doi.org/{doi}` to the landing page
|
|
1409
|
+
- Handles Crossref chooser pages (multiple resolution)
|
|
1410
|
+
- Scrapes PDF links from HTML (`citation_pdf_url`, href patterns)
|
|
1411
|
+
- Publisher-specific deterministic URL patterns (Wiley, T&F, SAGE, MIT Press, …)
|
|
1412
|
+
15. **DataCite related identifiers**
|
|
1413
|
+
- Supplementary material → main paper
|
|
1414
|
+
- Versioned DOI resolution (`IsIdenticalTo`, `IsVersionOf`)
|
|
1415
|
+
16. **DOI → PMID fallback**
|
|
1416
|
+
- Converts the DOI to a PMID and tries PMID-native sources (the PubMed page's `citation_pdf_url`)
|
|
1417
|
+
17. **Google Scholar via SerpApi** (optional)
|
|
1418
|
+
- Enabled by `SERPAPI_API_KEY` in `.env.local` or the environment
|
|
1419
|
+
- Searches the quoted DOI, then the quoted article title if the DOI search returned results but no accepted PDF
|
|
1420
|
+
- At most two searches per unresolved DOI, with up to five results per search;
|
|
1421
|
+
searches consume your SerpApi account quota
|
|
1422
|
+
- Prioritizes PDF resource links, then tries article landing pages; uses the
|
|
1423
|
+
existing title matching and downloaded-PDF identity checks
|
|
1424
|
+
- Skips without a key. HTTP 401, 403 or 429 disables it for the rest of the
|
|
1425
|
+
process; any other request error, or an error response from SerpApi, ends
|
|
1426
|
+
the SerpApi attempt for that record
|
|
1427
|
+
- Reported as `serpapi_scholar` in source tracking
|
|
1428
|
+
|
|
1429
|
+
### Final Fallbacks
|
|
1430
|
+
|
|
1431
|
+
18. **Elsevier full-text API**
|
|
1432
|
+
- Elsevier records only (`10.1016/*`, or a Crossref landing page on sciencedirect.com / elsevier.com)
|
|
1433
|
+
- Tries the PDF first, then full-text XML (the XML half is skipped with `--no-xml-fallback`)
|
|
1434
|
+
- Requires `ELSEVIER_TDM_API_KEY`
|
|
1435
|
+
|
|
1436
|
+
If the whole chain yields no PDF, `fetch_pdf` tries structured full text
|
|
1437
|
+
(T1 XML / T2 HTML) from the tier ladder before giving up; `--no-xml-fallback`
|
|
1438
|
+
turns this off. Each tier ends with a Europe PMC render-repository rung
|
|
1439
|
+
(`epmc_repo_xml`, `epmc_transform_html`), so a non-OA record that Europe PMC
|
|
1440
|
+
still displays comes back as JATS.
|
|
1441
|
+
|
|
1442
|
+
---
|
|
1443
|
+
|
|
1444
|
+
**Total: 18 standard steps + 5 special handlers**, plus opt-in institutional access after Unpaywall.
|
|
1445
|
+
|
|
1446
|
+
## Requirements
|
|
1447
|
+
|
|
1448
|
+
- Python 3.10+
|
|
1449
|
+
- requests, urllib3, python-dotenv
|
|
1450
|
+
- pypdf (every retrieved PDF is identity-checked, so a PDF text engine is required)
|
|
1451
|
+
|
|
1452
|
+
Playwright is optional (the `browser` extra; see [Installation](#optional-install-playwright-and-chromium-for-browser-automation-experimental)). Optional extras are listed under [Optional extras](#optional-extras).
|
|
1453
|
+
|
|
1454
|
+
## Troubleshooting
|
|
1455
|
+
|
|
1456
|
+
### Rate Limiting
|
|
1457
|
+
|
|
1458
|
+
If you're downloading many papers, consider:
|
|
1459
|
+
- Increasing the `delay` parameter (e.g., `delay=1.0`)
|
|
1460
|
+
- Using the batch downloader with controlled parallelism
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
## Ethical Considerations
|
|
1464
|
+
|
|
1465
|
+
This tool is intended for:
|
|
1466
|
+
- ✅ Accessing open access papers
|
|
1467
|
+
- ✅ Retrieving papers you have legal access to
|
|
1468
|
+
- ✅ Academic research and education
|
|
1469
|
+
- ✅ Fair use purposes
|
|
1470
|
+
|
|
1471
|
+
Please respect copyright laws and publisher terms of service in your jurisdiction.
|
|
1472
|
+
|
|
1473
|
+
## Acknowledgments
|
|
1474
|
+
|
|
1475
|
+
This package aggregates access to multiple open academic resources. Thanks to:
|
|
1476
|
+
- OpenAlex, Unpaywall, Crossref, PubMed Central, Semantic Scholar, and other open science initiatives
|
|
1477
|
+
- The Playwright team for browser automation tools
|