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