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,225 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
# .env.local is loaded here, at import time -- see _env.py.
|
|
5
|
+
from ._env import (
|
|
6
|
+
EMAIL as _DEFAULT_EMAIL,
|
|
7
|
+
S2_API_KEY as _S2_API_KEY,
|
|
8
|
+
OPENALEX_API_KEY as _OPENALEX_API_KEY,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
def fetch_metadata_from_doi(doi, email=None, delay=0.2):
|
|
12
|
+
"""
|
|
13
|
+
Progressive multi-API metadata enrichment:
|
|
14
|
+
OpenAlex → DataCite → Crossref → Unpaywall → EuropePMC → Semantic Scholar
|
|
15
|
+
Stops early if all fields are filled.
|
|
16
|
+
"""
|
|
17
|
+
# Use email from .env.local if not provided
|
|
18
|
+
if email is None:
|
|
19
|
+
email = _DEFAULT_EMAIL
|
|
20
|
+
|
|
21
|
+
if not isinstance(doi, str) or not doi.strip():
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
doi = doi.strip()
|
|
25
|
+
headers = {"User-Agent": "MetascienceObservatory/1.0"}
|
|
26
|
+
meta = {k: None for k in ["authors", "title", "journal", "volume", "issue", "pages", "year", "url"]}
|
|
27
|
+
|
|
28
|
+
def enrich(current, new):
|
|
29
|
+
"""Fill missing fields in current dict with non-empty values from new dict."""
|
|
30
|
+
if not new:
|
|
31
|
+
return current
|
|
32
|
+
for k, v in new.items():
|
|
33
|
+
if (current.get(k) in [None, "", "NaN"]) and (v not in [None, "", "NaN"]):
|
|
34
|
+
current[k] = v
|
|
35
|
+
return current
|
|
36
|
+
|
|
37
|
+
def is_complete(m):
|
|
38
|
+
"""Check if all metadata fields are filled."""
|
|
39
|
+
return all(m.get(k) not in [None, "", "NaN"] for k in m)
|
|
40
|
+
|
|
41
|
+
# ---------- 1️⃣ OpenAlex ----------
|
|
42
|
+
try:
|
|
43
|
+
openalex_headers = dict(headers)
|
|
44
|
+
if _OPENALEX_API_KEY:
|
|
45
|
+
openalex_headers["Authorization"] = f"Bearer {_OPENALEX_API_KEY}"
|
|
46
|
+
r = requests.get(
|
|
47
|
+
f"https://api.openalex.org/works/https://doi.org/{doi}",
|
|
48
|
+
timeout=10,
|
|
49
|
+
headers=openalex_headers
|
|
50
|
+
)
|
|
51
|
+
if r.status_code == 200:
|
|
52
|
+
data = r.json()
|
|
53
|
+
# host_venue was removed from the OpenAlex schema; primary_location
|
|
54
|
+
# replaces it. Reading the old key silently yielded journal=None and
|
|
55
|
+
# a doi.org url for every record.
|
|
56
|
+
primary = data.get("primary_location") or {}
|
|
57
|
+
source = primary.get("source") or {}
|
|
58
|
+
biblio = data.get("biblio") or {}
|
|
59
|
+
oa = {
|
|
60
|
+
"authors": "; ".join([a["author"]["display_name"] for a in data.get("authorships", [])]) or None,
|
|
61
|
+
"title": data.get("title"),
|
|
62
|
+
"journal": source.get("display_name"),
|
|
63
|
+
"volume": biblio.get("volume"),
|
|
64
|
+
"issue": biblio.get("issue"),
|
|
65
|
+
"pages": biblio.get("first_page"),
|
|
66
|
+
"year": data.get("publication_year"),
|
|
67
|
+
"url": primary.get("landing_page_url") or f"https://doi.org/{doi}",
|
|
68
|
+
}
|
|
69
|
+
meta = enrich(meta, oa)
|
|
70
|
+
if is_complete(meta):
|
|
71
|
+
return meta
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
time.sleep(delay)
|
|
75
|
+
|
|
76
|
+
# ---------- 2️⃣ DataCite ----------
|
|
77
|
+
try:
|
|
78
|
+
r = requests.get(f"https://api.datacite.org/dois/{doi.lower()}", timeout=10, headers=headers)
|
|
79
|
+
if r.status_code == 200:
|
|
80
|
+
d = r.json().get("data", {}).get("attributes", {})
|
|
81
|
+
authors = []
|
|
82
|
+
for a in d.get("creators", []):
|
|
83
|
+
name = a.get("name") or f"{a.get('givenName','')} {a.get('familyName','')}".strip()
|
|
84
|
+
if name:
|
|
85
|
+
authors.append(name)
|
|
86
|
+
dc = {
|
|
87
|
+
"authors": "; ".join(authors) or None,
|
|
88
|
+
"title": (d.get("titles") or [{}])[0].get("title"),
|
|
89
|
+
"journal": d.get("publisher"),
|
|
90
|
+
"year": d.get("publicationYear"),
|
|
91
|
+
"url": d.get("url") or f"https://doi.org/{doi}",
|
|
92
|
+
}
|
|
93
|
+
meta = enrich(meta, dc)
|
|
94
|
+
if is_complete(meta):
|
|
95
|
+
return meta
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
time.sleep(delay)
|
|
99
|
+
|
|
100
|
+
# ---------- 3️⃣ Crossref ----------
|
|
101
|
+
try:
|
|
102
|
+
# Use polite pool for higher rate limits (10 req/s vs 5 req/s)
|
|
103
|
+
crossref_params = {}
|
|
104
|
+
if _DEFAULT_EMAIL:
|
|
105
|
+
crossref_params["mailto"] = _DEFAULT_EMAIL
|
|
106
|
+
r = requests.get(
|
|
107
|
+
f"https://api.crossref.org/works/{doi}",
|
|
108
|
+
params=crossref_params,
|
|
109
|
+
timeout=10,
|
|
110
|
+
headers=headers
|
|
111
|
+
)
|
|
112
|
+
if r.status_code == 200:
|
|
113
|
+
m = r.json()["message"]
|
|
114
|
+
authors = []
|
|
115
|
+
for a in m.get("author", []):
|
|
116
|
+
parts = []
|
|
117
|
+
if "given" in a: parts.append(a["given"])
|
|
118
|
+
if "family" in a: parts.append(a["family"])
|
|
119
|
+
name = " ".join(parts).strip()
|
|
120
|
+
if name:
|
|
121
|
+
authors.append(name)
|
|
122
|
+
year = (
|
|
123
|
+
m.get("published-print", {}).get("date-parts", [[None]])[0][0]
|
|
124
|
+
or m.get("published-online", {}).get("date-parts", [[None]])[0][0]
|
|
125
|
+
)
|
|
126
|
+
cr = {
|
|
127
|
+
"authors": "; ".join(authors) or None,
|
|
128
|
+
"title": (m.get("title") or [None])[0],
|
|
129
|
+
"journal": (m.get("container-title") or [None])[0],
|
|
130
|
+
"volume": m.get("volume"),
|
|
131
|
+
"issue": m.get("issue"),
|
|
132
|
+
"pages": m.get("page"),
|
|
133
|
+
"year": year,
|
|
134
|
+
"url": f"https://doi.org/{doi}",
|
|
135
|
+
}
|
|
136
|
+
meta = enrich(meta, cr)
|
|
137
|
+
if is_complete(meta):
|
|
138
|
+
return meta
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
time.sleep(delay)
|
|
142
|
+
|
|
143
|
+
# ---------- 4️⃣ Unpaywall ----------
|
|
144
|
+
try:
|
|
145
|
+
r = requests.get(f"https://api.unpaywall.org/v2/{doi}?email={email}", timeout=10, headers=headers)
|
|
146
|
+
if r.status_code == 200:
|
|
147
|
+
u = r.json()
|
|
148
|
+
best_loc = u.get("best_oa_location") or {}
|
|
149
|
+
authors = "; ".join(
|
|
150
|
+
[f"{a.get('given','')} {a.get('family','')}".strip() for a in u.get("z_authors", [])]
|
|
151
|
+
) or None
|
|
152
|
+
up = {
|
|
153
|
+
"authors": authors,
|
|
154
|
+
"title": u.get("title"),
|
|
155
|
+
"journal": u.get("journal_name"),
|
|
156
|
+
"volume": u.get("journal_volume"),
|
|
157
|
+
"issue": u.get("journal_issue"),
|
|
158
|
+
"pages": u.get("journal_pages"),
|
|
159
|
+
"year": u.get("year"),
|
|
160
|
+
"url": best_loc.get("url") or u.get("doi_url") or f"https://doi.org/{doi}",
|
|
161
|
+
}
|
|
162
|
+
meta = enrich(meta, up)
|
|
163
|
+
if is_complete(meta):
|
|
164
|
+
return meta
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
time.sleep(delay)
|
|
168
|
+
|
|
169
|
+
# ---------- 5️⃣ Europe PMC ----------
|
|
170
|
+
try:
|
|
171
|
+
r = requests.get(
|
|
172
|
+
f"https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=DOI:{doi}&format=json",
|
|
173
|
+
timeout=10,
|
|
174
|
+
)
|
|
175
|
+
if r.status_code == 200:
|
|
176
|
+
data = r.json().get("resultList", {}).get("result", [])
|
|
177
|
+
if data:
|
|
178
|
+
d = data[0]
|
|
179
|
+
ep = {
|
|
180
|
+
"authors": d.get("authorString"),
|
|
181
|
+
"title": d.get("title"),
|
|
182
|
+
"journal": d.get("journalTitle"),
|
|
183
|
+
"volume": d.get("journalVolume"),
|
|
184
|
+
"issue": d.get("issue"),
|
|
185
|
+
"pages": d.get("pageInfo"),
|
|
186
|
+
"year": d.get("pubYear"),
|
|
187
|
+
"url": d.get("fullTextUrlList", {}).get("fullTextUrl", [{}])[0].get("url", f"https://doi.org/{doi}"),
|
|
188
|
+
}
|
|
189
|
+
meta = enrich(meta, ep)
|
|
190
|
+
if is_complete(meta):
|
|
191
|
+
return meta
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
time.sleep(delay)
|
|
195
|
+
|
|
196
|
+
# ---------- 6️⃣ Semantic Scholar ----------
|
|
197
|
+
try:
|
|
198
|
+
s2_headers = dict(headers)
|
|
199
|
+
if _S2_API_KEY:
|
|
200
|
+
s2_headers["x-api-key"] = _S2_API_KEY
|
|
201
|
+
r = requests.get(
|
|
202
|
+
f"https://api.semanticscholar.org/graph/v1/paper/DOI:{doi}"
|
|
203
|
+
"?fields=title,year,venue,url,authors",
|
|
204
|
+
timeout=10,
|
|
205
|
+
headers=s2_headers,
|
|
206
|
+
)
|
|
207
|
+
if r.status_code == 200:
|
|
208
|
+
s = r.json()
|
|
209
|
+
ss = {
|
|
210
|
+
"authors": "; ".join(a.get("name", "") for a in s.get("authors", [])) or None,
|
|
211
|
+
"title": s.get("title"),
|
|
212
|
+
"journal": s.get("venue"),
|
|
213
|
+
"year": s.get("year"),
|
|
214
|
+
"url": s.get("url") or f"https://doi.org/{doi}",
|
|
215
|
+
}
|
|
216
|
+
meta = enrich(meta, ss)
|
|
217
|
+
if is_complete(meta):
|
|
218
|
+
return meta
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# ---------- Default fallback ----------
|
|
223
|
+
if not meta["url"]:
|
|
224
|
+
meta["url"] = f"https://doi.org/{doi}"
|
|
225
|
+
return meta
|