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,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
|