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,187 @@
1
+ """Files from a DSpace 7 institutional repository.
2
+
3
+ The long tail of green open access lives in institutional repositories, and a
4
+ large share of them run DSpace 7, which exposes a uniform REST API. That
5
+ uniformity is the point: one walk covers Harvard DASH, MIT, and hundreds of
6
+ others, with no scraping and no per-site special-casing.
7
+
8
+ /server/api/pid/find?id=hdl:1/10976353 -> the item (302, then JSON)
9
+ /server/api/core/items/<uuid>/bundles -> ORIGINAL, TEXT, THUMBNAIL, ...
10
+ .../bundles/<uuid>/bitstreams -> the files
11
+ .../bitstreams/<uuid>/content -> the bytes
12
+
13
+ Verified end to end against Harvard DASH for 10.1073/pnas.1209746109: handle
14
+ 1/10976353 resolves to item 73120378-b728-6bd4-e053-0100007fdf3b, whose
15
+ ORIGINAL bundle yields a real 403 KB PDF.
16
+
17
+ Only the ORIGINAL bundle is taken. TEXT holds a machine-extracted plaintext
18
+ copy of the same PDF -- keeping it would file a lossy duplicate of the article
19
+ as a supplementary file -- and THUMBNAIL is a preview image.
20
+
21
+ What this is NOT: a way to get supplementary material a publisher withholds.
22
+ The DASH deposit for that PNAS paper is the accepted manuscript, 23 pages,
23
+ article only -- no SI. Repositories hold the paper far more often than they
24
+ hold its supplements. This earns its place as a full-text source of last
25
+ resort, not as a supplement route, and the caller should treat what it returns
26
+ accordingly.
27
+ """
28
+
29
+ import re
30
+ from typing import List, Optional
31
+ from urllib.parse import urlparse
32
+
33
+ from .supplement_index import (
34
+ ROLE_SUPPLEMENT,
35
+ ROLE_UNKNOWN,
36
+ SupplementFile,
37
+ classify_role,
38
+ )
39
+
40
+ #: DSpace's own paths, identical across installations.
41
+ _PID_FIND = "{base}/server/api/pid/find"
42
+ _BUNDLES = "{base}/server/api/core/items/{uuid}/bundles"
43
+
44
+ #: ORIGINAL is the deposited files. TEXT is an extracted-plaintext derivative
45
+ #: of them and THUMBNAIL a preview, so both would be duplicates of something we
46
+ #: already have.
47
+ _WANTED_BUNDLES = {"ORIGINAL"}
48
+
49
+ #: A handle looks like "1/10976353" or "hdl:1/10976353", possibly inside a URL.
50
+ #: Handle prefixes are not always bare integers: MIT's is "1721.1", so
51
+ #: (\d+/\d+) silently truncated 1721.1/12345 to 1/12345 -- a valid-looking
52
+ #: handle for a different item.
53
+ _HANDLE_RE = re.compile(r"(?:hdl:|/handle/)([\d.]+/\w+)")
54
+
55
+ #: Harvard's purl form carries the item id with no handle prefix at all:
56
+ #: nrs.harvard.edu/urn-3:HUL.InstRepos:10976353. The DASH handle is always
57
+ #: "1/<that id>", and the purl host is not the repository, so both have to be
58
+ #: rewritten rather than parsed out of the URL.
59
+ _HARVARD_PURL_RE = re.compile(r"urn-3:HUL\.InstRepos:(\d+)", re.I)
60
+
61
+ _MAX_FILES = 40
62
+
63
+ #: classify_role returns ROLE_UNKNOWN for most repository filenames, which the
64
+ #: supplementary pass keeps anyway. Defaulting to SUPPLEMENT is the safer of
65
+ #: the two errors: a deposit's extra files are usually appendices, and dropping
66
+ #: one loses more than keeping a duplicate costs.
67
+ ROLE_UNKNOWN_FALLBACK = ROLE_SUPPLEMENT
68
+
69
+
70
+ def dspace_handle(url_or_handle: Optional[str]):
71
+ """(base_url, handle) for a DSpace landing page, or None.
72
+
73
+ Accepts what OpenAlex actually gives out -- an nrs.harvard.edu purl, a
74
+ /handle/ URL, or a bare handle with an explicit base.
75
+ """
76
+ if not url_or_handle:
77
+ return None
78
+ text = str(url_or_handle).strip()
79
+ purl = _HARVARD_PURL_RE.search(text)
80
+ if purl:
81
+ return "https://dash.harvard.edu", f"1/{purl.group(1)}"
82
+ match = _HANDLE_RE.search(text)
83
+ if not match:
84
+ return None
85
+ handle = match.group(1)
86
+ # Only a real URL carries a repository to talk to. A bare "hdl:1721.1/999"
87
+ # has no host, and urlparse would otherwise hand back the handle itself as
88
+ # the netloc -- giving "https://hdl:1721.1" as a base URL.
89
+ if "//" not in text:
90
+ return None
91
+ parsed = urlparse(text)
92
+ host = parsed.netloc
93
+ if not host or "." not in host:
94
+ return None
95
+ return f"{parsed.scheme or 'https'}://{host}", handle
96
+
97
+
98
+ def enumerate_dspace(ids, ctx, landing_url: Optional[str] = None) -> List[SupplementFile]:
99
+ """Files in a DSpace 7 item, resolved from its handle.
100
+
101
+ `landing_url` is the repository URL (OpenAlex's `landing_page_url` for a
102
+ green location). Without one there is nothing to resolve -- DSpace has no
103
+ DOI-based lookup -- so this declines rather than guessing.
104
+ """
105
+ target = landing_url or (ctx.scratch.get("dspace_landing_url") if ctx else None)
106
+ parsed = dspace_handle(target)
107
+ if not parsed:
108
+ return []
109
+ base, handle = parsed
110
+
111
+ response = ctx.http.get(_PID_FIND.format(base=base),
112
+ params={"id": f"hdl:{handle}"},
113
+ headers={"Accept": "application/json"},
114
+ timeout=30, polite=False)
115
+ if not response.ok:
116
+ ctx.log(f" dspace: {base} handle {handle} -> HTTP {response.status}")
117
+ return []
118
+ try:
119
+ item = response.json() or {}
120
+ except ValueError:
121
+ return []
122
+ uuid = item.get("uuid")
123
+ if not uuid:
124
+ return []
125
+
126
+ bundles = ctx.http.get(_BUNDLES.format(base=base, uuid=uuid),
127
+ headers={"Accept": "application/json"},
128
+ timeout=30, polite=False)
129
+ if not bundles.ok:
130
+ return []
131
+ try:
132
+ embedded = (bundles.json() or {}).get("_embedded") or {}
133
+ except ValueError:
134
+ return []
135
+
136
+ files: List[SupplementFile] = []
137
+ for bundle in embedded.get("bundles") or []:
138
+ if str(bundle.get("name") or "").upper() not in _WANTED_BUNDLES:
139
+ continue
140
+ href = ((bundle.get("_links") or {}).get("bitstreams") or {}).get("href")
141
+ if not href:
142
+ continue
143
+ listing = ctx.http.get(href, headers={"Accept": "application/json"},
144
+ timeout=30, polite=False)
145
+ if not listing.ok:
146
+ continue
147
+ try:
148
+ bitstreams = ((listing.json() or {}).get("_embedded") or {}).get("bitstreams") or []
149
+ except ValueError:
150
+ continue
151
+ for index, bitstream in enumerate(bitstreams):
152
+ if len(files) >= _MAX_FILES:
153
+ break
154
+ content = ((bitstream.get("_links") or {}).get("content") or {}).get("href")
155
+ name = bitstream.get("name") or ""
156
+ if not content or not name:
157
+ continue
158
+ # A repository deposit is usually the ARTICLE, not a supplement, so
159
+ # let the shared heuristic decide rather than assuming either way.
160
+ role = classify_role(name)
161
+ if role == ROLE_UNKNOWN:
162
+ # "unknown" is truthy, so an `or` fallback would never fire.
163
+ role = ROLE_UNKNOWN_FALLBACK
164
+ files.append(SupplementFile(
165
+ name=name,
166
+ url=content,
167
+ provider="dspace_files",
168
+ listing_index=index,
169
+ size_bytes=bitstream.get("sizeBytes"),
170
+ checksum=_checksum_of(bitstream),
171
+ role=role,
172
+ origin_doi=getattr(ids, "doi", None),
173
+ extra={"dspace_base": base, "handle": handle, "item": uuid},
174
+ ))
175
+ if files:
176
+ ctx.log(f" dspace: {len(files)} file(s) from {base} {handle}")
177
+ return files
178
+
179
+
180
+ def _checksum_of(bitstream) -> Optional[str]:
181
+ """DSpace's checkSum block as the "md5:..." form SupplementFile expects."""
182
+ block = bitstream.get("checkSum") or {}
183
+ value = block.get("value")
184
+ algorithm = str(block.get("checkSumAlgorithm") or "").lower()
185
+ if value and algorithm:
186
+ return f"{algorithm}:{value}"
187
+ return None