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,69 @@
1
+ """What a source is handed when the engine calls it.
2
+
3
+ Its own module so `sources.*` can depend on it without importing the engine,
4
+ which imports the sources back through the ladder config.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional
9
+
10
+ from .tiers import Ladder, Tier
11
+
12
+
13
+ @dataclass
14
+ class RetrievalContext:
15
+ """Per-record retrieval state shared with every source."""
16
+
17
+ http: object # http.HttpClient
18
+ resolver: object # resolve.BatchResolver
19
+ ladder: Ladder
20
+ save_path: str # the .pdf path the caller asked for
21
+ target_task: str = "extraction"
22
+ verbose: bool = False
23
+ use_playwright: bool = False
24
+ email: Optional[str] = None
25
+ delay: float = 0.1
26
+ provenance: Optional[object] = None # provenance.ProvenanceRecord or None
27
+
28
+ #: --download-data-artifacts. Off, the linked-artifact providers route only
29
+ #: the paper's own material (CLASS_OWNED) and record everything else in the
30
+ #: sidecar without fetching it. On, they also route `related` deposits --
31
+ #: which is where the actual research data lives: in one 44-sidecar corpus
32
+ #: every `owned` link was a BioStudies mirror of supplementary files already
33
+ #: fetched, while the Zenodo and Dataverse replication packages were all
34
+ #: classified `related`.
35
+ download_data_artifacts: bool = False
36
+
37
+ #: Per-record ceiling for those artifacts, kept separate from the
38
+ #: supplementary budget so one large dataset cannot starve the paper's own
39
+ #: SI (or the reverse). None means the default in supplementary.py.
40
+ max_data_artifact_bytes: Optional[int] = None
41
+
42
+ #: --download-related-unverified. Off, a `related` deposit is skipped when
43
+ #: its own DataCite record declares it belongs to a DIFFERENT article. On,
44
+ #: every index assertion is taken at face value -- which is how "Tying
45
+ #: Odysseus to the Mast" ended up filed under a savings-reminder megastudy.
46
+ download_related_unverified: bool = False
47
+
48
+
49
+
50
+ #: Set once a T1 artifact has been accepted for this record. NCBI efetch and
51
+ #: Europe PMC serve the same underlying PMC JATS, so storing both is not
52
+ #: corroboration -- it manufactures false agreement downstream.
53
+ pmc_xml_already_taken: bool = False
54
+
55
+ #: Sources may leave breadcrumbs for later sources in the same walk.
56
+ scratch: dict = field(default_factory=dict)
57
+
58
+ def log(self, message: str) -> None:
59
+ if self.verbose:
60
+ print(message)
61
+
62
+ def threshold(self, key: str, default: int) -> int:
63
+ return self.ladder.threshold(key, default)
64
+
65
+ def is_extraction(self) -> bool:
66
+ return self.target_task == "extraction"
67
+
68
+
69
+ __all__ = ["RetrievalContext", "Tier"]
@@ -0,0 +1,349 @@
1
+ """Who to ask when the file cannot be fetched.
2
+
3
+ For a paywalled paper or a withheld supplement the corresponding author is
4
+ often the only remaining route -- no API has the file and no browser gets past
5
+ the publisher. Their address is not in any metadata API, but it is nearly
6
+ always in the artifacts already on disk.
7
+
8
+ Measured over 43 papers in a real corpus:
9
+
10
+ Crossref author[].email 0 of 3 probed -- not populated in practice
11
+ JATS <corresp><email> structural, exact, but only 7 records have XML
12
+ HTML several shapes highest precision when present (see below)
13
+ PDF first two pages 26 of 31 PDF-only records
14
+
15
+ Combined, ~79% of records lacking material yield an address. Everything here
16
+ reads files already downloaded, so discovery costs no network requests.
17
+
18
+ Precision, not recall, is the thing to protect: a wrong address emails a
19
+ stranger in the user's name. The ordering below is by how *explicitly* each
20
+ source states the corresponding role --
21
+
22
+ JATS @corresp="yes" the document says so in markup
23
+ HTML "Corresponding Author" + address the page says so in markup
24
+ PDF prose near a cue, scored we are inferring
25
+
26
+ -- and the scored PDF heuristic scores 97% (35/36 checkable picks) with 86%
27
+ coverage. The single error was a Nature *news* page reprinting a different
28
+ article's author block, which is why callers must skip records already
29
+ classified as news/editorial.
30
+
31
+ One HTML shape deserves its own note. Wiley renders the address as the literal
32
+ text "[email protected]" -- Cloudflare email protection -- so a plain regex
33
+ finds nothing on a page that does in fact carry the address. The real value is
34
+ in `data-cfemail`, hex, XOR-ed with its own first byte. Verified:
35
+
36
+ data-cfemail="244a49455e45566446510a414051" -> nmazar@bu.edu
37
+
38
+ which matches the same paper's PDF exactly.
39
+ """
40
+
41
+ import html as _html
42
+ import os
43
+ import re
44
+ from dataclasses import dataclass
45
+ from typing import List, Optional
46
+
47
+ #: How the address was found, most trustworthy first. Recorded on the Contact
48
+ #: so a draft can show its provenance and a human can judge a stale address.
49
+ SOURCE_JATS = "jats"
50
+ SOURCE_HTML = "html"
51
+ SOURCE_PDF = "pdf"
52
+
53
+ _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
54
+
55
+ #: Phrases that mark the following (or nearby) address as the corresponding
56
+ #: author's. Taken from the shapes actually observed across the corpus, not
57
+ #: guessed: "Corresponding author:", "E-mail addresses:", "Electronic mail may
58
+ #: be sent to", "Address correspondence to".
59
+ _CUE_RE = re.compile(
60
+ r"(correspond\w*|e-?mail address(es)?|electronic mail may be sent"
61
+ r"|address correspondence|to whom correspondence|e-?mail:)",
62
+ re.I,
63
+ )
64
+
65
+ #: Addresses that are never a person we should write to.
66
+ _JUNK_RE = re.compile(
67
+ r"(^|[.@])(editor|permissions?|reprints?|support|info|help|noreply|no-reply"
68
+ r"|subscription|customerservice|webmaster|admin)([.@]|$)"
69
+ r"|@(wiley|elsevier|springernature|sciencedirect|tandf|sagepub)\.com$",
70
+ re.I,
71
+ )
72
+
73
+ #: How far back to look for a cue. One sentence, roughly -- far enough to cross
74
+ #: "Corresponding author. E-mail address:" and its markup, short enough not to
75
+ #: reach the previous author's block.
76
+ _CUE_WINDOW = 160
77
+
78
+ #: Author blocks live at the top. Reading two pages bounds the cost and avoids
79
+ #: the reference list, where other people's addresses appear.
80
+ _PDF_PAGES = 2
81
+
82
+
83
+ @dataclass
84
+ class Contact:
85
+ """One corresponding-author address, and where it came from."""
86
+
87
+ email: str
88
+ source: str # SOURCE_JATS | SOURCE_HTML | SOURCE_PDF
89
+ name: Optional[str] = None
90
+ cue: str = "" # the phrase that justified the pick
91
+ paper_doi: Optional[str] = None
92
+ paper_year: Optional[int] = None
93
+
94
+ @property
95
+ def confidence(self) -> str:
96
+ """Coarse, and honest about what each source proves."""
97
+ if self.source == SOURCE_JATS:
98
+ return "high"
99
+ if self.source == SOURCE_HTML:
100
+ return "high" if self.cue else "medium"
101
+ return "medium" if self.cue else "low"
102
+
103
+ @property
104
+ def provenance(self) -> str:
105
+ where = {SOURCE_JATS: "JATS <corresp>", SOURCE_HTML: "article HTML",
106
+ SOURCE_PDF: "PDF first page"}.get(self.source, self.source)
107
+ bits = [where]
108
+ if self.paper_doi:
109
+ bits.append(f"of {self.paper_doi}")
110
+ if self.paper_year:
111
+ bits.append(f"({self.paper_year})")
112
+ if self.cue:
113
+ bits.append(f'cue "{self.cue}"')
114
+ return " ".join(bits)
115
+
116
+
117
+ def _clean(email: str) -> str:
118
+ return email.strip().rstrip(".,;:)>\"'").lower()
119
+
120
+
121
+ def _acceptable(email: str) -> bool:
122
+ return bool(email) and not _JUNK_RE.search(email)
123
+
124
+
125
+ # -- Cloudflare email protection ---------------------------------------------
126
+
127
+ def decode_cfemail(hex_blob: str) -> Optional[str]:
128
+ """Undo Cloudflare's data-cfemail obfuscation.
129
+
130
+ First byte is the XOR key; the rest is the address. Without this, every
131
+ Cloudflare-protected publisher page looks like it has no address at all --
132
+ the DOM shows only the literal string "[email protected]".
133
+ """
134
+ try:
135
+ raw = bytes.fromhex(hex_blob.strip())
136
+ except ValueError:
137
+ return None
138
+ if len(raw) < 2:
139
+ return None
140
+ key = raw[0]
141
+ try:
142
+ decoded = "".join(chr(byte ^ key) for byte in raw[1:])
143
+ except ValueError:
144
+ return None
145
+ return decoded if _EMAIL_RE.fullmatch(decoded) else None
146
+
147
+
148
+ # -- JATS ---------------------------------------------------------------------
149
+
150
+ _JATS_CORRESP_RE = re.compile(
151
+ r"<corresp\b[^>]*>(.*?)</corresp>", re.S | re.I)
152
+ _JATS_EMAIL_RE = re.compile(r"<email[^>]*>([^<]+)</email>", re.I)
153
+
154
+
155
+ def from_jats(text: str) -> Optional[Contact]:
156
+ """The address inside a <corresp> block -- the document's own statement."""
157
+ if not text:
158
+ return None
159
+ for block in _JATS_CORRESP_RE.findall(text):
160
+ found = _JATS_EMAIL_RE.search(block)
161
+ if found:
162
+ email = _clean(found.group(1))
163
+ if _acceptable(email):
164
+ return Contact(email=email, source=SOURCE_JATS,
165
+ name=_name_in_corresp(block), cue="corresp")
166
+ # Some publishers put the email outside <corresp> but mark the author
167
+ # @corresp="yes"; fall back to the first <email> in the front matter.
168
+ head = text[:_front_matter_end(text)]
169
+ found = _JATS_EMAIL_RE.search(head)
170
+ if found:
171
+ email = _clean(found.group(1))
172
+ if _acceptable(email):
173
+ return Contact(email=email, source=SOURCE_JATS)
174
+ return None
175
+
176
+
177
+ #: "should be addressed to: Mara Mather, 3715 McClintock Ave., ..." -- the
178
+ #: name is the first comma-delimited fragment after the phrase. Only accepted
179
+ #: when it looks like a personal name (2-4 capitalised words), so an address
180
+ #: fragment or a department never becomes a salutation.
181
+ _CORRESP_NAME_RE = re.compile(
182
+ r"(?:addressed to|correspondence to|author)[:\s]+"
183
+ r"([A-Z][A-Za-z.'\u2019-]+(?:\s+[A-Z][A-Za-z.'\u2019-]+){1,3})(?=[,.]|\s+at\b)")
184
+
185
+
186
+ def _name_in_corresp(block: str) -> Optional[str]:
187
+ """The addressee named inside a <corresp> block, if it states one.
188
+
189
+ Better than "Dear Dr. [name]" in a draft the user has to edit, and safe to
190
+ skip when absent -- a wrong name is worse than a placeholder.
191
+ """
192
+ plain = " ".join(_TAG_RE.sub(" ", block or "").split())
193
+ found = _CORRESP_NAME_RE.search(plain)
194
+ return found.group(1).strip() if found else None
195
+
196
+
197
+ def _front_matter_end(text: str) -> int:
198
+ """Where the article body starts, so we never read the reference list."""
199
+ for marker in ("</front>", "<body", "<ref-list"):
200
+ index = text.find(marker)
201
+ if index > 0:
202
+ return index
203
+ return min(len(text), 20000)
204
+
205
+
206
+ # -- HTML ---------------------------------------------------------------------
207
+
208
+ _TAG_RE = re.compile(r"<[^>]+>")
209
+ _SCRIPT_RE = re.compile(r"<script.*?</script>|<style.*?</style>", re.S | re.I)
210
+ _CFEMAIL_RE = re.compile(r'data-cfemail="([a-fA-F0-9]+)"')
211
+ _MAILTO_RE = re.compile(r'href="mailto:([^"?]+)', re.I)
212
+ _META_EMAIL_RE = re.compile(
213
+ r'<meta[^>]+name="citation_author_email"[^>]+content="([^"]+)"', re.I)
214
+
215
+
216
+ def from_html(text: str) -> Optional[Contact]:
217
+ """Address from an article page, in descending order of explicitness."""
218
+ if not text:
219
+ return None
220
+
221
+ # 1. An explicit mailto is unambiguous.
222
+ for match in _MAILTO_RE.finditer(text):
223
+ email = _clean(match.group(1))
224
+ if _acceptable(email):
225
+ near = _TAG_RE.sub(" ", text[max(0, match.start() - 300):match.start()])
226
+ cue = _CUE_RE.search(near)
227
+ return Contact(email=email, source=SOURCE_HTML,
228
+ cue=cue.group(0) if cue else "")
229
+
230
+ # 2. Cloudflare-obfuscated. Prefer one whose surrounding text says
231
+ # "Corresponding Author" -- Wiley marks the role in the DOM even while
232
+ # hiding the address.
233
+ best = None
234
+ for match in _CFEMAIL_RE.finditer(text):
235
+ email = decode_cfemail(match.group(1))
236
+ if not email or not _acceptable(_clean(email)):
237
+ continue
238
+ near = _TAG_RE.sub(" ", text[max(0, match.start() - 400):match.start()])
239
+ cue = _CUE_RE.search(near)
240
+ candidate = Contact(email=_clean(email), source=SOURCE_HTML,
241
+ cue=cue.group(0) if cue else "")
242
+ if cue:
243
+ return candidate
244
+ best = best or candidate
245
+ if best:
246
+ return best
247
+
248
+ # 3. Publisher metadata, when emitted.
249
+ found = _META_EMAIL_RE.search(text)
250
+ if found:
251
+ email = _clean(found.group(1))
252
+ if _acceptable(email):
253
+ return Contact(email=email, source=SOURCE_HTML, cue="citation_author_email")
254
+
255
+ # 4. A bare address in the visible text, scored like the PDF case.
256
+ visible = _html.unescape(_TAG_RE.sub(" ", _SCRIPT_RE.sub(" ", text)))
257
+ return _scored_pick(visible, SOURCE_HTML)
258
+
259
+
260
+ # -- PDF ----------------------------------------------------------------------
261
+
262
+ def from_pdf(path: str) -> Optional[Contact]:
263
+ """Address from the first pages of the PDF.
264
+
265
+ pypdf rather than a pdftotext subprocess: pure Python, so this works on
266
+ Windows and needs no external binary.
267
+ """
268
+ try:
269
+ from pypdf import PdfReader
270
+ except ImportError:
271
+ return None
272
+ try:
273
+ reader = PdfReader(path)
274
+ pages = reader.pages[:_PDF_PAGES]
275
+ text = "\n".join((page.extract_text() or "") for page in pages)
276
+ except Exception:
277
+ return None
278
+ return _scored_pick(text, SOURCE_PDF)
279
+
280
+
281
+ def _scored_pick(text: str, source: str) -> Optional[Contact]:
282
+ """The most likely corresponding address in a block of prose.
283
+
284
+ 14 of 43 corpus PDFs carry more than one address, so first-match is wrong.
285
+ Score: +2 for a corresponding-author cue just before the match, +1 for
286
+ sitting in the first half of the text (author blocks precede everything
287
+ else). Ties break toward the earlier match.
288
+ """
289
+ if not text:
290
+ return None
291
+ best_score = -1
292
+ best: Optional[Contact] = None
293
+ midpoint = max(1, len(text) // 2)
294
+ for match in _EMAIL_RE.finditer(text):
295
+ email = _clean(match.group(0))
296
+ if not _acceptable(email):
297
+ continue
298
+ before = text[max(0, match.start() - _CUE_WINDOW):match.start()]
299
+ cue = _CUE_RE.search(before)
300
+ score = (2 if cue else 0) + (1 if match.start() < midpoint else 0)
301
+ if score > best_score:
302
+ best_score = score
303
+ best = Contact(email=email, source=source,
304
+ cue=cue.group(0) if cue else "")
305
+ return best
306
+
307
+
308
+ # -- the record-level entry point ---------------------------------------------
309
+
310
+ #: Extensions searched, in the precision order argued for in the docstring.
311
+ _MARKUP = ((".xml", from_jats), (".nxml", from_jats),
312
+ (".fulltext.html", from_html), (".html", from_html),
313
+ (".htm", from_html))
314
+
315
+
316
+ def find_corresponding_author(stem: str, doi: Optional[str] = None,
317
+ year: Optional[int] = None) -> Optional[Contact]:
318
+ """Best available address for one record, from artifacts already on disk.
319
+
320
+ `stem` is the output path stem, matching how the rest of the supplementary
321
+ subsystem addresses a record. Returns None when nothing usable is found --
322
+ the caller lists those separately rather than guessing.
323
+ """
324
+ if not stem:
325
+ return None
326
+
327
+ for extension, extract in _MARKUP:
328
+ path = stem + extension
329
+ if not os.path.exists(path):
330
+ continue
331
+ try:
332
+ with open(path, encoding="utf-8", errors="replace") as handle:
333
+ text = handle.read()
334
+ except OSError:
335
+ continue
336
+ contact = extract(text)
337
+ if contact:
338
+ contact.paper_doi = contact.paper_doi or doi
339
+ contact.paper_year = contact.paper_year or year
340
+ return contact
341
+
342
+ pdf_path = stem + ".pdf"
343
+ if os.path.exists(pdf_path):
344
+ contact = from_pdf(pdf_path)
345
+ if contact:
346
+ contact.paper_doi = contact.paper_doi or doi
347
+ contact.paper_year = contact.paper_year or year
348
+ return contact
349
+ return None