fetchpdf 0.1.0__py3-none-any.whl

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