fetchpdf 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fetchpdf/__init__.py +49 -0
- fetchpdf/__main__.py +6 -0
- fetchpdf/_env.py +63 -0
- fetchpdf/_http.py +40 -0
- fetchpdf/fetch_abstract_from_doi.py +549 -0
- fetchpdf/fetch_metadata_from_doi.py +225 -0
- fetchpdf/fetchpdf.py +7339 -0
- fetchpdf/retrieval/__init__.py +36 -0
- fetchpdf/retrieval/_util.py +150 -0
- fetchpdf/retrieval/artifact.py +87 -0
- fetchpdf/retrieval/backends/__init__.py +109 -0
- fetchpdf/retrieval/backends/claude_cli.py +111 -0
- fetchpdf/retrieval/backends/openrouter.py +244 -0
- fetchpdf/retrieval/cache.py +123 -0
- fetchpdf/retrieval/chunk.py +195 -0
- fetchpdf/retrieval/classify.py +110 -0
- fetchpdf/retrieval/context.py +69 -0
- fetchpdf/retrieval/corresponding.py +349 -0
- fetchpdf/retrieval/engine.py +666 -0
- fetchpdf/retrieval/extract_images.py +593 -0
- fetchpdf/retrieval/fulltext_scan.py +472 -0
- fetchpdf/retrieval/goals.py +145 -0
- fetchpdf/retrieval/http.py +669 -0
- fetchpdf/retrieval/identifiers.py +149 -0
- fetchpdf/retrieval/ladder.json +362 -0
- fetchpdf/retrieval/linked_artifacts.py +144 -0
- fetchpdf/retrieval/llm_adjudicate.py +278 -0
- fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
- fetchpdf/retrieval/normalize.py +301 -0
- fetchpdf/retrieval/pdf_identity.py +533 -0
- fetchpdf/retrieval/pdf_text.py +242 -0
- fetchpdf/retrieval/provenance.py +185 -0
- fetchpdf/retrieval/ratelimit.py +113 -0
- fetchpdf/retrieval/repository_waf.py +152 -0
- fetchpdf/retrieval/request_drafts.py +304 -0
- fetchpdf/retrieval/resolve.py +481 -0
- fetchpdf/retrieval/sources/__init__.py +15 -0
- fetchpdf/retrieval/sources/arxiv.py +102 -0
- fetchpdf/retrieval/sources/core_text.py +64 -0
- fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
- fetchpdf/retrieval/sources/epmc.py +133 -0
- fetchpdf/retrieval/sources/landing.py +34 -0
- fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
- fetchpdf/retrieval/sources/pmc.py +59 -0
- fetchpdf/retrieval/sources/preprint.py +101 -0
- fetchpdf/retrieval/sources/publisher_html.py +154 -0
- fetchpdf/retrieval/sources/supplements.py +183 -0
- fetchpdf/retrieval/supplement_atypon.py +269 -0
- fetchpdf/retrieval/supplement_dspace.py +187 -0
- fetchpdf/retrieval/supplement_graph.py +842 -0
- fetchpdf/retrieval/supplement_index.py +820 -0
- fetchpdf/retrieval/supplement_pmc.py +351 -0
- fetchpdf/retrieval/supplement_publishers.py +374 -0
- fetchpdf/retrieval/supplementary.py +1770 -0
- fetchpdf/retrieval/tiers.py +213 -0
- fetchpdf/retrieval/to_markdown.py +661 -0
- fetchpdf/retrieval/validate.py +368 -0
- fetchpdf/retrieval/verify.py +149 -0
- fetchpdf-0.1.0.dist-info/METADATA +1392 -0
- fetchpdf-0.1.0.dist-info/RECORD +64 -0
- fetchpdf-0.1.0.dist-info/WHEEL +5 -0
- fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
- fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
- fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Draft emails asking authors for what could not be fetched.
|
|
2
|
+
|
|
3
|
+
Nothing here sends anything. It writes markdown files you read, edit and send
|
|
4
|
+
yourself. That is a deliberate limit, not an unfinished feature:
|
|
5
|
+
|
|
6
|
+
* Extraction is ~97% precise, not 100%. One wrong address emails a stranger
|
|
7
|
+
in your name, and there is no undo.
|
|
8
|
+
* Addresses go stale invisibly -- one author appears under four addresses in
|
|
9
|
+
a single corpus, two of them dead.
|
|
10
|
+
* Forensic corpora contain people under scrutiny. Whether and how to contact
|
|
11
|
+
them is a judgement the tool has no business making.
|
|
12
|
+
|
|
13
|
+
So there is no --send flag, no SMTP, no mail library, and no mailto: auto-open.
|
|
14
|
+
Please do not add one.
|
|
15
|
+
|
|
16
|
+
WHAT EARNS AN EMAIL
|
|
17
|
+
-------------------
|
|
18
|
+
Only material we can show exists. Three qualifying cases:
|
|
19
|
+
|
|
20
|
+
epmc_not_open_access EPMC's hasSuppl=Y: the files exist, withheld
|
|
21
|
+
declared.missing the article's own JATS names a file nobody got
|
|
22
|
+
no artifact on disk the paper itself was never fetched
|
|
23
|
+
|
|
24
|
+
A record whose supplements simply do not exist gets nothing. Asking an author
|
|
25
|
+
for a file that was never published wastes their time and yours, and it is the
|
|
26
|
+
same false-alarm failure that once made this tool warn about five records when
|
|
27
|
+
only one had anything to withhold.
|
|
28
|
+
|
|
29
|
+
News and editorial items are excluded outright: there is no paper to request,
|
|
30
|
+
and they are also where address extraction is least reliable -- a Nature news
|
|
31
|
+
page reprints other articles' author blocks.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import Dict, List, Optional
|
|
39
|
+
|
|
40
|
+
from .corresponding import Contact
|
|
41
|
+
|
|
42
|
+
#: Written to the directory fetchpdf was RUN FROM, not the output dir: these
|
|
43
|
+
#: are working documents for a person, so they belong where the person is. The
|
|
44
|
+
#: prefix does a folder's job -- groups them in a listing, globbable for
|
|
45
|
+
#: cleanup (rm email_request_*.md) -- without claiming a directory we do not
|
|
46
|
+
#: own.
|
|
47
|
+
FILENAME_PREFIX = "email_request_"
|
|
48
|
+
UNRESOLVED_NAME = FILENAME_PREFIX + "UNRESOLVED.md"
|
|
49
|
+
|
|
50
|
+
#: Why a record is being asked about. Kept distinct because the ask differs:
|
|
51
|
+
#: a missing paper is a reprint request, a missing supplement is not.
|
|
52
|
+
WANT_PDF = "pdf"
|
|
53
|
+
WANT_SUPPLEMENT = "supplement"
|
|
54
|
+
|
|
55
|
+
#: Whether a human could plausibly get it themselves. See classify_block().
|
|
56
|
+
MANUAL_LIKELY = "manual_likely"
|
|
57
|
+
BLOCKED_HARD = "blocked_hard"
|
|
58
|
+
NOT_PUBLISHED = "not_published"
|
|
59
|
+
|
|
60
|
+
_DEFAULT_PURPOSE = ("I am compiling a corpus of published work and its "
|
|
61
|
+
"underlying materials for a research project on "
|
|
62
|
+
"reproducibility.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Ask:
|
|
67
|
+
"""One paper, and what is missing from it."""
|
|
68
|
+
|
|
69
|
+
doi: str
|
|
70
|
+
title: str = ""
|
|
71
|
+
year: Optional[int] = None
|
|
72
|
+
wants: List[str] = field(default_factory=list) # WANT_PDF / WANT_SUPPLEMENT
|
|
73
|
+
detail: str = "" # e.g. "Table S1, Appendix A"
|
|
74
|
+
block: str = "" # MANUAL_LIKELY / BLOCKED_HARD
|
|
75
|
+
url: str = ""
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def summary(self) -> str:
|
|
79
|
+
if WANT_PDF in self.wants and WANT_SUPPLEMENT in self.wants:
|
|
80
|
+
return "the full text and its supplementary materials"
|
|
81
|
+
if WANT_PDF in self.wants:
|
|
82
|
+
return "the full text"
|
|
83
|
+
return self.detail or "the supplementary materials"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def classify_block(status: Optional[int], title: str, links_found: int) -> str:
|
|
87
|
+
"""Could a person get this by clicking, or is it a hard wall?
|
|
88
|
+
|
|
89
|
+
The obvious test -- "is Cloudflare involved" -- is useless: a
|
|
90
|
+
challenge-platform script is present on pages that work fine. What
|
|
91
|
+
separates them is whether the interstitial CLEARED.
|
|
92
|
+
|
|
93
|
+
Measured on three real publisher pages with a headed browser on the same
|
|
94
|
+
VPN: PNAS 200 with an SI link, Wiley 200 with an SI link, SAGE 403 titled
|
|
95
|
+
"Just a moment...". Only the last is a wall.
|
|
96
|
+
"""
|
|
97
|
+
blocked_title = bool(re.match(r"\s*(just a moment|attention required|"
|
|
98
|
+
r"access denied|are you a robot)", title or "", re.I))
|
|
99
|
+
if status == 403 or blocked_title:
|
|
100
|
+
return MANUAL_LIKELY if not blocked_title else BLOCKED_HARD
|
|
101
|
+
if links_found:
|
|
102
|
+
return "" # retrieved fine; nothing to report
|
|
103
|
+
return NOT_PUBLISHED
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _slug(email: str, name: Optional[str] = None) -> str:
|
|
107
|
+
base = (name or email.split("@")[0]).lower()
|
|
108
|
+
base = re.sub(r"[^a-z0-9]+", "-", base).strip("-")
|
|
109
|
+
return base or "author"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class _Group:
|
|
114
|
+
contact: Contact
|
|
115
|
+
asks: List[Ask] = field(default_factory=list)
|
|
116
|
+
alternates: List[Contact] = field(default_factory=list)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def group_by_author(pairs) -> Dict[str, _Group]:
|
|
120
|
+
"""Collapse (Contact, Ask) pairs into one group per address.
|
|
121
|
+
|
|
122
|
+
One author accounts for many records in a single-author corpus, and 38
|
|
123
|
+
separate drafts would be unusable where one listing eight papers is a
|
|
124
|
+
reasonable thing to send.
|
|
125
|
+
|
|
126
|
+
Addresses are grouped case-insensitively, and the group keeps the contact
|
|
127
|
+
from the NEWEST paper -- a 2005 address is probably dead while a 2024 one
|
|
128
|
+
probably is not. The older ones are kept as alternates rather than
|
|
129
|
+
discarded, because the tool cannot actually tell which is live.
|
|
130
|
+
"""
|
|
131
|
+
groups: Dict[str, _Group] = {}
|
|
132
|
+
for contact, ask in pairs:
|
|
133
|
+
key = contact.email.lower()
|
|
134
|
+
group = groups.get(key)
|
|
135
|
+
if group is None:
|
|
136
|
+
groups[key] = _Group(contact=contact, asks=[ask])
|
|
137
|
+
continue
|
|
138
|
+
group.asks.append(ask)
|
|
139
|
+
if (ask.year or 0) > (group.contact.paper_year or 0):
|
|
140
|
+
group.alternates.append(group.contact)
|
|
141
|
+
group.contact = contact
|
|
142
|
+
else:
|
|
143
|
+
group.alternates.append(contact)
|
|
144
|
+
for group in groups.values():
|
|
145
|
+
group.asks.sort(key=lambda a: -(a.year or 0))
|
|
146
|
+
return groups
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render(group: _Group, purpose: str = _DEFAULT_PURPOSE) -> str:
|
|
150
|
+
"""One draft, as markdown. Neutral, specific, and editable."""
|
|
151
|
+
asks = group.asks
|
|
152
|
+
plural = "s" if len(asks) > 1 else ""
|
|
153
|
+
pdfs = sum(1 for a in asks if WANT_PDF in a.wants)
|
|
154
|
+
supps = sum(1 for a in asks if WANT_SUPPLEMENT in a.wants)
|
|
155
|
+
|
|
156
|
+
kind = ("materials" if pdfs and supps
|
|
157
|
+
else "full text" if pdfs else "supplementary materials")
|
|
158
|
+
subject = f"Request for {kind} — {len(asks)} paper{plural}"
|
|
159
|
+
|
|
160
|
+
lines = [f"# Request: {kind} ({len(asks)} paper{plural})", "",
|
|
161
|
+
f"**To:** {group.contact.email}", f"**Subject:** {subject}", "",
|
|
162
|
+
"---", "",
|
|
163
|
+
(f"Dear Dr. {group.contact.name}," if group.contact.name
|
|
164
|
+
else "Dear Author,"), "",
|
|
165
|
+
purpose, "",
|
|
166
|
+
f"I was unable to obtain the following from the published record, "
|
|
167
|
+
f"and would be grateful for a copy:", ""]
|
|
168
|
+
|
|
169
|
+
for ask in asks:
|
|
170
|
+
year = f" ({ask.year})" if ask.year else ""
|
|
171
|
+
title = f" — {ask.title}" if ask.title else ""
|
|
172
|
+
lines.append(f"- **{ask.doi}**{year}{title}")
|
|
173
|
+
lines.append(f" - Missing: {ask.summary}")
|
|
174
|
+
lines += ["", "Thank you for your time.", "", "---", ""]
|
|
175
|
+
|
|
176
|
+
lines.append(f"*Address source: {group.contact.provenance} "
|
|
177
|
+
f"(confidence: {group.contact.confidence})*")
|
|
178
|
+
if group.alternates:
|
|
179
|
+
others = ", ".join(
|
|
180
|
+
f"{c.email}" + (f" ({c.paper_year})" if c.paper_year else "")
|
|
181
|
+
for c in dict.fromkeys(
|
|
182
|
+
(c for c in group.alternates if c.email != group.contact.email)))
|
|
183
|
+
if others:
|
|
184
|
+
lines.append(f"*Other addresses seen for this author: {others}*")
|
|
185
|
+
manual = [a for a in asks if a.block == MANUAL_LIKELY and a.url]
|
|
186
|
+
if manual:
|
|
187
|
+
lines += ["", "*Before sending: these may just need one click in your "
|
|
188
|
+
"own browser —*"]
|
|
189
|
+
lines += [f"* - {a.url}*" for a in manual]
|
|
190
|
+
lines.append("")
|
|
191
|
+
lines.append("*Draft only. Nothing has been emailed.*")
|
|
192
|
+
return "\n".join(lines) + "\n"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
#: Manifest statuses that mean the paper's own supplements were not obtained.
|
|
196
|
+
_SUPPLEMENT_GAP_REASONS = frozenset({"epmc_not_open_access"})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def collect_asks(output_dir: str, failed_dois=None, news_dois=None):
|
|
200
|
+
"""Walk an output directory and decide what is worth asking for.
|
|
201
|
+
|
|
202
|
+
Reads the manifests already written by the supplementary pass, so this can
|
|
203
|
+
run over a finished corpus as easily as at the end of a live run.
|
|
204
|
+
|
|
205
|
+
Returns (pairs, unresolved) where pairs is [(Contact, Ask)] and unresolved
|
|
206
|
+
is [Ask] for records with a real gap but no address.
|
|
207
|
+
"""
|
|
208
|
+
import glob
|
|
209
|
+
import json
|
|
210
|
+
|
|
211
|
+
from .corresponding import find_corresponding_author
|
|
212
|
+
from .supplementary import MANIFEST_SUFFIX
|
|
213
|
+
|
|
214
|
+
failed = {str(d).strip().lower() for d in (failed_dois or ())}
|
|
215
|
+
news = {str(d).strip().lower() for d in (news_dois or ())}
|
|
216
|
+
pairs, unresolved = [], []
|
|
217
|
+
|
|
218
|
+
pattern = os.path.join(output_dir, "**", "*" + MANIFEST_SUFFIX)
|
|
219
|
+
for manifest_path in sorted(glob.glob(pattern, recursive=True)):
|
|
220
|
+
try:
|
|
221
|
+
with open(manifest_path, encoding="utf-8") as handle:
|
|
222
|
+
manifest = json.load(handle)
|
|
223
|
+
except (OSError, ValueError):
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
identifiers = manifest.get("identifiers_resolved") or {}
|
|
227
|
+
doi = identifiers.get("doi") or manifest.get("identifier") or ""
|
|
228
|
+
if not doi or doi.strip().lower() in news:
|
|
229
|
+
# No paper to request, and the least reliable place to read an
|
|
230
|
+
# address from -- news pages carry other articles' author blocks.
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
wants, details = [], []
|
|
234
|
+
declared_missing = ((manifest.get("declared") or {}).get("missing")) or []
|
|
235
|
+
if declared_missing:
|
|
236
|
+
wants.append(WANT_SUPPLEMENT)
|
|
237
|
+
details.append(", ".join(str(n) for n in declared_missing[:6]))
|
|
238
|
+
elif any((s or {}).get("reason") in _SUPPLEMENT_GAP_REASONS
|
|
239
|
+
for s in (manifest.get("skipped") or [])):
|
|
240
|
+
wants.append(WANT_SUPPLEMENT)
|
|
241
|
+
|
|
242
|
+
stem = manifest_path[:-len(MANIFEST_SUFFIX)]
|
|
243
|
+
if doi.strip().lower() in failed and not _has_artifact(stem):
|
|
244
|
+
wants.append(WANT_PDF)
|
|
245
|
+
|
|
246
|
+
if not wants:
|
|
247
|
+
continue # nothing demonstrably missing -- no email
|
|
248
|
+
|
|
249
|
+
ask = Ask(doi=doi, wants=wants, detail="; ".join(d for d in details if d),
|
|
250
|
+
url=f"https://doi.org/{doi}")
|
|
251
|
+
contact = find_corresponding_author(stem, doi=doi)
|
|
252
|
+
if contact:
|
|
253
|
+
ask.year = contact.paper_year
|
|
254
|
+
pairs.append((contact, ask))
|
|
255
|
+
else:
|
|
256
|
+
unresolved.append(ask)
|
|
257
|
+
return pairs, unresolved
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _has_artifact(stem: str) -> bool:
|
|
261
|
+
"""Whether any full-text artifact for this record landed on disk."""
|
|
262
|
+
return any(os.path.exists(stem + ext)
|
|
263
|
+
for ext in (".pdf", ".xml", ".nxml", ".fulltext.html", ".html"))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def write_drafts(groups: Dict[str, _Group], unresolved: List[Ask],
|
|
267
|
+
directory: Optional[str] = None,
|
|
268
|
+
purpose: str = _DEFAULT_PURPOSE) -> List[tuple]:
|
|
269
|
+
"""Write one file per author into `directory` (default: the CWD).
|
|
270
|
+
|
|
271
|
+
Returns [(path, n_papers, n_pdfs, n_supplements)] for the run summary.
|
|
272
|
+
"""
|
|
273
|
+
directory = directory or os.getcwd()
|
|
274
|
+
written = []
|
|
275
|
+
for group in sorted(groups.values(), key=lambda g: -len(g.asks)):
|
|
276
|
+
path = os.path.join(
|
|
277
|
+
directory, FILENAME_PREFIX + _slug(group.contact.email,
|
|
278
|
+
group.contact.name) + ".md")
|
|
279
|
+
try:
|
|
280
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
281
|
+
handle.write(render(group, purpose))
|
|
282
|
+
except OSError:
|
|
283
|
+
continue
|
|
284
|
+
written.append((path, len(group.asks),
|
|
285
|
+
sum(1 for a in group.asks if WANT_PDF in a.wants),
|
|
286
|
+
sum(1 for a in group.asks if WANT_SUPPLEMENT in a.wants)))
|
|
287
|
+
|
|
288
|
+
if unresolved:
|
|
289
|
+
path = os.path.join(directory, UNRESOLVED_NAME)
|
|
290
|
+
lines = ["# Unresolved: no corresponding-author address found", "",
|
|
291
|
+
"These records are missing material, but no address could be "
|
|
292
|
+
"extracted from the artifacts on disk. Look them up by hand.", ""]
|
|
293
|
+
for ask in sorted(unresolved, key=lambda a: -(a.year or 0)):
|
|
294
|
+
year = f" ({ask.year})" if ask.year else ""
|
|
295
|
+
lines.append(f"- **{ask.doi}**{year} — missing {ask.summary}")
|
|
296
|
+
if ask.url:
|
|
297
|
+
lines.append(f" - {ask.url}")
|
|
298
|
+
try:
|
|
299
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
300
|
+
handle.write("\n".join(lines) + "\n")
|
|
301
|
+
written.append((path, len(unresolved), 0, 0))
|
|
302
|
+
except OSError:
|
|
303
|
+
pass
|
|
304
|
+
return written
|