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.
Files changed (64) hide show
  1. fetchpdf/__init__.py +49 -0
  2. fetchpdf/__main__.py +6 -0
  3. fetchpdf/_env.py +63 -0
  4. fetchpdf/_http.py +40 -0
  5. fetchpdf/fetch_abstract_from_doi.py +549 -0
  6. fetchpdf/fetch_metadata_from_doi.py +225 -0
  7. fetchpdf/fetchpdf.py +7339 -0
  8. fetchpdf/retrieval/__init__.py +36 -0
  9. fetchpdf/retrieval/_util.py +150 -0
  10. fetchpdf/retrieval/artifact.py +87 -0
  11. fetchpdf/retrieval/backends/__init__.py +109 -0
  12. fetchpdf/retrieval/backends/claude_cli.py +111 -0
  13. fetchpdf/retrieval/backends/openrouter.py +244 -0
  14. fetchpdf/retrieval/cache.py +123 -0
  15. fetchpdf/retrieval/chunk.py +195 -0
  16. fetchpdf/retrieval/classify.py +110 -0
  17. fetchpdf/retrieval/context.py +69 -0
  18. fetchpdf/retrieval/corresponding.py +349 -0
  19. fetchpdf/retrieval/engine.py +666 -0
  20. fetchpdf/retrieval/extract_images.py +593 -0
  21. fetchpdf/retrieval/fulltext_scan.py +472 -0
  22. fetchpdf/retrieval/goals.py +145 -0
  23. fetchpdf/retrieval/http.py +669 -0
  24. fetchpdf/retrieval/identifiers.py +149 -0
  25. fetchpdf/retrieval/ladder.json +362 -0
  26. fetchpdf/retrieval/linked_artifacts.py +144 -0
  27. fetchpdf/retrieval/llm_adjudicate.py +278 -0
  28. fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
  29. fetchpdf/retrieval/normalize.py +301 -0
  30. fetchpdf/retrieval/pdf_identity.py +533 -0
  31. fetchpdf/retrieval/pdf_text.py +242 -0
  32. fetchpdf/retrieval/provenance.py +185 -0
  33. fetchpdf/retrieval/ratelimit.py +113 -0
  34. fetchpdf/retrieval/repository_waf.py +152 -0
  35. fetchpdf/retrieval/request_drafts.py +304 -0
  36. fetchpdf/retrieval/resolve.py +481 -0
  37. fetchpdf/retrieval/sources/__init__.py +15 -0
  38. fetchpdf/retrieval/sources/arxiv.py +102 -0
  39. fetchpdf/retrieval/sources/core_text.py +64 -0
  40. fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
  41. fetchpdf/retrieval/sources/epmc.py +133 -0
  42. fetchpdf/retrieval/sources/landing.py +34 -0
  43. fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
  44. fetchpdf/retrieval/sources/pmc.py +59 -0
  45. fetchpdf/retrieval/sources/preprint.py +101 -0
  46. fetchpdf/retrieval/sources/publisher_html.py +154 -0
  47. fetchpdf/retrieval/sources/supplements.py +183 -0
  48. fetchpdf/retrieval/supplement_atypon.py +269 -0
  49. fetchpdf/retrieval/supplement_dspace.py +187 -0
  50. fetchpdf/retrieval/supplement_graph.py +842 -0
  51. fetchpdf/retrieval/supplement_index.py +820 -0
  52. fetchpdf/retrieval/supplement_pmc.py +351 -0
  53. fetchpdf/retrieval/supplement_publishers.py +374 -0
  54. fetchpdf/retrieval/supplementary.py +1770 -0
  55. fetchpdf/retrieval/tiers.py +213 -0
  56. fetchpdf/retrieval/to_markdown.py +661 -0
  57. fetchpdf/retrieval/validate.py +368 -0
  58. fetchpdf/retrieval/verify.py +149 -0
  59. fetchpdf-0.1.0.dist-info/METADATA +1392 -0
  60. fetchpdf-0.1.0.dist-info/RECORD +64 -0
  61. fetchpdf-0.1.0.dist-info/WHEEL +5 -0
  62. fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
  63. fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. 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