fetchpdf 0.1.0__py3-none-any.whl
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/__init__.py +49 -0
- fetchpdf/__main__.py +6 -0
- fetchpdf/_env.py +63 -0
- fetchpdf/_http.py +40 -0
- fetchpdf/fetch_abstract_from_doi.py +549 -0
- fetchpdf/fetch_metadata_from_doi.py +225 -0
- fetchpdf/fetchpdf.py +7339 -0
- fetchpdf/retrieval/__init__.py +36 -0
- fetchpdf/retrieval/_util.py +150 -0
- fetchpdf/retrieval/artifact.py +87 -0
- fetchpdf/retrieval/backends/__init__.py +109 -0
- fetchpdf/retrieval/backends/claude_cli.py +111 -0
- fetchpdf/retrieval/backends/openrouter.py +244 -0
- fetchpdf/retrieval/cache.py +123 -0
- fetchpdf/retrieval/chunk.py +195 -0
- fetchpdf/retrieval/classify.py +110 -0
- fetchpdf/retrieval/context.py +69 -0
- fetchpdf/retrieval/corresponding.py +349 -0
- fetchpdf/retrieval/engine.py +666 -0
- fetchpdf/retrieval/extract_images.py +593 -0
- fetchpdf/retrieval/fulltext_scan.py +472 -0
- fetchpdf/retrieval/goals.py +145 -0
- fetchpdf/retrieval/http.py +669 -0
- fetchpdf/retrieval/identifiers.py +149 -0
- fetchpdf/retrieval/ladder.json +362 -0
- fetchpdf/retrieval/linked_artifacts.py +144 -0
- fetchpdf/retrieval/llm_adjudicate.py +278 -0
- fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
- fetchpdf/retrieval/normalize.py +301 -0
- fetchpdf/retrieval/pdf_identity.py +533 -0
- fetchpdf/retrieval/pdf_text.py +242 -0
- fetchpdf/retrieval/provenance.py +185 -0
- fetchpdf/retrieval/ratelimit.py +113 -0
- fetchpdf/retrieval/repository_waf.py +152 -0
- fetchpdf/retrieval/request_drafts.py +304 -0
- fetchpdf/retrieval/resolve.py +481 -0
- fetchpdf/retrieval/sources/__init__.py +15 -0
- fetchpdf/retrieval/sources/arxiv.py +102 -0
- fetchpdf/retrieval/sources/core_text.py +64 -0
- fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
- fetchpdf/retrieval/sources/epmc.py +133 -0
- fetchpdf/retrieval/sources/landing.py +34 -0
- fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
- fetchpdf/retrieval/sources/pmc.py +59 -0
- fetchpdf/retrieval/sources/preprint.py +101 -0
- fetchpdf/retrieval/sources/publisher_html.py +154 -0
- fetchpdf/retrieval/sources/supplements.py +183 -0
- fetchpdf/retrieval/supplement_atypon.py +269 -0
- fetchpdf/retrieval/supplement_dspace.py +187 -0
- fetchpdf/retrieval/supplement_graph.py +842 -0
- fetchpdf/retrieval/supplement_index.py +820 -0
- fetchpdf/retrieval/supplement_pmc.py +351 -0
- fetchpdf/retrieval/supplement_publishers.py +374 -0
- fetchpdf/retrieval/supplementary.py +1770 -0
- fetchpdf/retrieval/tiers.py +213 -0
- fetchpdf/retrieval/to_markdown.py +661 -0
- fetchpdf/retrieval/validate.py +368 -0
- fetchpdf/retrieval/verify.py +149 -0
- fetchpdf-0.1.0.dist-info/METADATA +1392 -0
- fetchpdf-0.1.0.dist-info/RECORD +64 -0
- fetchpdf-0.1.0.dist-info/WHEEL +5 -0
- fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
- fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
- fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fetchpdf
|
|
3
|
+
Version: 0.1.0
|
|
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: playwright>=1.40.0
|
|
24
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
25
|
+
Requires-Dist: pypdf>=4.0
|
|
26
|
+
Provides-Extra: html
|
|
27
|
+
Requires-Dist: lxml>=4.9; extra == "html"
|
|
28
|
+
Provides-Extra: images
|
|
29
|
+
Requires-Dist: pymupdf>=1.24; extra == "images"
|
|
30
|
+
Provides-Extra: text
|
|
31
|
+
Requires-Dist: pymupdf>=1.24; extra == "text"
|
|
32
|
+
Requires-Dist: pypdf>=4.0; extra == "text"
|
|
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
|
+
- [Usage Examples](#usage-examples)
|
|
52
|
+
- [Batch Processing & Parallel Execution](#batch-processing--parallel-execution)
|
|
53
|
+
- [Format-Prioritized Retrieval](#format-prioritized-retrieval)
|
|
54
|
+
- [Supplementary Material](#supplementary-material)
|
|
55
|
+
- [`--pull-everything`: the second layer](#--pull-everything-the-second-layer)
|
|
56
|
+
- [API Reference](#api-reference)
|
|
57
|
+
- [Download Sources](#download-sources-in-order-of-priority)
|
|
58
|
+
- [Troubleshooting](#troubleshooting)
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- 🔍 **Multiple Sources**: Automatically tries 10+ sources including:
|
|
63
|
+
- OSF (Open Science Framework) - Projects & Preprints
|
|
64
|
+
- PubMed Central / Europe PMC
|
|
65
|
+
- OpenAlex
|
|
66
|
+
- Unpaywall
|
|
67
|
+
- Crossref
|
|
68
|
+
- Semantic Scholar
|
|
69
|
+
- Figshare
|
|
70
|
+
- SSRN (Social Science Research Network)
|
|
71
|
+
|
|
72
|
+
- 🔄 **Smart Fallback**: If one source fails, automatically tries the next
|
|
73
|
+
- 🧬 **Format Prioritization**: `--prioritize-xml` prefers structured full text (JATS/TEI) over PDF — see [below](#format-prioritized-retrieval)
|
|
74
|
+
- 📎 **Supplementary Material**: `--pull-supplementary` fetches every supplementary file alongside the paper — see [below](#supplementary-material)
|
|
75
|
+
- 🔗 **Linked Datasets & Code**: 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 — see [below](#linked-datasets-and-code-stem_linked_artifactsjson)
|
|
76
|
+
- 🤖 **LLM-assisted retrieval**: `--pull-everything` adds a second layer — a model reads the paper itself and goes after the SI and datasets the APIs missed — see [below](#--pull-everything-the-second-layer)
|
|
77
|
+
- 🎭 **Browser Automation**: Uses Playwright to bypass JavaScript-based protections
|
|
78
|
+
- 🚀 **Batch Processing**: Process multiple DOIs/PMIDs from CSV files or lists
|
|
79
|
+
- ⚡ **Parallel Execution**: Download multiple papers simultaneously with configurable workers
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install fetchpdf
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Or, to work from source:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
git clone https://github.com/The-Metascience-Observatory/fetchpdf.git
|
|
91
|
+
cd fetchpdf
|
|
92
|
+
pip install -e .
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Post-Installation: Install Playwright Browsers
|
|
96
|
+
|
|
97
|
+
After installing the package, you need to install Playwright browsers:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
playwright install chromium
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Configuration: `.env.local`
|
|
104
|
+
|
|
105
|
+
Create a `.env.local` file in the project root to configure API keys and settings. API keys are **optional** but significantly improve rate limits and reliability:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# Required — used by Unpaywall (mandatory) and Crossref polite pool (10 req/s vs 5)
|
|
109
|
+
EMAIL=your@email.com
|
|
110
|
+
|
|
111
|
+
# Optional — improves rate limits / avoids throttling
|
|
112
|
+
OPENALEXAPIKEY=your_openalex_key # OpenAlex: avoids 429 rate-limit errors
|
|
113
|
+
SEMANTIC_SCHOLAR_API_KEY=your_s2_key # Semantic Scholar: 1 → 100 req/s
|
|
114
|
+
ENTREZ_EUTILS_API_KEY=your_ncbi_key # NCBI E-utilities: 3 → 10 req/s
|
|
115
|
+
COREAPIKEY=your_core_key # CORE: 40M+ OA papers from core.ac.uk
|
|
116
|
+
|
|
117
|
+
# Optional — publisher-specific
|
|
118
|
+
ELSEVIER_TDM_API_KEY=your_elsevier_key # Elsevier text/data-mining access
|
|
119
|
+
|
|
120
|
+
# Optional — only for --pull-everything --llm-backend openrouter
|
|
121
|
+
OPENROUTER_API_KEY=your_openrouter_key # the retrieval agent's second backend
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Rate limit improvements with API keys:**
|
|
125
|
+
|
|
126
|
+
| API | Without Key | With Key | How to Get |
|
|
127
|
+
|-----|-------------|----------|------------|
|
|
128
|
+
| Crossref | 5 req/s | 10 req/s (polite pool) | Just set `EMAIL` |
|
|
129
|
+
| OpenAlex | Severe throttling | Normal | [openalex.org/users](https://openalex.org/users) |
|
|
130
|
+
| Semantic Scholar | 1 req/s | 100 req/s | [semanticscholar.org/product/api](https://www.semanticscholar.org/product/api) |
|
|
131
|
+
| NCBI E-utilities | 3 req/s | 10 req/s | [ncbi.nlm.nih.gov/account](https://www.ncbi.nlm.nih.gov/account/) |
|
|
132
|
+
| CORE | Unavailable | 1,000 tokens/day | [core.ac.uk/services/api](https://core.ac.uk/services/api) |
|
|
133
|
+
|
|
134
|
+
## Quick Start
|
|
135
|
+
|
|
136
|
+
### Command Line Interface
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# Batch download from CSV (auto-detected by .csv extension)
|
|
140
|
+
fetchpdf papers.csv -o ./pdfs
|
|
141
|
+
fetchpdf papers.csv -o ./pdfs -w 4 # 4 parallel workers
|
|
142
|
+
|
|
143
|
+
# Single DOI or PMID
|
|
144
|
+
fetchpdf "10.1038/nature12373" # saves to ./pdfs/
|
|
145
|
+
fetchpdf "10.1038/nature12373" -o ./papers # custom output dir
|
|
146
|
+
fetchpdf "10.1038/nature12373" output.pdf # custom filename
|
|
147
|
+
fetchpdf 33262244 # PMID auto-resolved to DOI
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Getting more than the PDF
|
|
151
|
+
|
|
152
|
+
By default a record yields one file: the best PDF the source chain finds. Three
|
|
153
|
+
flags widen that, and they compose — use all three together to get the paper in
|
|
154
|
+
both formats plus everything deposited with it.
|
|
155
|
+
|
|
156
|
+
**1. Structured full text (XML / HTML) instead of, or alongside, the PDF**
|
|
157
|
+
|
|
158
|
+
A PDF has no tables, only ink that looks like tables. For anything that parses
|
|
159
|
+
the paper, ask for markup:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Keep BOTH a structured copy (JATS/TEI XML, else publisher HTML) AND the PDF
|
|
163
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html
|
|
164
|
+
|
|
165
|
+
# Prefer structured full text, but accept a PDF when there is none
|
|
166
|
+
fetchpdf papers.csv -o ./out --prioritize-xml
|
|
167
|
+
|
|
168
|
+
# Structured only — fail the record rather than write a PDF
|
|
169
|
+
fetchpdf papers.csv -o ./out --xml-html-only # XML or publisher HTML
|
|
170
|
+
fetchpdf papers.csv -o ./out --xml-only # XML, nothing else
|
|
171
|
+
|
|
172
|
+
# Also render each XML/HTML artifact to {stem}.md for an LLM
|
|
173
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html --to-markdown
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Artifacts land as suffixed siblings: `{stem}.xml`, `{stem}.fulltext.html`,
|
|
177
|
+
`{stem}.pdf`, `{stem}.md`. `--get-xml-or-html` is also a backfill — pointed at a
|
|
178
|
+
directory of PDFs you already have, it fetches only the missing structured half.
|
|
179
|
+
Publisher HTML needs `pip install 'fetchpdf[html]'`; without it that tier is skipped
|
|
180
|
+
with a logged reason. Full detail: [Format-Prioritized Retrieval](#format-prioritized-retrieval).
|
|
181
|
+
|
|
182
|
+
**2. Supplementary material (SI/SM)**
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
# Every supplementary file the SI endpoints offer: PDFs, spreadsheets, docs, data
|
|
186
|
+
fetchpdf papers.csv -o ./out --pull-supplementary
|
|
187
|
+
|
|
188
|
+
# Raise or lower the per-file cap (default 300 MB)
|
|
189
|
+
fetchpdf papers.csv -o ./out --pull-supplementary --max-supplementary-mb 500
|
|
190
|
+
|
|
191
|
+
# Pick up files deposited since an earlier run
|
|
192
|
+
fetchpdf papers.csv -o ./out --refresh-supplementary
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Files arrive numbered — `{stem}_supplementary_info_1.xlsx`, `_2.docx`, … — with
|
|
196
|
+
`{stem}_supplementary_info.json` recording what each number originally was, plus
|
|
197
|
+
what was *skipped and why*. The numbering discards original filenames, so
|
|
198
|
+
[read the manifest](#the-manifest-is-not-optional-reading). This runs for records
|
|
199
|
+
whose PDF is already on disk, so it collects supplements for an old corpus
|
|
200
|
+
without re-fetching a single paper, and a second run over the same directory
|
|
201
|
+
costs zero requests.
|
|
202
|
+
|
|
203
|
+
**3. Linked datasets and code**
|
|
204
|
+
|
|
205
|
+
The supplementary pass also asks the link services (ScholeXplorer, Europe PMC
|
|
206
|
+
datalinks, DataCite) what datasets and software each paper is connected to, and
|
|
207
|
+
writes every answer to `{stem}_linked_artifacts.json`. Deposits affirmatively
|
|
208
|
+
identified as *the paper's own* are downloaded and numbered alongside the
|
|
209
|
+
supplements. `--download-data-artifacts` goes further:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# Supplements + the paper's own linked deposits (Zenodo, Dryad, OSF, figshare, Dataverse)
|
|
213
|
+
fetchpdf papers.csv -o ./out --pull-supplementary
|
|
214
|
+
|
|
215
|
+
# Also take citation-grade deposits whose own metadata names this article,
|
|
216
|
+
# and repositories named in the paper's own full text
|
|
217
|
+
fetchpdf papers.csv -o ./out --download-data-artifacts
|
|
218
|
+
|
|
219
|
+
# Per-record dataset cap (default 500 MB), counted separately from supplements
|
|
220
|
+
fetchpdf papers.csv -o ./out --download-data-artifacts --max-data-artifact-mb 2000
|
|
221
|
+
|
|
222
|
+
# Expand replication-package zips (off by default — they have their own layout)
|
|
223
|
+
fetchpdf papers.csv -o ./out --download-data-artifacts --unpack-data-artifacts
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`--download-data-artifacts` implies `--pull-supplementary`. What is *not*
|
|
227
|
+
downloaded still gets recorded: cited-but-not-owned datasets, tool citations,
|
|
228
|
+
trial registrations. See [Linked datasets and code](#linked-datasets-and-code-stem_linked_artifactsjson).
|
|
229
|
+
|
|
230
|
+
**All three at once**, which is the usual corpus-building invocation:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
fetchpdf papers.csv -o ./out -w 4 \
|
|
234
|
+
--get-xml-or-html --to-markdown \
|
|
235
|
+
--download-data-artifacts \
|
|
236
|
+
--make-subfolder --provenance
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
out/10.1371--journal.pone.0000308/
|
|
241
|
+
10.1371--journal.pone.0000308.pdf <- the paper
|
|
242
|
+
10.1371--journal.pone.0000308.xml <- structured full text
|
|
243
|
+
10.1371--journal.pone.0000308.md <- prose + HTML tables
|
|
244
|
+
10.1371--journal.pone.0000308_supplementary_info_1.xls
|
|
245
|
+
10.1371--journal.pone.0000308_supplementary_info_2.csv <- from a Dryad deposit
|
|
246
|
+
10.1371--journal.pone.0000308_supplementary_info.json <- what each number is
|
|
247
|
+
10.1371--journal.pone.0000308_linked_artifacts.json <- every dataset/code link
|
|
248
|
+
10.1371--journal.pone.0000308.provenance.json <- source, tier, hashes
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Supplements and datasets never change whether a record counts as a success: a
|
|
252
|
+
paper with no SI is not a failure, and a repository timing out cannot turn a
|
|
253
|
+
downloaded paper into a failed one.
|
|
254
|
+
|
|
255
|
+
### Python API
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from fetchpdf import fetch_pdf
|
|
259
|
+
|
|
260
|
+
# Download a PDF from a DOI (PMID also supported)
|
|
261
|
+
doi = "10.1038/nature12373"
|
|
262
|
+
save_path = "./papers/nature_paper.pdf"
|
|
263
|
+
|
|
264
|
+
result = fetch_pdf(
|
|
265
|
+
doi=doi,
|
|
266
|
+
save_path=save_path,
|
|
267
|
+
verbose=True,
|
|
268
|
+
delay=0.1 # Polite delay between API calls
|
|
269
|
+
)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The extra formats and the supplementary pass are keyword arguments on the batch
|
|
273
|
+
entry point — `--pull-supplementary` is plumbed through `batch_fetch_pdfs` only,
|
|
274
|
+
so use it even for a single DOI:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
from fetchpdf import batch_fetch_pdfs
|
|
278
|
+
|
|
279
|
+
results = batch_fetch_pdfs(
|
|
280
|
+
dois=["10.1371/journal.pone.0000308"],
|
|
281
|
+
output_dir="./out",
|
|
282
|
+
get_xml_or_html=True, # structured copy AND the PDF
|
|
283
|
+
to_markdown=True, # plus {stem}.md
|
|
284
|
+
pull_supplementary=True, # SI/SM as numbered siblings + manifest
|
|
285
|
+
download_data_artifacts=True, # and the paper's linked deposits
|
|
286
|
+
max_supplementary_bytes=300 * 1024 * 1024,
|
|
287
|
+
want_provenance=True,
|
|
288
|
+
verbose=True,
|
|
289
|
+
)
|
|
290
|
+
# -> [(doi, success, save_path), ...]
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
To collect supplements for one record you already have on disk, call
|
|
294
|
+
`pull_supplementary_for` directly — see [API Reference](#api-reference).
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
### PMID Support
|
|
299
|
+
|
|
300
|
+
You can provide a PMID anywhere a DOI is accepted. The tool first resolves PMID -> DOI (via NCBI), then runs the normal DOI download flow.
|
|
301
|
+
|
|
302
|
+
**CLI examples:**
|
|
303
|
+
```bash
|
|
304
|
+
# Plain PMID
|
|
305
|
+
fetchpdf "33262244" -o ./pdfs -v
|
|
306
|
+
|
|
307
|
+
# PMID with prefix
|
|
308
|
+
fetchpdf "PMID:33262244" -o ./pdfs
|
|
309
|
+
|
|
310
|
+
# PubMed URL
|
|
311
|
+
fetchpdf "https://pubmed.ncbi.nlm.nih.gov/33262244/" -o ./pdfs
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
If a PMID cannot be resolved to a DOI, that identifier will fail quickly and be reported in batch mode.
|
|
315
|
+
|
|
316
|
+
**Batch Processing from CSV:**
|
|
317
|
+
```bash
|
|
318
|
+
# Sequential processing
|
|
319
|
+
fetchpdf dois.csv -o ./pdfs
|
|
320
|
+
|
|
321
|
+
# Parallel processing with 4 workers
|
|
322
|
+
fetchpdf dois.csv -o ./pdfs -w 4
|
|
323
|
+
|
|
324
|
+
# Custom DOI column name
|
|
325
|
+
fetchpdf papers.csv --doi-column "paper_doi" -o ./pdfs -w 2
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
## Usage Examples
|
|
330
|
+
|
|
331
|
+
### Example 1: Download Multiple Papers
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
from fetchpdf import fetch_pdf
|
|
335
|
+
import os
|
|
336
|
+
|
|
337
|
+
dois = [
|
|
338
|
+
"10.1038/nature12373",
|
|
339
|
+
"10.1126/science.1241224",
|
|
340
|
+
"10.1016/j.cell.2019.05.031"
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
output_dir = "./papers"
|
|
344
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
345
|
+
|
|
346
|
+
for doi in dois:
|
|
347
|
+
safe_doi = doi.replace("/", "--")
|
|
348
|
+
save_path = os.path.join(output_dir, f"{safe_doi}.pdf")
|
|
349
|
+
|
|
350
|
+
print(f"Downloading {doi}...")
|
|
351
|
+
result = fetch_pdf(doi, save_path, verbose=True)
|
|
352
|
+
|
|
353
|
+
if result:
|
|
354
|
+
print(f"✅ Success: {save_path}")
|
|
355
|
+
else:
|
|
356
|
+
print(f"❌ Failed: {doi}")
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Example 2: Batch Processing with Parallel Execution From CSV
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
from fetchpdf import batch_fetch_pdfs
|
|
363
|
+
|
|
364
|
+
# Parallel processing with 4 workers
|
|
365
|
+
results = batch_fetch_pdfs(
|
|
366
|
+
dois="papers.csv",
|
|
367
|
+
output_dir="./downloaded_papers",
|
|
368
|
+
verbose=False,
|
|
369
|
+
workers=4, # 4 parallel downloads
|
|
370
|
+
delay=0.2 # Delay per worker
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Get failed DOIs
|
|
374
|
+
failed_dois = [doi for doi, success, _ in results if not success]
|
|
375
|
+
print(f"Failed: {len(failed_dois)} DOIs")
|
|
376
|
+
|
|
377
|
+
# Save failed DOIs for retry
|
|
378
|
+
if failed_dois:
|
|
379
|
+
import csv
|
|
380
|
+
with open("failed_dois.csv", "w", newline="") as f:
|
|
381
|
+
writer = csv.writer(f)
|
|
382
|
+
writer.writerow(["DOI"])
|
|
383
|
+
writer.writerows([doi] for doi in failed_dois)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Example 3: Batch Processing with DOI List
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
from fetchpdf import batch_fetch_pdfs
|
|
390
|
+
|
|
391
|
+
# Process a list of DOIs directly
|
|
392
|
+
dois = [
|
|
393
|
+
"10.1038/nature12373",
|
|
394
|
+
"10.1126/science.1241224",
|
|
395
|
+
"10.1016/j.cell.2019.05.031"
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
results = batch_fetch_pdfs(
|
|
399
|
+
dois=dois, # Pass list directly
|
|
400
|
+
output_dir="./papers",
|
|
401
|
+
workers=3 # Use 3 parallel workers
|
|
402
|
+
)
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
### Example 3: Download with Metadata
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
from fetchpdf import fetch_pdf, fetch_metadata_from_doi
|
|
410
|
+
import os
|
|
411
|
+
|
|
412
|
+
doi = "10.1038/nature12373"
|
|
413
|
+
|
|
414
|
+
# Get metadata first
|
|
415
|
+
metadata = fetch_metadata_from_doi(doi)
|
|
416
|
+
print(f"Downloading: {metadata['title']}")
|
|
417
|
+
|
|
418
|
+
# Use metadata for filename
|
|
419
|
+
safe_title = metadata['title'][:50].replace(" ", "_").replace("/", "_")
|
|
420
|
+
save_path = f"./papers/{safe_title}.pdf"
|
|
421
|
+
|
|
422
|
+
# Download PDF
|
|
423
|
+
result = fetch_pdf(doi, save_path)
|
|
424
|
+
|
|
425
|
+
if result:
|
|
426
|
+
print(f"✅ Downloaded: {metadata['title']}")
|
|
427
|
+
print(f" Authors: {metadata['authors']}")
|
|
428
|
+
print(f" Year: {metadata['year']}")
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### Example 4: Using the Missing PDFs Report
|
|
432
|
+
|
|
433
|
+
When batch processing completes, an HTML report is automatically created for any failed downloads:
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
from fetchpdf import batch_fetch_pdfs
|
|
437
|
+
|
|
438
|
+
results = batch_fetch_pdfs(
|
|
439
|
+
dois="papers.csv",
|
|
440
|
+
output_dir="./papers",
|
|
441
|
+
workers=4
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# After completion, check ./papers/missing_pdfs.html
|
|
445
|
+
# The report includes:
|
|
446
|
+
# - Paper title, authors, journal, year
|
|
447
|
+
# - Clickable DOI links
|
|
448
|
+
# - Expected filename for manual download
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**The report helps you:**
|
|
452
|
+
- Quickly identify which papers failed
|
|
453
|
+
- Click DOI links to manually download
|
|
454
|
+
- See expected filenames for manual organization
|
|
455
|
+
- View paper metadata even without the PDF
|
|
456
|
+
|
|
457
|
+
To disable the report:
|
|
458
|
+
```python
|
|
459
|
+
results = batch_fetch_pdfs(
|
|
460
|
+
dois="papers.csv",
|
|
461
|
+
output_dir="./papers",
|
|
462
|
+
create_missing_report=False # Disable report
|
|
463
|
+
)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Or via command line:
|
|
467
|
+
```bash
|
|
468
|
+
fetchpdf papers.csv -o ./papers --no-missing-report
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Batch Processing & Parallel Execution
|
|
472
|
+
|
|
473
|
+
This package supports both **batch processing** and **parallel execution** for downloading multiple PDFs efficiently.
|
|
474
|
+
|
|
475
|
+
### Batch Processing Capabilities
|
|
476
|
+
|
|
477
|
+
#### 1. Process List of DOIs
|
|
478
|
+
|
|
479
|
+
**Python API:**
|
|
480
|
+
```python
|
|
481
|
+
from fetchpdf import batch_fetch_pdfs
|
|
482
|
+
|
|
483
|
+
dois = ["10.1038/nature12373", "10.1126/science.1241224", "10.1016/j.cell.2019.05.031"]
|
|
484
|
+
|
|
485
|
+
results = batch_fetch_pdfs(
|
|
486
|
+
dois=dois,
|
|
487
|
+
output_dir="./papers",
|
|
488
|
+
workers=1 # Sequential
|
|
489
|
+
)
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
#### 2. Process CSV File
|
|
493
|
+
|
|
494
|
+
**CSV format:**
|
|
495
|
+
```csv
|
|
496
|
+
DOI
|
|
497
|
+
10.1038/nature12373
|
|
498
|
+
10.1126/science.1241224
|
|
499
|
+
10.1016/j.cell.2019.05.031
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
**Python API:**
|
|
503
|
+
```python
|
|
504
|
+
from fetchpdf import batch_fetch_pdfs
|
|
505
|
+
|
|
506
|
+
results = batch_fetch_pdfs(
|
|
507
|
+
dois="papers.csv", # Path to CSV
|
|
508
|
+
output_dir="./papers",
|
|
509
|
+
workers=1
|
|
510
|
+
)
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Command Line:**
|
|
514
|
+
```bash
|
|
515
|
+
fetchpdf papers.csv -o ./papers
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
#### 3. Custom DOI Column Name
|
|
519
|
+
|
|
520
|
+
If your CSV uses a different column name:
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
fetchpdf data.csv --doi-column "paper_doi" -o ./pdfs
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Parallel Execution
|
|
527
|
+
|
|
528
|
+
#### Why Use Parallel Execution?
|
|
529
|
+
|
|
530
|
+
- **Faster downloads**: Download multiple PDFs simultaneously
|
|
531
|
+
- **Better resource utilization**: Make use of network I/O waiting time
|
|
532
|
+
- **Configurable workers**: Control parallelism based on your needs
|
|
533
|
+
|
|
534
|
+
#### Performance Comparison
|
|
535
|
+
|
|
536
|
+
**Example: Downloading 100 PDFs**
|
|
537
|
+
|
|
538
|
+
| Workers | Time | Speedup |
|
|
539
|
+
|---------|------|---------|
|
|
540
|
+
| 1 (sequential) | ~500 seconds | 1x |
|
|
541
|
+
| 2 workers | ~260 seconds | 1.9x |
|
|
542
|
+
| 4 workers | ~140 seconds | 3.6x |
|
|
543
|
+
| 8 workers | ~80 seconds | 6.3x |
|
|
544
|
+
|
|
545
|
+
*Note: Actual speedup depends on network speed, API rate limits, and source availability.*
|
|
546
|
+
|
|
547
|
+
#### Python API - Parallel
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
from fetchpdf import batch_fetch_pdfs
|
|
551
|
+
|
|
552
|
+
# Use 4 parallel workers
|
|
553
|
+
results = batch_fetch_pdfs(
|
|
554
|
+
dois="papers.csv",
|
|
555
|
+
output_dir="./papers",
|
|
556
|
+
workers=4, # 4 parallel downloads
|
|
557
|
+
delay=0.2 # Delay between API calls per worker
|
|
558
|
+
)
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
#### Command Line - Parallel
|
|
562
|
+
|
|
563
|
+
```bash
|
|
564
|
+
# Use 4 parallel workers
|
|
565
|
+
fetchpdf papers.csv -o ./papers -w 4
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Best Practices
|
|
569
|
+
|
|
570
|
+
#### 1. Start with Sequential Processing
|
|
571
|
+
|
|
572
|
+
For small batches (< 20 DOIs), sequential processing is sufficient:
|
|
573
|
+
|
|
574
|
+
```python
|
|
575
|
+
results = batch_fetch_pdfs(dois=dois, output_dir="./papers", workers=1)
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
#### 2. Use Moderate Parallelism
|
|
579
|
+
|
|
580
|
+
For large batches, use 2-4 workers:
|
|
581
|
+
|
|
582
|
+
```python
|
|
583
|
+
results = batch_fetch_pdfs(dois=dois, output_dir="./papers", workers=4)
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
**Why not more?**
|
|
587
|
+
- API rate limits
|
|
588
|
+
- Respectful to servers
|
|
589
|
+
- Diminishing returns beyond 4-8 workers
|
|
590
|
+
|
|
591
|
+
#### 3. Increase Delay for Larger Workers
|
|
592
|
+
|
|
593
|
+
More workers = more simultaneous requests. Be polite to APIs:
|
|
594
|
+
|
|
595
|
+
```python
|
|
596
|
+
results = batch_fetch_pdfs(
|
|
597
|
+
dois=dois,
|
|
598
|
+
output_dir="./papers",
|
|
599
|
+
workers=8,
|
|
600
|
+
delay=0.5 # Longer delay with more workers
|
|
601
|
+
)
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
#### 4. Handle Failures and Retry
|
|
605
|
+
|
|
606
|
+
```python
|
|
607
|
+
# First attempt
|
|
608
|
+
results = batch_fetch_pdfs(dois=dois, output_dir="./papers", workers=4)
|
|
609
|
+
|
|
610
|
+
# Get failed DOIs
|
|
611
|
+
failed_dois = [doi for doi, success, _ in results if not success]
|
|
612
|
+
|
|
613
|
+
if failed_dois:
|
|
614
|
+
print(f"Retrying {len(failed_dois)} failed DOIs...")
|
|
615
|
+
|
|
616
|
+
# Retry with sequential processing and longer delay
|
|
617
|
+
retry_results = batch_fetch_pdfs(
|
|
618
|
+
dois=failed_dois,
|
|
619
|
+
output_dir="./papers",
|
|
620
|
+
workers=1,
|
|
621
|
+
delay=1.0,
|
|
622
|
+
verbose=True # See what's happening
|
|
623
|
+
)
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
#### 5. Save Results for Tracking
|
|
627
|
+
|
|
628
|
+
```python
|
|
629
|
+
import csv
|
|
630
|
+
|
|
631
|
+
results = batch_fetch_pdfs(dois="papers.csv", output_dir="./papers", workers=4)
|
|
632
|
+
|
|
633
|
+
# Save results
|
|
634
|
+
with open("download_results.csv", "w", newline="") as f:
|
|
635
|
+
writer = csv.writer(f)
|
|
636
|
+
writer.writerow(["DOI", "Success", "Path"])
|
|
637
|
+
writer.writerows(results)
|
|
638
|
+
|
|
639
|
+
# Statistics
|
|
640
|
+
successes = sum(1 for _, success, _ in results if success)
|
|
641
|
+
print(f"Success: {successes}/{len(results)}")
|
|
642
|
+
print(f"Success rate: {successes/len(results)*100:.1f}%")
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
## Format-Prioritized Retrieval
|
|
646
|
+
|
|
647
|
+
The default chain walks *sources* and takes the first PDF each one yields. That
|
|
648
|
+
is right for a paper you want to read, and wrong for feeding a table-extraction
|
|
649
|
+
pipeline: what breaks there is not character accuracy but row/column/header
|
|
650
|
+
association, and that only survives losslessly in markup.
|
|
651
|
+
|
|
652
|
+
`--prioritize-xml` inverts the loop — format tiers outer, sources inner — so a
|
|
653
|
+
JATS copy from a worse-ranked source beats a PDF from a better-ranked one.
|
|
654
|
+
|
|
655
|
+
```bash
|
|
656
|
+
# Prefer structured full text, fall back through the normal chain
|
|
657
|
+
fetchpdf papers.csv -o ./out --prioritize-xml
|
|
658
|
+
|
|
659
|
+
# Structured XML or nothing
|
|
660
|
+
fetchpdf papers.csv -o ./out --xml-only
|
|
661
|
+
|
|
662
|
+
# XML or publisher HTML, but never a PDF
|
|
663
|
+
fetchpdf papers.csv -o ./out --xml-html-only
|
|
664
|
+
|
|
665
|
+
# Re-run over an existing directory, writing only genuine upgrades
|
|
666
|
+
fetchpdf papers.csv -o ./out --upgrade-existing
|
|
667
|
+
|
|
668
|
+
# Audit sidecar per record
|
|
669
|
+
fetchpdf papers.csv -o ./out --prioritize-xml --provenance
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Getting both formats, and Markdown for the model
|
|
673
|
+
|
|
674
|
+
```bash
|
|
675
|
+
# A structured copy AND the PDF for every record
|
|
676
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html
|
|
677
|
+
|
|
678
|
+
# ...and a {stem}.md for each XML/HTML artifact
|
|
679
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html --to-markdown
|
|
680
|
+
|
|
681
|
+
# Convert artifacts you already have, without re-downloading
|
|
682
|
+
fetchpdf-md ./out
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
`--get-xml-or-html` doubles as a backfill: pointed at a directory of existing
|
|
686
|
+
PDFs it fetches only the missing structured half and re-downloads nothing.
|
|
687
|
+
|
|
688
|
+
### Re-running over a directory you already have
|
|
689
|
+
|
|
690
|
+
There is no ledger and nothing records which flags built a directory. Skipping
|
|
691
|
+
is decided per record from the filesystem: if an artifact already sits at the
|
|
692
|
+
record's stem, that record is skipped. This is what makes an interrupted run
|
|
693
|
+
resumable — re-run the same command and it picks up where it stopped — and it
|
|
694
|
+
works identically flat or under `--make-subfolder`, since the two layouts differ
|
|
695
|
+
by one path segment.
|
|
696
|
+
|
|
697
|
+
The silent part is that a skip also skips the *choice*. A directory of papers
|
|
698
|
+
you fetched last month can gain its supplementary material without re-fetching a
|
|
699
|
+
single PDF, because the SI pass runs on the skip branch — but nothing says so.
|
|
700
|
+
So a re-run that is about to skip records asks:
|
|
701
|
+
|
|
702
|
+
```
|
|
703
|
+
📁 1,284 of 5,000 records already have artifacts in ./out.
|
|
704
|
+
|
|
705
|
+
[s] skip them download only the 3,716 still missing (default)
|
|
706
|
+
[m] skip, but pull SI/SM keep the papers, fetch supplementary material for all of them
|
|
707
|
+
[a] abort
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Answering `m` is exactly `--pull-supplementary`: every PDF already on disk stays
|
|
711
|
+
untouched, and records whose SI manifest is already written cost zero requests.
|
|
712
|
+
|
|
713
|
+
`--on-existing {skip,supplement,ask}` pre-answers it. **The prompt never appears
|
|
714
|
+
unless stdin and stdout are both terminals** — pipes, cron and CI get a one-line
|
|
715
|
+
count and today's behaviour, so no unattended run can block on it. It is also
|
|
716
|
+
suppressed when the answer is already known: `--pull-supplementary` given
|
|
717
|
+
explicitly, `--abstract-only`, or the goal-aware flags (`--get-xml-or-html`,
|
|
718
|
+
`--upgrade-existing` on the tiered path), which do not skip records at all.
|
|
719
|
+
|
|
720
|
+
What is *not* remembered between runs: failures. `failed_dois.csv` is a report,
|
|
721
|
+
never read back, so every re-run retries every previous failure. Feed it back in
|
|
722
|
+
deliberately if you want only the retries.
|
|
723
|
+
|
|
724
|
+
**Markdown output keeps tables as HTML, deliberately.** Markdown has no span
|
|
725
|
+
mechanism, so a `<th colspan="2">` over two treatment arms collapses and every
|
|
726
|
+
value to its right shifts one column — silently, in a way that still parses as a
|
|
727
|
+
valid table. Prose becomes Markdown; every table stays canonical minimal HTML at
|
|
728
|
+
its position in the document, with its caption and footnotes attached. Inline
|
|
729
|
+
HTML is part of the CommonMark spec, so this is ordinary Markdown, not a hybrid.
|
|
730
|
+
|
|
731
|
+
Every table is HTML, including simple ones with no spans. Mixing pipes and HTML
|
|
732
|
+
would make a three-column header ambiguous — no spans, or spans lost in
|
|
733
|
+
conversion? — and that ambiguity is worse than either format alone.
|
|
734
|
+
|
|
735
|
+
Each `.md` opens with front matter recording table count, tables that were
|
|
736
|
+
published as images (not machine-readable), figures referenced but not included,
|
|
737
|
+
and separate prose/table token counts:
|
|
738
|
+
|
|
739
|
+
```yaml
|
|
740
|
+
---
|
|
741
|
+
source_artifact: 10.3390--biom12111676.xml
|
|
742
|
+
table_format: canonical-html
|
|
743
|
+
tables: 2
|
|
744
|
+
tables_not_machine_readable: 0
|
|
745
|
+
figures_referenced_not_included: 1
|
|
746
|
+
prose_tokens: 11980
|
|
747
|
+
table_tokens: 189
|
|
748
|
+
---
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
JATS references images and never contains them, so figures appear as visible
|
|
752
|
+
placeholders rather than vanishing — a record whose key outcome sits in a forest
|
|
753
|
+
plot should look incomplete rather than complete.
|
|
754
|
+
|
|
755
|
+
### Choosing a flag
|
|
756
|
+
|
|
757
|
+
| Flag | Accepts | Falls back to PDF? |
|
|
758
|
+
|------|---------|--------------------|
|
|
759
|
+
| `--prioritize-xml` | everything, best format first | yes |
|
|
760
|
+
| `--xml-html-only` | T1 XML, T2 HTML | **no** — record fails |
|
|
761
|
+
| `--xml-only` | T1 XML | **no** — record fails |
|
|
762
|
+
| `--get-xml-or-html` | structured **and** PDF, both kept | yes, and keeps it |
|
|
763
|
+
|
|
764
|
+
`--xml-only` and `--xml-html-only` both imply `--prioritize-xml`. Given both,
|
|
765
|
+
`--xml-only` is stricter and wins (with a warning).
|
|
766
|
+
|
|
767
|
+
### The tier ladder
|
|
768
|
+
|
|
769
|
+
| Tier | Format | Why it sits here |
|
|
770
|
+
|------|--------|------------------|
|
|
771
|
+
| T1 | JATS/TEI XML | Cells, spans, headers and footnotes are markup. The only lossless tier. |
|
|
772
|
+
| T2 | Publisher HTML | Real `<table>` with `<th>` and spans; at most OA publishers generated from the same JATS. |
|
|
773
|
+
| T3 | LaTeX / e-print source | Lossless in principle, macro-hostile in practice. |
|
|
774
|
+
| T4 | Structured supplements | Often the underlying data; coverage is partial. |
|
|
775
|
+
| T5 | PDF | Structure must be reconstructed; introduces undetectable numeric corruption. |
|
|
776
|
+
| T6 | Plain text | Flattening destroys row/column association **silently**. Screening only. |
|
|
777
|
+
| T7 | Landing page | Last resort. |
|
|
778
|
+
|
|
779
|
+
`--target-task` selects the ladder. `screening` ranks plain text 4th;
|
|
780
|
+
`extraction` refuses it outright rather than degrading into it — every number is
|
|
781
|
+
present but anchored to nothing, which yields confident, plausible, wrong
|
|
782
|
+
arm-to-outcome assignments.
|
|
783
|
+
|
|
784
|
+
Artifacts are written as suffixed siblings: `{stem}.xml`, `{stem}.fulltext.html`,
|
|
785
|
+
`{stem}.source.tar.gz`, `{stem}.suppl.zip`, `{stem}.pdf`, `{stem}.txt`.
|
|
786
|
+
|
|
787
|
+
### Reordering without editing code
|
|
788
|
+
|
|
789
|
+
The ladder, the per-source capability map and the per-host rate limits live in
|
|
790
|
+
[`fetchpdf/retrieval/ladder.json`](fetchpdf/retrieval/ladder.json). Point
|
|
791
|
+
`FETCHPDF_LADDER` at your own copy to override it.
|
|
792
|
+
|
|
793
|
+
### Validation
|
|
794
|
+
|
|
795
|
+
HTTP 200 is not evidence of full text. Before a T1 artifact is accepted it must
|
|
796
|
+
parse, have a populated `<body>`, clear a character threshold, and not be a
|
|
797
|
+
publisher denial stub — NCBI returns those with status 200, full metadata, no
|
|
798
|
+
body, and the explanation in an XML comment. T2 additionally requires at least
|
|
799
|
+
one `<table>` with populated `<td>`, which is what rejects JS-rendered table
|
|
800
|
+
containers and tables shipped as images. Failures demote and the walk continues;
|
|
801
|
+
nothing is written unclassified or unvalidated.
|
|
802
|
+
|
|
803
|
+
A PDF gets the same question asked of it: **is this the paper we asked for?**
|
|
804
|
+
`%PDF` and a size floor are a file-type check, not an identity check, and the
|
|
805
|
+
difference is not academic. A landing page's reference list is a list of other
|
|
806
|
+
people's PDFs; when the article's own copy is unavailable — paywalled,
|
|
807
|
+
bot-walled, moved — a link-scraping fallback will happily download one of them,
|
|
808
|
+
and it arrives as a genuine `application/pdf` with a 200. That is how
|
|
809
|
+
`10.1111/all.14949`, a Wiley allergy paper, came back as the USDA's 164-page
|
|
810
|
+
*Dietary Guidelines for Americans*, a work it cites, saved next to the correct
|
|
811
|
+
XML for the same record fetched in the same walk.
|
|
812
|
+
|
|
813
|
+
So every retrieved PDF is now checked against the record it was fetched for
|
|
814
|
+
(`retrieval/pdf_identity`). Any one signal is enough, because the documents
|
|
815
|
+
that legitimately fail one are common:
|
|
816
|
+
|
|
817
|
+
| signal | carries |
|
|
818
|
+
|---|---|
|
|
819
|
+
| the requested DOI in the front matter | essentially every version of record |
|
|
820
|
+
| the article title on the front pages | accepted manuscripts (`nihms-*.pdf`), which print the title and not the publisher's DOI |
|
|
821
|
+
| the title as a near match | Greek letters typeset in a Latin face, hyphenated line breaks, entity-mangled metadata |
|
|
822
|
+
| the title embedded in the PDF's own metadata | documents whose text layer is too thin to carry one |
|
|
823
|
+
|
|
824
|
+
The title costs no extra request: resolution has already memoised the Crossref
|
|
825
|
+
payload by the time a PDF is judged.
|
|
826
|
+
|
|
827
|
+
Refusals are kept distinct rather than collapsed into one message, because
|
|
828
|
+
*"this is a different paper"* is a bug signal, *"this document has no text
|
|
829
|
+
layer"* is a corpus-quality signal, and *"no PDF reader is installed"* is a
|
|
830
|
+
broken install — and they send whoever reads the report to three different
|
|
831
|
+
places. A document that says almost nothing machine-readable is reported as
|
|
832
|
+
unreadable, never as somebody else's: a scanned paper often extracts to nothing
|
|
833
|
+
but a library's download stamp, which is text, but not text that could ever
|
|
834
|
+
have carried a title.
|
|
835
|
+
|
|
836
|
+
The check refuses inside the candidate loop, not only at the end of the run.
|
|
837
|
+
Refusing only at the end would downgrade "wrong PDF" to "no PDF" — the right
|
|
838
|
+
copy is frequently two candidates further down, and the loop has to reach it.
|
|
839
|
+
Artifacts already on disk are never re-judged, so re-running over a corpus will
|
|
840
|
+
not delete files it did not fetch.
|
|
841
|
+
|
|
842
|
+
Measured over 500 main-article PDFs from real corpora: 95.6% verified, 3.4%
|
|
843
|
+
refused as a different document, 1.0% refused as illegible. The refusals were
|
|
844
|
+
inspected by hand and were overwhelmingly genuine — publisher marketing pages
|
|
845
|
+
("Why Publish in Spine?"), permissions boilerplate, a government citizens'
|
|
846
|
+
charter, an R package manual, a JSTOR literature article, and cited documents
|
|
847
|
+
like the one above.
|
|
848
|
+
|
|
849
|
+
Because verification is not optional, **a PDF text engine is no longer
|
|
850
|
+
optional**: `pypdf` is a base dependency and the CLI refuses to start without an
|
|
851
|
+
engine rather than rejecting every PDF one at a time. PyMuPDF remains in the
|
|
852
|
+
`text` extra as the faster reader and is preferred when installed.
|
|
853
|
+
|
|
854
|
+
#### The right article, but not all of it
|
|
855
|
+
|
|
856
|
+
A publisher's first-page preview is the hardest case, because every identity
|
|
857
|
+
signal passes it: it carries the article's own DOI, its own title, its own
|
|
858
|
+
journal furniture. It is simply two pages of seventeen, cut off mid-sentence,
|
|
859
|
+
with nothing anywhere in the text admitting as much. The same is true of an
|
|
860
|
+
article's own supplementary file, which is why an "APA supplemental PDF"
|
|
861
|
+
route can hand back four pages of a thirteen-page record and look like success.
|
|
862
|
+
|
|
863
|
+
Two structural signals catch these, both refusing with a `truncated` verdict
|
|
864
|
+
that is deliberately distinct from "wrong article" — it *is* the right article,
|
|
865
|
+
so the caller should keep walking the chain for a complete copy rather than
|
|
866
|
+
conclude the source was bad:
|
|
867
|
+
|
|
868
|
+
1. **Against the record's own structured full text**, when the walk already has
|
|
869
|
+
it. The strongest signal, because it compares the document with *itself* in a
|
|
870
|
+
format a paywall cannot truncate. Measured on records where both were
|
|
871
|
+
retrieved: complete PDFs scored 0.98, 1.02 and 1.04; a two-page Brill preview
|
|
872
|
+
of a thirty-two-page article scored **0.07**.
|
|
873
|
+
2. **Against the printed page range.** Available far more often, since most
|
|
874
|
+
records never get a structured copy. Over 456 verified-correct corpus PDFs
|
|
875
|
+
the ratios run 0.22, 0.31, 0.33, 0.33, 0.42 and then jump to 0.73, so the 0.5
|
|
876
|
+
threshold sits in open space rather than on a slope.
|
|
877
|
+
|
|
878
|
+
Two other signals were measured and **rejected** rather than shipped: 77.6% of
|
|
879
|
+
known-good PDFs have no "References"/"Acknowledgements" marker in their last
|
|
880
|
+
4,000 characters, and 14.8% never print their own last page number. Both would
|
|
881
|
+
have thrown away one correct paper in seven.
|
|
882
|
+
|
|
883
|
+
URLs containing `/previewpdf/` are skipped before any transfer — Human Kinetics
|
|
884
|
+
and Brill both serve previews from that path — and a URL already refused for a
|
|
885
|
+
record is not fetched again, since `try_landing_page_pdf_fallback` runs from
|
|
886
|
+
nine call sites and several converge on the same publisher URL.
|
|
887
|
+
|
|
888
|
+
#### Crossref under parallel load
|
|
889
|
+
|
|
890
|
+
Verification needs a title for every record, which made metadata the most
|
|
891
|
+
rate-limit-hungry step in the tool at exactly the moment large runs got faster.
|
|
892
|
+
Crossref publishes its allowance in every response — `x-rate-limit-limit: 10`,
|
|
893
|
+
`x-rate-limit-interval: 1s` — and three things now keep a `--workers 10` run
|
|
894
|
+
inside it:
|
|
895
|
+
|
|
896
|
+
- **One process-wide token bucket — genuinely one.** The tiered engine, the
|
|
897
|
+
supplementary pass and the legacy chain each used to build their own
|
|
898
|
+
`HostRateLimiter`, so a run touching two of them politely allowed 10/s twice
|
|
899
|
+
against an allowance of 10/s. They now share a single limiter
|
|
900
|
+
(`ratelimit.shared_host_limiter`), and every Crossref call in the package
|
|
901
|
+
draws from the same bucket. Politeness is a property of the process, not of a
|
|
902
|
+
batch.
|
|
903
|
+
- **A 429 slows every worker.** The bucket is drained by `Retry-After` rather
|
|
904
|
+
than the receiving thread sleeping alone — otherwise one worker backs off
|
|
905
|
+
while the other nine keep spending the budget that just ran out.
|
|
906
|
+
- **Titles fetched in bulk, before any worker starts.** Crossref accepts a
|
|
907
|
+
comma-joined `filter=doi:...` list; batches of 50 with `select=DOI,title,page`
|
|
908
|
+
return every item in about a fifth of a second. A 10,000-record run needs
|
|
909
|
+
**200 metadata requests instead of 10,000**.
|
|
910
|
+
- **429 means wait, not "no such record".** `Retry-After` is honoured (clamped
|
|
911
|
+
to 60s), with bounded exponential backoff behind it. And **"not in Crossref"
|
|
912
|
+
is not "no title"** — Crossref does not register Zenodo, OSF, figshare or
|
|
913
|
+
Dryad DOIs, so those fall through to DataCite rather than being recorded as
|
|
914
|
+
titleless.
|
|
915
|
+
|
|
916
|
+
Measured on 120 records at `--workers 10`: **19 Crossref requests, zero 429s,
|
|
917
|
+
peak 9 req/s.**
|
|
918
|
+
|
|
919
|
+
The bug that motivated this was worse than slowness. A failed lookup used to be
|
|
920
|
+
cached as "this record has no title" — so one 429 became permanent, and since a
|
|
921
|
+
record with no title cannot be verified and an unverifiable PDF is deleted, a
|
|
922
|
+
brief rate-limit blip **deleted correct files**. Now the two are kept strictly
|
|
923
|
+
apart: a 404 is an answer and is cached, while a 429 or a timeout is not an
|
|
924
|
+
answer and is never cached. Deleting now requires a **positive** finding (`wrong_article` or `truncated`)
|
|
925
|
+
**and** metadata we actually obtained. `no_reference`, `unreadable` and
|
|
926
|
+
`no_engine` all mean "nobody checked", which is not a statement about the file;
|
|
927
|
+
each of them used to delete it. If metadata cannot be fetched at all, the PDF is
|
|
928
|
+
**kept unverified rather than deleted** — a wrong file kept can be found again
|
|
929
|
+
by re-running the audit, a right file deleted cannot — and every such record is
|
|
930
|
+
named at the end of the run, because they are the only artifacts in it that
|
|
931
|
+
nobody checked.
|
|
932
|
+
|
|
933
|
+
#### When there is no PDF, there may still be the paper
|
|
934
|
+
|
|
935
|
+
A record whose PDF cannot be had falls back to structured full text **even when
|
|
936
|
+
it was not asked for**. That is not a consolation prize: XML is tier 1 on the
|
|
937
|
+
extraction ladder and the PDF is tier 5. "No PDF" and "no full text" are
|
|
938
|
+
different answers, and only one of them is worth a human's time. The fallback is
|
|
939
|
+
restricted to T1/T2, so it cannot re-enter the chain that called it, and
|
|
940
|
+
`--no-xml-fallback` opts out.
|
|
941
|
+
|
|
942
|
+
In practice this is what a bot-walled or preview-only record now returns: the
|
|
943
|
+
Brill record above yields 715 KB of publisher HTML where it previously yielded
|
|
944
|
+
two pages of PDF, and `10.1111/all.14949` yields the full JATS that NCBI efetch
|
|
945
|
+
serves for records Europe PMC's OA route refuses.
|
|
946
|
+
|
|
947
|
+
### T2 HTML support
|
|
948
|
+
|
|
949
|
+
Publisher HTML needs a forgiving parser:
|
|
950
|
+
|
|
951
|
+
```bash
|
|
952
|
+
pip install 'fetchpdf[html]'
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
Without it the T2 rung demotes with a logged reason and the ladder descends
|
|
956
|
+
normally. Every other tier needs only the standard library.
|
|
957
|
+
|
|
958
|
+
## Supplementary Material
|
|
959
|
+
|
|
960
|
+
`--pull-supplementary` fetches everything the authors deposited *alongside* the
|
|
961
|
+
paper — supplementary PDFs, spreadsheets, documents, images, archives, raw data —
|
|
962
|
+
and saves it as flat siblings of the main artifact.
|
|
963
|
+
|
|
964
|
+
```bash
|
|
965
|
+
fetchpdf papers.csv -o ./out --pull-supplementary
|
|
966
|
+
fetchpdf papers.csv -o ./out --pull-supplementary -w 4
|
|
967
|
+
fetchpdf "10.1371/journal.pone.0000308" -o ./out --pull-supplementary
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
```
|
|
971
|
+
out/
|
|
972
|
+
10.1371--journal.pone.0000308.pdf
|
|
973
|
+
10.1371--journal.pone.0000308_supplementary_info_1.doc
|
|
974
|
+
10.1371--journal.pone.0000308_supplementary_info_2.doc
|
|
975
|
+
10.1371--journal.pone.0000308_supplementary_info_3.txt
|
|
976
|
+
10.1371--journal.pone.0000308_supplementary_info_4.xls
|
|
977
|
+
10.1371--journal.pone.0000308_supplementary_info.json <- the manifest
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
It works with or without `--prioritize-xml`, and it runs for records whose PDF is
|
|
981
|
+
already on disk — so pointing it at a corpus you downloaded months ago collects
|
|
982
|
+
the supplements for all of it without re-fetching a single paper.
|
|
983
|
+
|
|
984
|
+
### The manifest is not optional reading
|
|
985
|
+
|
|
986
|
+
Flat numbering discards the original filenames, so
|
|
987
|
+
`{stem}_supplementary_info.json` is the only record of what `_2` actually is:
|
|
988
|
+
|
|
989
|
+
```json
|
|
990
|
+
{"index": 2, "filename": "..._supplementary_info_2.xls",
|
|
991
|
+
"original_name": "pone.0000308.s004.xls", "label": "Table S1",
|
|
992
|
+
"provider": "europepmc_supplements", "container": "PMC1817752_SupplementaryFiles.zip",
|
|
993
|
+
"bytes": 42496, "sha256": "9f2c...", "role": "supplement"}
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
It also records what was *not* taken and why — `too-large` with the declared byte
|
|
997
|
+
count, `duplicate-of` with the index it duplicates, `not-a-document` for a
|
|
998
|
+
Cloudflare interstitial served as HTTP 200. "This record has no supplementary
|
|
999
|
+
material" and "one 4 GB HDF5 was refused" are different facts, and the manifest is
|
|
1000
|
+
where they stay distinguishable.
|
|
1001
|
+
|
|
1002
|
+
The manifest is also the skip signal: a second run over the same output directory
|
|
1003
|
+
costs zero HTTP requests. Delete a numbered file and the next run restores it *at
|
|
1004
|
+
its original index*, so removing `_2` never renumbers `_3`. Use
|
|
1005
|
+
`--refresh-supplementary` to re-enumerate and pick up newly deposited files.
|
|
1006
|
+
|
|
1007
|
+
### Where the files come from
|
|
1008
|
+
|
|
1009
|
+
Fifteen routes, tried in a fixed order so the numbering is reproducible:
|
|
1010
|
+
|
|
1011
|
+
| Route | Applies to |
|
|
1012
|
+
|---|---|
|
|
1013
|
+
| JATS `<supplementary-material>` | any PMC record — *classifies*, telling supplements from figures |
|
|
1014
|
+
| Europe PMC `supplementaryFiles` | any PMCID (asked with `includeInlineImage=no`, or it returns every figure too) |
|
|
1015
|
+
| PMC AWS Open Data | the PMC OA subset; declares size and md5 before transfer |
|
|
1016
|
+
| Elsevier object API | Elsevier DOIs — the only ScienceDirect route that needs no browser |
|
|
1017
|
+
| Springer / Nature ESM | `10.1038`, `10.1007`, `10.1186`, … |
|
|
1018
|
+
| PLOS | `10.1371` |
|
|
1019
|
+
| bioRxiv / medRxiv | `10.1101` |
|
|
1020
|
+
| APA supplemental | `10.1037` |
|
|
1021
|
+
| Figshare / Zenodo / OSF / Dryad / Dataverse | repository DOIs, and datasets reached through link services |
|
|
1022
|
+
| DataCite reverse relations | any DOI — finds the deposited datasets that cite it |
|
|
1023
|
+
| Crossref component DOIs | publishers that register components |
|
|
1024
|
+
| OpenAIRE ScholeXplorer | any DOI — Scholix publication→dataset/software links |
|
|
1025
|
+
| Europe PMC datalinks | any PMID — text-mined accessions and data citations |
|
|
1026
|
+
|
|
1027
|
+
Atypon (PNAS, Science, T&F, Sage), Wiley, ACS and MDPI serve HTTP 403 to plain
|
|
1028
|
+
requests and are not yet covered; arXiv ancillary files are not yet covered
|
|
1029
|
+
either. Dryad (`10.5061`) and Harvard Dataverse (`10.7910/DVN`) deposits are
|
|
1030
|
+
reached through the link services rather than as top-level providers.
|
|
1031
|
+
|
|
1032
|
+
### Size cap
|
|
1033
|
+
|
|
1034
|
+
`--max-supplementary-mb` defaults to 300, per file. A file whose `Content-Length`
|
|
1035
|
+
exceeds it is skipped without downloading anything; a server that under-reports is
|
|
1036
|
+
aborted mid-stream and its partial removed. Nothing is ever truncated — a
|
|
1037
|
+
truncated `.xlsx` is a corrupt zip that some readers open far enough to yield
|
|
1038
|
+
wrong numbers, which is worse than not having the file.
|
|
1039
|
+
|
|
1040
|
+
### It never changes whether a record succeeded
|
|
1041
|
+
|
|
1042
|
+
A paper with no supplementary material is not a failure, and a repository serving
|
|
1043
|
+
malformed JSON cannot turn a successful download into a failed one. Supplementary
|
|
1044
|
+
files never appear in `failed_dois.csv`, in the missing-PDFs report, in
|
|
1045
|
+
`source_tracking.csv`, or in the format-composition tally.
|
|
1046
|
+
|
|
1047
|
+
### Linked datasets and code: `{stem}_linked_artifacts.json`
|
|
1048
|
+
|
|
1049
|
+
The two link services (ScholeXplorer, Europe PMC datalinks) discover more than
|
|
1050
|
+
they download, and the rest lands in a second sidecar. Every
|
|
1051
|
+
publication→dataset and publication→software link they returned is recorded
|
|
1052
|
+
there with a classification:
|
|
1053
|
+
|
|
1054
|
+
- `owned` — affirmatively the paper's own material: a supplement-grade
|
|
1055
|
+
relation (`IsSupplementTo` and friends), or a repository deposit whose own
|
|
1056
|
+
DataCite record names this article
|
|
1057
|
+
- `related` — everything citation-grade: datasets/software the paper cites,
|
|
1058
|
+
text-mined data citations that no metadata ties back to the article, and
|
|
1059
|
+
repositories with no file enumerator
|
|
1060
|
+
- `tool_citation` — "xgboost software on GitHub" and friends: somebody's tool,
|
|
1061
|
+
not this paper's code. Recorded, never downloaded.
|
|
1062
|
+
- `registry` — ClinicalTrials.gov and other registrations; a link, not a file
|
|
1063
|
+
|
|
1064
|
+
**Only `owned` links are downloaded.** A "cites" link cannot distinguish the
|
|
1065
|
+
paper's own deposit from a dataset the paper merely cites, so citation-grade
|
|
1066
|
+
links earn a download only when the deposit's DataCite record names the
|
|
1067
|
+
article — otherwise they stay sidecar links for the operator to judge.
|
|
1068
|
+
Ownership-confirmed deposits in Figshare, Zenodo, OSF, Dryad or Harvard
|
|
1069
|
+
Dataverse route to those enumerators and land as numbered supplements
|
|
1070
|
+
(`routed_to_download: true` in the sidecar). The same deposit named by several
|
|
1071
|
+
link services is listed and downloaded once.
|
|
1072
|
+
|
|
1073
|
+
Empty answers are recorded too — `"queried": [...]` with zero links is a
|
|
1074
|
+
different fact from a sidecar that does not exist. On the corpora this was
|
|
1075
|
+
measured against (2026-08), most papers have no links at all: ~30% of a recent
|
|
1076
|
+
biomedical batch had any ScholeXplorer link, an older clinical-trial corpus had
|
|
1077
|
+
none, and Europe PMC's text-mined accessions were the widest net. The sidecar
|
|
1078
|
+
is written by the same `--pull-supplementary` pass, after the manifest; a run
|
|
1079
|
+
that skips enumeration (manifest already present, no `--refresh-supplementary`)
|
|
1080
|
+
does not rewrite it.
|
|
1081
|
+
|
|
1082
|
+
#### Where dataset links come from, and what can be downloaded
|
|
1083
|
+
|
|
1084
|
+
Discovery — the link services queried per record:
|
|
1085
|
+
|
|
1086
|
+
| Service | What it contributes |
|
|
1087
|
+
|---|---|
|
|
1088
|
+
| OpenAIRE ScholeXplorer | Scholix links aggregated from Crossref, DataCite, EMBL-EBI, repositories, plus OpenAIRE's text-mining of ~14M PDFs |
|
|
1089
|
+
| 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) |
|
|
1090
|
+
| DataCite reverse query | Deposits whose own metadata names the article — depositor-declared ground truth |
|
|
1091
|
+
|
|
1092
|
+
Download — repositories with file enumerators (ownership-confirmed links only):
|
|
1093
|
+
|
|
1094
|
+
| Repository | Routed on | Notes |
|
|
1095
|
+
|---|---|---|
|
|
1096
|
+
| figshare | `10.6084` / "figshare" | declared size + md5 before transfer |
|
|
1097
|
+
| Zenodo | `10.5281` / "zenodo" | restricted records skipped |
|
|
1098
|
+
| OSF | `10.17605` / "osf.io" | walks folder trees (depth/node caps) |
|
|
1099
|
+
| Dryad | `10.5061` / "dryad" | declared size + sha-256, paginated |
|
|
1100
|
+
| Harvard Dataverse | `10.7910/DVN` | declared md5; restricted files skipped |
|
|
1101
|
+
| BioStudies | — | content arrives via the Europe PMC supplementary bundle instead, never fetched twice |
|
|
1102
|
+
|
|
1103
|
+
Not yet downloadable (recorded in the sidecar, left to the operator):
|
|
1104
|
+
Mendeley Data (`10.17632`), non-Harvard Dataverse installations, subject
|
|
1105
|
+
databases (GEO, SRA, PDB, UniProt — accession records, not file bundles),
|
|
1106
|
+
GitLab repositories, and the institutional-repository long tail.
|
|
1107
|
+
Adding an enumerator is the same small pattern as `enumerate_dryad` /
|
|
1108
|
+
`enumerate_dataverse` in `fetchpdf/retrieval/supplement_index.py`.
|
|
1109
|
+
|
|
1110
|
+
## `--pull-everything`: the second layer
|
|
1111
|
+
|
|
1112
|
+
Everything above is layer 1 — APIs, indexes and enumerators. It is very good at
|
|
1113
|
+
what publishers and repositories *declare*, and it cannot read. Layer 2 is a
|
|
1114
|
+
model that reads the paper itself and goes after what layer 1 did not get.
|
|
1115
|
+
|
|
1116
|
+
```bash
|
|
1117
|
+
fetchpdf papers.csv -o ./out --get-xml-or-html --make-subfolder --provenance \
|
|
1118
|
+
--pull-everything
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
`--pull-everything` implies `--pull-supplementary` and
|
|
1122
|
+
`--download-data-artifacts`, turns on adjudication of the deterministic scan's
|
|
1123
|
+
uncertain candidates, and adds the retrieval agent. Files land exactly where
|
|
1124
|
+
they always did: supplements as numbered siblings of the PDF, linked deposits
|
|
1125
|
+
in `{stem}_data_artifacts/`.
|
|
1126
|
+
|
|
1127
|
+
**It is not literally everything, and the run says so.** Wiley, ACS and MDPI
|
|
1128
|
+
serve HTTP 403 to plain requests; Atypon needs the headed-browser path; arXiv
|
|
1129
|
+
ancillary files are uncovered; the repositories in the table above still have
|
|
1130
|
+
no enumerator. Those are recorded in the sidecar, not fetched.
|
|
1131
|
+
|
|
1132
|
+
### What the agent is told
|
|
1133
|
+
|
|
1134
|
+
Three things a model does not have and layer 1 does:
|
|
1135
|
+
|
|
1136
|
+
1. the paper's own text, weighted toward its availability statements;
|
|
1137
|
+
2. what has already been obtained, so it does not fetch a second copy;
|
|
1138
|
+
3. **what the link services found and declined, with their reasons.** That is
|
|
1139
|
+
the richest input it gets. Measured over four corpora, those services
|
|
1140
|
+
returned 3,005 `related` links that were never routed, and of 280 `owned`
|
|
1141
|
+
ones 264 were BioStudies mirrors of supplements already in hand.
|
|
1142
|
+
|
|
1143
|
+
It proposes; the existing gates dispose. A proposal naming a repository routes
|
|
1144
|
+
through the same enumerator and the same ownership checks
|
|
1145
|
+
(`_should_route`, `_deposit_claims_another_article`) an index's link would, and
|
|
1146
|
+
anything it saves still meets `_commit`'s size cap, sha256, deduplication and
|
|
1147
|
+
challenge-page sniff. No failure of it can change whether a record succeeded.
|
|
1148
|
+
|
|
1149
|
+
### `--llm-backend`: two sandboxes, one result
|
|
1150
|
+
|
|
1151
|
+
| | `claude-cli` (default) | `openrouter` |
|
|
1152
|
+
|---|---|---|
|
|
1153
|
+
| auth | whatever the local Claude Code CLI already has | `OPENROUTER_API_KEY` in `.env.local` |
|
|
1154
|
+
| `--llm-model` | `haiku`, `sonnet`, … | the same names, translated to slugs |
|
|
1155
|
+
| tools | WebFetch only | `fetch_url` and `download`, both implemented here |
|
|
1156
|
+
| downloads? | **no** — it navigates and reports URLs, fetchpdf transfers | yes, into a staging directory |
|
|
1157
|
+
|
|
1158
|
+
The asymmetry is not a preference, it is a measurement. Against Claude Code
|
|
1159
|
+
2.1.240:
|
|
1160
|
+
|
|
1161
|
+
- **`--allowed-tools` grants permission; it does not restrict the tool set.** A
|
|
1162
|
+
subprocess given `--allowed-tools "Bash(echo:*)"` ran
|
|
1163
|
+
`cat ../outside_canary.txt` and returned the contents. So did
|
|
1164
|
+
`--allowed-tools WebFetch`. With and without `--permission-mode
|
|
1165
|
+
bypassPermissions`.
|
|
1166
|
+
- **`--disallowed-tools` does remove tools**, and was the only lever that
|
|
1167
|
+
worked.
|
|
1168
|
+
- **A default subprocess inherits the operator's own MCP servers.** On the
|
|
1169
|
+
machine this was written on, that put Gmail, Google Drive, Calendar and a
|
|
1170
|
+
brokerage account in scope for a subprocess whose job is downloading
|
|
1171
|
+
spreadsheets. `--strict-mcp-config` with an empty config removes them.
|
|
1172
|
+
|
|
1173
|
+
So the CLI backend is locked to WebFetch and nothing else — verified by asking a
|
|
1174
|
+
locked-down subprocess to list its own tools, which answered `WebFetch` — and a
|
|
1175
|
+
process with no file tools cannot save a file. It reports URLs instead, which
|
|
1176
|
+
loses nothing: navigating is the part that needs a model, and transferring bytes
|
|
1177
|
+
under a cap with a hash is the part this package already does. The OpenRouter
|
|
1178
|
+
backend has no general-purpose harness to lock down, because its two tools are
|
|
1179
|
+
ours, so it downloads directly.
|
|
1180
|
+
|
|
1181
|
+
### What it costs, and what to expect
|
|
1182
|
+
|
|
1183
|
+
One model call per record. `--max-llm-records N` is the ceiling that stops an
|
|
1184
|
+
overnight batch spending without bound; records past it still get every API
|
|
1185
|
+
route, and the manifest records that the agent was *skipped* rather than that it
|
|
1186
|
+
found nothing.
|
|
1187
|
+
|
|
1188
|
+
Measured on the corpora this was built against, **most calls will correctly find
|
|
1189
|
+
nothing**, and that is not a defect in the agent. Of 606 PDF-only records, 6
|
|
1190
|
+
name a repository at all and 17 carry a data-availability statement — the
|
|
1191
|
+
biomedical sets skew to 1991–2006, before data-sharing norms existed. Expect the
|
|
1192
|
+
yield on modern and social-science corpora instead: `fulltext_scan` alone
|
|
1193
|
+
produced 83 OSF files from ten such records, and the link indexes had surfaced
|
|
1194
|
+
none of the fourteen deposits those articles named in their own prose.
|
|
1195
|
+
|
|
1196
|
+
Recall is also **model-dependent and not deterministic**. On one record whose
|
|
1197
|
+
GitHub URL is broken across a line by PDF layout — invisible to any regex —
|
|
1198
|
+
`haiku` recovered it on two runs out of four and returned nothing on the other
|
|
1199
|
+
two. Treat the agent as a second pass that sometimes finds what layer 1 missed,
|
|
1200
|
+
not as a guarantee.
|
|
1201
|
+
|
|
1202
|
+
## API Reference
|
|
1203
|
+
|
|
1204
|
+
### `fetch_pdf(doi, save_path, email, verbose, delay)`
|
|
1205
|
+
|
|
1206
|
+
Download a PDF from a DOI (or a PMID that can be resolved to a DOI) using multiple fallback sources.
|
|
1207
|
+
|
|
1208
|
+
**Parameters:**
|
|
1209
|
+
- `doi` (str): DOI or PMID identifier to download
|
|
1210
|
+
- `save_path` (str): Path where the PDF should be saved
|
|
1211
|
+
- `email` (str, optional): Email for API calls (default: `EMAIL` from `.env.local`)
|
|
1212
|
+
- `verbose` (bool, optional): Print detailed progress (default: False)
|
|
1213
|
+
- `delay` (float, optional): Delay between API calls in seconds (default: 0.1)
|
|
1214
|
+
|
|
1215
|
+
**Returns:**
|
|
1216
|
+
- `str`: Path to downloaded PDF if successful, `None` otherwise
|
|
1217
|
+
|
|
1218
|
+
### `batch_fetch_pdfs(dois, output_dir, email, verbose, delay, workers, create_missing_report)`
|
|
1219
|
+
|
|
1220
|
+
Download PDFs for multiple DOI/PMID identifiers with optional parallel processing.
|
|
1221
|
+
|
|
1222
|
+
**Parameters:**
|
|
1223
|
+
- `dois` (list or str): List of DOI/PMID identifiers or path to CSV file
|
|
1224
|
+
- `output_dir` (str): Directory to save PDFs
|
|
1225
|
+
- `email` (str, optional): Email for API calls (default: `EMAIL` from `.env.local`)
|
|
1226
|
+
- `verbose` (bool, optional): Print detailed progress (default: False)
|
|
1227
|
+
- `delay` (float, optional): Delay between API calls per worker (default: 0.1)
|
|
1228
|
+
- `workers` (int, optional): Number of parallel workers; 1 = sequential (default: 1)
|
|
1229
|
+
- `create_missing_report` (bool, optional): Create HTML report for failed downloads (default: True)
|
|
1230
|
+
|
|
1231
|
+
**Returns:**
|
|
1232
|
+
- `list`: List of tuples `(doi, success, save_path)` for each DOI processed
|
|
1233
|
+
|
|
1234
|
+
**Side Effects:**
|
|
1235
|
+
- Creates `missing_pdfs.html` in `output_dir` if any downloads fail (unless `create_missing_report=False`)
|
|
1236
|
+
|
|
1237
|
+
### `pull_supplementary_for(raw_identifier, doi, save_path, ...)`
|
|
1238
|
+
|
|
1239
|
+
Fetch every supplementary file for one record, as flat siblings of `save_path`.
|
|
1240
|
+
|
|
1241
|
+
A separate call rather than a keyword on `fetch_pdf_from_doi`, deliberately: the
|
|
1242
|
+
tiered engine re-enters that function with a temporary path, so a flag threaded
|
|
1243
|
+
through it would write supplementary siblings next to a file that is about to be
|
|
1244
|
+
deleted. Two calls make that impossible rather than merely discouraged.
|
|
1245
|
+
|
|
1246
|
+
```python
|
|
1247
|
+
from fetchpdf import fetch_pdf, pull_supplementary_for
|
|
1248
|
+
|
|
1249
|
+
doi = "10.1371/journal.pone.0000308"
|
|
1250
|
+
path = fetch_pdf(doi, "out/paper.pdf")
|
|
1251
|
+
summary = pull_supplementary_for(doi, doi=doi, save_path="out/paper.pdf")
|
|
1252
|
+
print(summary.status, summary.written, summary.manifest_path)
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
**Key parameters:**
|
|
1256
|
+
- `raw_identifier` (str): the identifier as given (DOI or PMID)
|
|
1257
|
+
- `doi` (str, optional): the resolved DOI, when known
|
|
1258
|
+
- `save_path` (str): the main artifact's path; siblings and the manifest derive from its stem
|
|
1259
|
+
- `max_file_bytes` (int, optional): per-file cap (default 300 MB)
|
|
1260
|
+
- `refresh` (bool, optional): re-enumerate even when a manifest exists (default: False)
|
|
1261
|
+
- `resolver` / `http` (optional): reuse a batch's `BatchResolver` / `HttpClient` so the per-host rate budget stays shared
|
|
1262
|
+
|
|
1263
|
+
**Returns:**
|
|
1264
|
+
- `SupplementarySummary`: `.status` (`ok` | `none_found` | `partial` | `error` | `skipped`), `.written`, `.skipped`, `.bytes_written`, `.manifest_path`, `.paths`
|
|
1265
|
+
|
|
1266
|
+
Never raises for a network or provider failure, and its result must not be used to
|
|
1267
|
+
decide whether the record succeeded.
|
|
1268
|
+
|
|
1269
|
+
### `fetch_metadata_from_doi(doi, email, delay)`
|
|
1270
|
+
|
|
1271
|
+
Fetch metadata for a paper from its DOI.
|
|
1272
|
+
|
|
1273
|
+
**Parameters:**
|
|
1274
|
+
- `doi` (str): The DOI to fetch metadata for
|
|
1275
|
+
- `email` (str, optional): Email for API calls (default: `EMAIL` from `.env.local`)
|
|
1276
|
+
- `delay` (float, optional): Delay between API calls in seconds (default: 0.2)
|
|
1277
|
+
|
|
1278
|
+
**Returns:**
|
|
1279
|
+
- `dict`: Metadata dictionary with keys: `authors`, `title`, `journal`, `volume`, `issue`, `pages`, `year`, `url`
|
|
1280
|
+
|
|
1281
|
+
## Download Sources (in order of priority)
|
|
1282
|
+
|
|
1283
|
+
The tool tries multiple sources in sequence until a PDF is successfully downloaded. The order is optimized to check faster/more reliable sources first and preserve rate-limited API quotas.
|
|
1284
|
+
|
|
1285
|
+
### Special DOI Handlers (Pattern-Matched)
|
|
1286
|
+
These run first if the DOI matches specific patterns:
|
|
1287
|
+
|
|
1288
|
+
- **OSF** (Open Science Framework) - Projects (`10.17605/osf.io/*`) & Preprints (`10.31234/osf.io/*`)
|
|
1289
|
+
- Direct download URLs, Playwright browser automation, API fallback
|
|
1290
|
+
- **SSRN** (Social Science Research Network) - `10.2139/ssrn.*`
|
|
1291
|
+
- Playwright with Cloudflare bypass, download button detection
|
|
1292
|
+
- **Figshare** - `10.6084/m9.figshare.*`
|
|
1293
|
+
- Figshare API for file metadata and download URLs
|
|
1294
|
+
- **PsychArchives** - `10.23668/psycharchives.*`
|
|
1295
|
+
- Leibniz psychology repository bitstream extraction
|
|
1296
|
+
|
|
1297
|
+
### Standard Fallback Chain (All DOIs)
|
|
1298
|
+
|
|
1299
|
+
1. **PubMed Central (PMC)** via Europe PMC
|
|
1300
|
+
- Open access papers via NCBI idconv → Europe PMC PDF endpoint
|
|
1301
|
+
- Fast, reliable for biomedical papers
|
|
1302
|
+
|
|
1303
|
+
2. **Unpaywall**
|
|
1304
|
+
- Comprehensive legal open access aggregator
|
|
1305
|
+
- Highly reliable, requires email
|
|
1306
|
+
|
|
1307
|
+
3. **Crossref**
|
|
1308
|
+
- Publisher metadata with direct PDF links
|
|
1309
|
+
- Landing page scraping with `citation_pdf_url` extraction
|
|
1310
|
+
- **NEW: Crossref chooser page handler** for multi-resolution DOIs
|
|
1311
|
+
- Publisher-specific URL patterns (Wiley, T&F, SAGE, MIT Press, etc.)
|
|
1312
|
+
|
|
1313
|
+
4. **Europe PMC**
|
|
1314
|
+
- European PubMed Central search API
|
|
1315
|
+
- Complementary to PMC direct access
|
|
1316
|
+
|
|
1317
|
+
5. **Semantic Scholar**
|
|
1318
|
+
- AI-powered academic search with `openAccessPdf` field
|
|
1319
|
+
- Landing page fallback for OJS sites
|
|
1320
|
+
- PsyArXiv→OSF URL conversion
|
|
1321
|
+
|
|
1322
|
+
6. **OpenAlex** ⚠️ **Rate Limited**
|
|
1323
|
+
- **1,000 downloads/day limit** (even with API key)
|
|
1324
|
+
- Placed after other sources to preserve quota for harder-to-find papers
|
|
1325
|
+
- Comprehensive metadata aggregator with open access locations
|
|
1326
|
+
|
|
1327
|
+
7. **CORE** (core.ac.uk)
|
|
1328
|
+
- 40M+ open access papers from repositories worldwide
|
|
1329
|
+
- Search API with download URLs
|
|
1330
|
+
|
|
1331
|
+
8. **Direct DOI Resolver**
|
|
1332
|
+
- Follows `https://doi.org/{doi}` redirect to landing page
|
|
1333
|
+
- Handles Crossref chooser pages (multiple resolution)
|
|
1334
|
+
- Scrapes PDF links from HTML (`citation_pdf_url`, href patterns)
|
|
1335
|
+
- Publisher-specific deterministic URL patterns
|
|
1336
|
+
|
|
1337
|
+
9. **DataCite Related Identifiers**
|
|
1338
|
+
- Supplementary material → main paper fallback
|
|
1339
|
+
- Versioned DOI resolution (`IsIdenticalTo`, `IsVersionOf`)
|
|
1340
|
+
- Figshare supplement handling
|
|
1341
|
+
|
|
1342
|
+
10. **ResearchGate**
|
|
1343
|
+
- DuckDuckGo search: `title + site:researchgate.net`
|
|
1344
|
+
- Landing page PDF extraction
|
|
1345
|
+
|
|
1346
|
+
11. **DOI → PMID Fallback**
|
|
1347
|
+
- Convert DOI to PMID when DOI sources fail
|
|
1348
|
+
- Try PMID-native sources (PubMed landing page `citation_pdf_url`)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
### Final Fallback
|
|
1352
|
+
|
|
1353
|
+
12. **Elsevier XML API**
|
|
1354
|
+
- Text/data-mining API for Elsevier articles
|
|
1355
|
+
- Returns XML instead of PDF (last resort)
|
|
1356
|
+
- Requires `ELSEVIER_TDM_API_KEY`
|
|
1357
|
+
|
|
1358
|
+
---
|
|
1359
|
+
|
|
1360
|
+
**Total: 12 standard sources + 4 special handlers** = 16 different download strategies
|
|
1361
|
+
|
|
1362
|
+
## Requirements
|
|
1363
|
+
|
|
1364
|
+
- Python 3.8+
|
|
1365
|
+
- requests
|
|
1366
|
+
- playwright
|
|
1367
|
+
- duckduckgo-search
|
|
1368
|
+
|
|
1369
|
+
## Troubleshooting
|
|
1370
|
+
|
|
1371
|
+
### Rate Limiting
|
|
1372
|
+
|
|
1373
|
+
If you're downloading many papers, consider:
|
|
1374
|
+
- Increasing the `delay` parameter (e.g., `delay=1.0`)
|
|
1375
|
+
- Using the batch downloader with controlled parallelism
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
## Ethical Considerations
|
|
1379
|
+
|
|
1380
|
+
This tool is intended for:
|
|
1381
|
+
- ✅ Accessing open access papers
|
|
1382
|
+
- ✅ Retrieving papers you have legal access to
|
|
1383
|
+
- ✅ Academic research and education
|
|
1384
|
+
- ✅ Fair use purposes
|
|
1385
|
+
|
|
1386
|
+
Please respect copyright laws and publisher terms of service in your jurisdiction.
|
|
1387
|
+
|
|
1388
|
+
## Acknowledgments
|
|
1389
|
+
|
|
1390
|
+
This package aggregates access to multiple open academic resources. Thanks to:
|
|
1391
|
+
- OpenAlex, Unpaywall, Crossref, PubMed Central, Semantic Scholar, and other open science initiatives
|
|
1392
|
+
- The Playwright team for browser automation tools
|