fetchpdf 0.1.0__tar.gz → 0.1.3__tar.gz

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