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,549 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
import requests
|
|
5
|
+
from urllib.parse import quote_plus
|
|
6
|
+
|
|
7
|
+
from .fetchpdf import _get_with_retries
|
|
8
|
+
|
|
9
|
+
# .env.local is loaded here, at import time -- see _env.py.
|
|
10
|
+
from ._env import (
|
|
11
|
+
EMAIL as _DEFAULT_EMAIL,
|
|
12
|
+
S2_API_KEY as _S2_API_KEY,
|
|
13
|
+
ENTREZ_API_KEY as _NCBI_API_KEY,
|
|
14
|
+
OPENALEX_API_KEY as _OPENALEX_API_KEY,
|
|
15
|
+
CORE_API_KEY as _CORE_API_KEY,
|
|
16
|
+
SCOPUS_API_KEY as _SCOPUS_API_KEY,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_HEADERS = {"User-Agent": "MetascienceObservatory/1.0"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Helpers
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_EMPTY_ABSTRACT_PATTERNS = re.compile(
|
|
27
|
+
r'^(abstract\s+)?not\s+available|^no\s+abstract|^n/a$|^none$|^\[no\s+abstract\]',
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_JUNK_ABSTRACT_KEYWORDS = [
|
|
33
|
+
'cookie policy', 'cookies to enhance', 'sign in', 'purchase options',
|
|
34
|
+
'subscribe to the', 'privacy policy', 'terms of use', 'access through your institution',
|
|
35
|
+
'create an account', 'buy this article', 'rent this article',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_empty_abstract(text):
|
|
40
|
+
"""Return True if text is a placeholder or scraped junk (cookie banners, login pages)."""
|
|
41
|
+
if not text:
|
|
42
|
+
return True
|
|
43
|
+
t = text.strip()
|
|
44
|
+
if _EMPTY_ABSTRACT_PATTERNS.match(t):
|
|
45
|
+
return True
|
|
46
|
+
tl = t.lower()
|
|
47
|
+
# Scraped website junk: cookie banners, login walls, nav menus
|
|
48
|
+
if sum(1 for kw in _JUNK_ABSTRACT_KEYWORDS if kw in tl) >= 3:
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _strip_jats(text):
|
|
54
|
+
"""Remove JATS/HTML tags from abstract text, converting section headings to bold."""
|
|
55
|
+
if not text:
|
|
56
|
+
return None
|
|
57
|
+
# Section headings → **Heading** (handles <h4>, <jats:title>, <strong>, <b>)
|
|
58
|
+
text = re.sub(
|
|
59
|
+
r'<(?:h\d|jats:title|strong|b)[^>]*>([^<]+)</(?:h\d|jats:title|strong|b)>',
|
|
60
|
+
r'**\1** ', text, flags=re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
# Strip all remaining tags
|
|
63
|
+
text = re.sub(r'<[^>]+>', '', text)
|
|
64
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
65
|
+
if not text or _is_empty_abstract(text):
|
|
66
|
+
return None
|
|
67
|
+
return text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reconstruct_openalex_abstract(inverted_index):
|
|
71
|
+
"""Reconstruct abstract string from OpenAlex inverted index format."""
|
|
72
|
+
if not inverted_index:
|
|
73
|
+
return None
|
|
74
|
+
words = {}
|
|
75
|
+
for word, positions in inverted_index.items():
|
|
76
|
+
for pos in positions:
|
|
77
|
+
words[pos] = word
|
|
78
|
+
return " ".join(words[i] for i in sorted(words.keys())) or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _arxiv_id_from_doi(doi):
|
|
82
|
+
"""Extract arXiv ID from a 10.48550/arXiv.* DOI, or None."""
|
|
83
|
+
m = re.search(r'10\.48550/[Aa]r[Xx]iv\.(\S+)', doi)
|
|
84
|
+
return m.group(1) if m else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Main function
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def fetch_abstract_from_doi(doi, email=None, delay=0.1, verbose=False):
|
|
92
|
+
"""
|
|
93
|
+
Fetch title and abstract for a DOI using multiple API sources.
|
|
94
|
+
|
|
95
|
+
Sources tried in order (stops as soon as title + abstract are both found):
|
|
96
|
+
1. Europe PMC (abstractText, biomedical focus)
|
|
97
|
+
2. Crossref (abstract field, JATS/HTML cleaned)
|
|
98
|
+
3. Semantic Scholar (abstract field)
|
|
99
|
+
4. OpenAlex (abstract_inverted_index reconstructed)
|
|
100
|
+
5. arXiv API (for 10.48550/arXiv.* DOIs)
|
|
101
|
+
6. DataCite (descriptions array, good for preprints/datasets)
|
|
102
|
+
7. CORE (description field, requires COREAPIKEY)
|
|
103
|
+
8. DOAJ (bibjson.abstract, open access journals)
|
|
104
|
+
9. Scopus (dc:description, requires SCOPUS_API_KEY)
|
|
105
|
+
10. PubMed efetch (DOI→PMID→plain-text abstract, biomedical fallback)
|
|
106
|
+
|
|
107
|
+
Returns a dict with keys:
|
|
108
|
+
title, abstract, authors, journal, year, doi
|
|
109
|
+
All values may be None if unavailable.
|
|
110
|
+
"""
|
|
111
|
+
if email is None:
|
|
112
|
+
email = _DEFAULT_EMAIL
|
|
113
|
+
|
|
114
|
+
doi = doi.strip()
|
|
115
|
+
|
|
116
|
+
# Normalize filename-format DOIs (inverse of doi_to_safe_filename), e.g.
|
|
117
|
+
# "10.1093--eurheartj--ehaf339" → "10.1093/eurheartj/ehaf339". A string that
|
|
118
|
+
# already contains '/' is a real DOI whose '--' may be literal (ASEE
|
|
119
|
+
# 10.18260/1-2--47556) — leave it alone.
|
|
120
|
+
from .fetchpdf import _decode_fs_tokens
|
|
121
|
+
if '~' in doi:
|
|
122
|
+
doi = _decode_fs_tokens(doi)
|
|
123
|
+
if '--' in doi and '/' not in doi:
|
|
124
|
+
doi = doi.replace('--', '/')
|
|
125
|
+
doi = doi.replace('\x00', '-')
|
|
126
|
+
|
|
127
|
+
result = {
|
|
128
|
+
"title": None,
|
|
129
|
+
"abstract": None,
|
|
130
|
+
"authors": None,
|
|
131
|
+
"journal": None,
|
|
132
|
+
"year": None,
|
|
133
|
+
"doi": doi,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def _enrich(new):
|
|
137
|
+
for k, v in new.items():
|
|
138
|
+
if v in (None, "", "NaN"):
|
|
139
|
+
continue
|
|
140
|
+
if k == "abstract" and _is_empty_abstract(v):
|
|
141
|
+
continue
|
|
142
|
+
cur = result.get(k)
|
|
143
|
+
if cur in (None, "", "NaN") or (k == "abstract" and _is_empty_abstract(cur)):
|
|
144
|
+
result[k] = v
|
|
145
|
+
|
|
146
|
+
def _done():
|
|
147
|
+
return bool(result["title"] and result["abstract"])
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# 1. Europe PMC
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
try:
|
|
153
|
+
r = _get_with_retries(
|
|
154
|
+
"https://www.ebi.ac.uk/europepmc/webservices/rest/search"
|
|
155
|
+
f"?query=DOI:{doi}&format=json&resultType=core",
|
|
156
|
+
headers=_HEADERS, timeout=20,
|
|
157
|
+
)
|
|
158
|
+
if r.status_code == 200:
|
|
159
|
+
hits = r.json().get("resultList", {}).get("result", [])
|
|
160
|
+
if hits:
|
|
161
|
+
d = hits[0]
|
|
162
|
+
_enrich({
|
|
163
|
+
"title": d.get("title"),
|
|
164
|
+
"abstract": _strip_jats(d.get("abstractText")) or None,
|
|
165
|
+
"authors": d.get("authorString"),
|
|
166
|
+
"journal": d.get("journalTitle"),
|
|
167
|
+
"year": d.get("pubYear"),
|
|
168
|
+
})
|
|
169
|
+
if verbose and result["abstract"]:
|
|
170
|
+
print(" ✅ Abstract from Europe PMC")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
if verbose: print(f" Europe PMC error: {e}")
|
|
173
|
+
time.sleep(delay)
|
|
174
|
+
if _done(): return result
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# 2. Crossref
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
try:
|
|
180
|
+
params = {}
|
|
181
|
+
if _DEFAULT_EMAIL:
|
|
182
|
+
params["mailto"] = _DEFAULT_EMAIL
|
|
183
|
+
r = requests.get(
|
|
184
|
+
f"https://api.crossref.org/works/{doi}",
|
|
185
|
+
params=params, headers=_HEADERS, timeout=10,
|
|
186
|
+
)
|
|
187
|
+
if r.status_code == 200:
|
|
188
|
+
m = r.json()["message"]
|
|
189
|
+
authors = []
|
|
190
|
+
for a in m.get("author", []):
|
|
191
|
+
name = " ".join(p for p in [a.get("given", ""), a.get("family", "")] if p).strip()
|
|
192
|
+
if name:
|
|
193
|
+
authors.append(name)
|
|
194
|
+
year = (
|
|
195
|
+
m.get("published-print", {}).get("date-parts", [[None]])[0][0]
|
|
196
|
+
or m.get("published-online", {}).get("date-parts", [[None]])[0][0]
|
|
197
|
+
)
|
|
198
|
+
abstract = _strip_jats(m.get("abstract"))
|
|
199
|
+
_enrich({
|
|
200
|
+
"title": (m.get("title") or [None])[0],
|
|
201
|
+
"abstract": abstract,
|
|
202
|
+
"authors": "; ".join(authors) or None,
|
|
203
|
+
"journal": (m.get("container-title") or [None])[0],
|
|
204
|
+
"year": year,
|
|
205
|
+
})
|
|
206
|
+
if verbose and abstract:
|
|
207
|
+
print(" ✅ Abstract from Crossref")
|
|
208
|
+
except Exception as e:
|
|
209
|
+
if verbose: print(f" Crossref error: {e}")
|
|
210
|
+
time.sleep(delay)
|
|
211
|
+
if _done(): return result
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# 3. Semantic Scholar
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
try:
|
|
217
|
+
s2_headers = dict(_HEADERS)
|
|
218
|
+
if _S2_API_KEY:
|
|
219
|
+
s2_headers["x-api-key"] = _S2_API_KEY
|
|
220
|
+
r = requests.get(
|
|
221
|
+
f"https://api.semanticscholar.org/graph/v1/paper/DOI:{doi}"
|
|
222
|
+
"?fields=title,abstract,year,authors,venue",
|
|
223
|
+
headers=s2_headers, timeout=10,
|
|
224
|
+
)
|
|
225
|
+
if r.status_code == 200:
|
|
226
|
+
d = r.json()
|
|
227
|
+
_enrich({
|
|
228
|
+
"title": d.get("title"),
|
|
229
|
+
"abstract": d.get("abstract"),
|
|
230
|
+
"authors": "; ".join(a.get("name", "") for a in d.get("authors", [])) or None,
|
|
231
|
+
"journal": d.get("venue"),
|
|
232
|
+
"year": d.get("year"),
|
|
233
|
+
})
|
|
234
|
+
if verbose and d.get("abstract"):
|
|
235
|
+
print(" ✅ Abstract from Semantic Scholar")
|
|
236
|
+
except Exception as e:
|
|
237
|
+
if verbose: print(f" Semantic Scholar error: {e}")
|
|
238
|
+
time.sleep(delay)
|
|
239
|
+
if _done(): return result
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
# 4. OpenAlex
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
try:
|
|
245
|
+
oa_headers = dict(_HEADERS)
|
|
246
|
+
if _OPENALEX_API_KEY:
|
|
247
|
+
oa_headers["Authorization"] = f"Bearer {_OPENALEX_API_KEY}"
|
|
248
|
+
r = requests.get(
|
|
249
|
+
f"https://api.openalex.org/works/https://doi.org/{doi}",
|
|
250
|
+
headers=oa_headers, timeout=10,
|
|
251
|
+
)
|
|
252
|
+
if r.status_code == 200:
|
|
253
|
+
d = r.json()
|
|
254
|
+
abstract = _reconstruct_openalex_abstract(d.get("abstract_inverted_index"))
|
|
255
|
+
authors = "; ".join(
|
|
256
|
+
a["author"]["display_name"]
|
|
257
|
+
for a in d.get("authorships", [])
|
|
258
|
+
if a.get("author", {}).get("display_name")
|
|
259
|
+
) or None
|
|
260
|
+
_enrich({
|
|
261
|
+
"title": d.get("title"),
|
|
262
|
+
"abstract": abstract,
|
|
263
|
+
"authors": authors,
|
|
264
|
+
"journal": (d.get("primary_location") or {}).get("source", {}).get("display_name"),
|
|
265
|
+
"year": d.get("publication_year"),
|
|
266
|
+
})
|
|
267
|
+
if verbose and abstract:
|
|
268
|
+
print(" ✅ Abstract from OpenAlex")
|
|
269
|
+
except Exception as e:
|
|
270
|
+
if verbose: print(f" OpenAlex error: {e}")
|
|
271
|
+
time.sleep(delay)
|
|
272
|
+
if _done(): return result
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------------
|
|
275
|
+
# 5. arXiv API (only for 10.48550/arXiv.* DOIs)
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
arxiv_id = _arxiv_id_from_doi(doi)
|
|
278
|
+
if arxiv_id:
|
|
279
|
+
try:
|
|
280
|
+
r = requests.get(
|
|
281
|
+
f"http://export.arxiv.org/api/query?id_list={arxiv_id}",
|
|
282
|
+
headers=_HEADERS, timeout=10,
|
|
283
|
+
)
|
|
284
|
+
if r.status_code == 200:
|
|
285
|
+
xml = r.text
|
|
286
|
+
title_m = re.search(r'<title>(.+?)</title>', xml, re.DOTALL)
|
|
287
|
+
summary_m = re.search(r'<summary>(.+?)</summary>', xml, re.DOTALL)
|
|
288
|
+
authors_m = re.findall(r'<name>(.+?)</name>', xml)
|
|
289
|
+
if title_m and summary_m:
|
|
290
|
+
_enrich({
|
|
291
|
+
"title": re.sub(r'\s+', ' ', title_m.group(1)).strip(),
|
|
292
|
+
"abstract": re.sub(r'\s+', ' ', summary_m.group(1)).strip(),
|
|
293
|
+
"authors": "; ".join(authors_m) or None,
|
|
294
|
+
"journal": "arXiv",
|
|
295
|
+
})
|
|
296
|
+
if verbose:
|
|
297
|
+
print(f" ✅ Abstract from arXiv ({arxiv_id})")
|
|
298
|
+
except Exception as e:
|
|
299
|
+
if verbose: print(f" arXiv error: {e}")
|
|
300
|
+
time.sleep(delay)
|
|
301
|
+
if _done(): return result
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
# 6. DataCite (good for preprints and datasets)
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
try:
|
|
307
|
+
r = requests.get(
|
|
308
|
+
f"https://api.datacite.org/dois/{quote_plus(doi)}",
|
|
309
|
+
headers=_HEADERS, timeout=10,
|
|
310
|
+
)
|
|
311
|
+
if r.status_code == 200:
|
|
312
|
+
attrs = r.json().get("data", {}).get("attributes", {})
|
|
313
|
+
# Abstract is in descriptions array with descriptionType "Abstract"
|
|
314
|
+
abstract = None
|
|
315
|
+
for desc in attrs.get("descriptions", []):
|
|
316
|
+
if (desc.get("descriptionType") or "").lower() == "abstract":
|
|
317
|
+
abstract = _strip_jats(desc.get("description"))
|
|
318
|
+
break
|
|
319
|
+
# Fallback: first description of any type
|
|
320
|
+
if not abstract and attrs.get("descriptions"):
|
|
321
|
+
abstract = _strip_jats(attrs["descriptions"][0].get("description"))
|
|
322
|
+
creators = attrs.get("creators", [])
|
|
323
|
+
authors = "; ".join(
|
|
324
|
+
c.get("name") or f"{c.get('givenName','')} {c.get('familyName','')}".strip()
|
|
325
|
+
for c in creators if c.get("name") or c.get("familyName")
|
|
326
|
+
) or None
|
|
327
|
+
_enrich({
|
|
328
|
+
"title": (attrs.get("titles") or [{}])[0].get("title"),
|
|
329
|
+
"abstract": abstract,
|
|
330
|
+
"authors": authors,
|
|
331
|
+
"journal": attrs.get("publisher"),
|
|
332
|
+
"year": attrs.get("publicationYear"),
|
|
333
|
+
})
|
|
334
|
+
if verbose and abstract:
|
|
335
|
+
print(" ✅ Abstract from DataCite")
|
|
336
|
+
except Exception as e:
|
|
337
|
+
if verbose: print(f" DataCite error: {e}")
|
|
338
|
+
time.sleep(delay)
|
|
339
|
+
if _done(): return result
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# 7. CORE (40M+ OA papers, requires COREAPIKEY)
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
if _CORE_API_KEY:
|
|
345
|
+
try:
|
|
346
|
+
r = requests.get(
|
|
347
|
+
f"https://api.core.ac.uk/v3/search/works"
|
|
348
|
+
f"?q=doi:{quote_plus(doi)}&limit=1",
|
|
349
|
+
headers={**_HEADERS, "Authorization": f"Bearer {_CORE_API_KEY}"},
|
|
350
|
+
timeout=10,
|
|
351
|
+
)
|
|
352
|
+
if r.status_code == 200:
|
|
353
|
+
hits = r.json().get("results", [])
|
|
354
|
+
if hits:
|
|
355
|
+
d = hits[0]
|
|
356
|
+
abstract = _strip_jats(d.get("abstract") or d.get("description"))
|
|
357
|
+
_enrich({
|
|
358
|
+
"title": d.get("title"),
|
|
359
|
+
"abstract": abstract,
|
|
360
|
+
"authors": "; ".join(
|
|
361
|
+
a.get("name", "") for a in d.get("authors", [])
|
|
362
|
+
) or None,
|
|
363
|
+
"journal": (d.get("journals") or [{}])[0].get("title") or d.get("publisher"),
|
|
364
|
+
"year": d.get("yearPublished"),
|
|
365
|
+
})
|
|
366
|
+
if verbose and abstract:
|
|
367
|
+
print(" ✅ Abstract from CORE")
|
|
368
|
+
except Exception as e:
|
|
369
|
+
if verbose: print(f" CORE error: {e}")
|
|
370
|
+
time.sleep(delay)
|
|
371
|
+
if _done(): return result
|
|
372
|
+
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
# 8. DOAJ (Directory of Open Access Journals, no key required)
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
try:
|
|
377
|
+
r = requests.get(
|
|
378
|
+
f"https://doaj.org/api/search/articles/doi:{quote_plus(doi)}",
|
|
379
|
+
headers=_HEADERS, timeout=10,
|
|
380
|
+
)
|
|
381
|
+
if r.status_code == 200:
|
|
382
|
+
hits = r.json().get("results", [])
|
|
383
|
+
if hits:
|
|
384
|
+
bib = hits[0].get("bibjson", {})
|
|
385
|
+
abstract = _strip_jats(bib.get("abstract"))
|
|
386
|
+
authors = "; ".join(
|
|
387
|
+
a.get("name", "") for a in bib.get("author", [])
|
|
388
|
+
) or None
|
|
389
|
+
journal_info = bib.get("journal", {})
|
|
390
|
+
_enrich({
|
|
391
|
+
"title": bib.get("title"),
|
|
392
|
+
"abstract": abstract,
|
|
393
|
+
"authors": authors,
|
|
394
|
+
"journal": journal_info.get("title"),
|
|
395
|
+
"year": bib.get("year"),
|
|
396
|
+
})
|
|
397
|
+
if verbose and abstract:
|
|
398
|
+
print(" ✅ Abstract from DOAJ")
|
|
399
|
+
except Exception as e:
|
|
400
|
+
if verbose: print(f" DOAJ error: {e}")
|
|
401
|
+
time.sleep(delay)
|
|
402
|
+
if _done(): return result
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
# 9. Scopus (Elsevier, requires SCOPUS_API_KEY)
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
if _SCOPUS_API_KEY:
|
|
408
|
+
try:
|
|
409
|
+
r = requests.get(
|
|
410
|
+
f"https://api.elsevier.com/content/abstract/doi/{doi}",
|
|
411
|
+
params={"apiKey": _SCOPUS_API_KEY, "httpAccept": "application/json"},
|
|
412
|
+
headers=_HEADERS, timeout=10,
|
|
413
|
+
)
|
|
414
|
+
if r.status_code == 200:
|
|
415
|
+
core = (
|
|
416
|
+
r.json()
|
|
417
|
+
.get("abstracts-retrieval-response", {})
|
|
418
|
+
.get("coredata", {})
|
|
419
|
+
)
|
|
420
|
+
abstract = _strip_jats(core.get("dc:description"))
|
|
421
|
+
# Authors: authorship list
|
|
422
|
+
auth_list = core.get("dc:creator") or []
|
|
423
|
+
if isinstance(auth_list, dict):
|
|
424
|
+
auth_list = [auth_list]
|
|
425
|
+
authors = "; ".join(
|
|
426
|
+
a.get("$", "") for a in auth_list if a.get("$")
|
|
427
|
+
) or None
|
|
428
|
+
_enrich({
|
|
429
|
+
"title": core.get("dc:title"),
|
|
430
|
+
"abstract": abstract,
|
|
431
|
+
"authors": authors,
|
|
432
|
+
"journal": core.get("prism:publicationName"),
|
|
433
|
+
"year": (core.get("prism:coverDate") or "")[:4] or None,
|
|
434
|
+
})
|
|
435
|
+
if verbose and abstract:
|
|
436
|
+
print(" ✅ Abstract from Scopus")
|
|
437
|
+
except Exception as e:
|
|
438
|
+
if verbose: print(f" Scopus error: {e}")
|
|
439
|
+
time.sleep(delay)
|
|
440
|
+
if _done(): return result
|
|
441
|
+
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
# 10. PubMed efetch (DOI → PMID → structured XML abstract)
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
try:
|
|
446
|
+
# Step 1: DOI → PMID via esearch
|
|
447
|
+
search_params = {"db": "pubmed", "term": f"{doi}[DOI]", "retmode": "json"}
|
|
448
|
+
if _NCBI_API_KEY:
|
|
449
|
+
search_params["api_key"] = _NCBI_API_KEY
|
|
450
|
+
if email:
|
|
451
|
+
search_params["email"] = email
|
|
452
|
+
r = requests.get(
|
|
453
|
+
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi",
|
|
454
|
+
params=search_params, headers=_HEADERS, timeout=10,
|
|
455
|
+
)
|
|
456
|
+
if r.status_code == 200:
|
|
457
|
+
ids = r.json().get("esearchresult", {}).get("idlist", [])
|
|
458
|
+
if ids:
|
|
459
|
+
pmid = ids[0]
|
|
460
|
+
# Step 2: PMID → structured XML via efetch
|
|
461
|
+
fetch_params = {
|
|
462
|
+
"db": "pubmed", "id": pmid,
|
|
463
|
+
"rettype": "xml", "retmode": "xml",
|
|
464
|
+
}
|
|
465
|
+
if _NCBI_API_KEY:
|
|
466
|
+
fetch_params["api_key"] = _NCBI_API_KEY
|
|
467
|
+
r2 = requests.get(
|
|
468
|
+
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi",
|
|
469
|
+
params=fetch_params, headers=_HEADERS, timeout=10,
|
|
470
|
+
)
|
|
471
|
+
if r2.status_code == 200:
|
|
472
|
+
xml = r2.text
|
|
473
|
+
# Extract abstract text (may have multiple AbstractText elements with labels)
|
|
474
|
+
parts = re.findall(
|
|
475
|
+
r'<AbstractText(?:[^>]*Label="([^"]*)")?[^>]*>([^<]+)</AbstractText>',
|
|
476
|
+
xml, re.DOTALL,
|
|
477
|
+
)
|
|
478
|
+
if parts:
|
|
479
|
+
sections = []
|
|
480
|
+
for label, text in parts:
|
|
481
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
482
|
+
if label:
|
|
483
|
+
sections.append(f"**{label}** {text}")
|
|
484
|
+
else:
|
|
485
|
+
sections.append(text)
|
|
486
|
+
abstract = " ".join(sections)
|
|
487
|
+
_enrich({"abstract": abstract or None})
|
|
488
|
+
if verbose and abstract:
|
|
489
|
+
print(f" ✅ Abstract from PubMed (PMID {pmid})")
|
|
490
|
+
except Exception as e:
|
|
491
|
+
if verbose: print(f" PubMed efetch error: {e}")
|
|
492
|
+
|
|
493
|
+
return result
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
# Formatting & saving
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
def format_abstract_markdown(meta, doi=None):
|
|
501
|
+
"""Format a metadata dict as a Markdown string."""
|
|
502
|
+
lines = []
|
|
503
|
+
|
|
504
|
+
title = meta.get("title") or "Untitled"
|
|
505
|
+
lines.append(f"# {title}\n")
|
|
506
|
+
|
|
507
|
+
info = []
|
|
508
|
+
if meta.get("authors"):
|
|
509
|
+
info.append(f"**Authors:** {meta['authors']}")
|
|
510
|
+
if meta.get("journal"):
|
|
511
|
+
info.append(f"**Journal:** {meta['journal']}")
|
|
512
|
+
if meta.get("year"):
|
|
513
|
+
info.append(f"**Year:** {meta['year']}")
|
|
514
|
+
doi_val = doi or meta.get("doi")
|
|
515
|
+
if doi_val:
|
|
516
|
+
info.append(f"**DOI:** [{doi_val}](https://doi.org/{doi_val})")
|
|
517
|
+
|
|
518
|
+
if info:
|
|
519
|
+
lines.append(" \n".join(info))
|
|
520
|
+
lines.append("")
|
|
521
|
+
|
|
522
|
+
abstract = meta.get("abstract")
|
|
523
|
+
if abstract:
|
|
524
|
+
lines.append("## Abstract\n")
|
|
525
|
+
lines.append(abstract)
|
|
526
|
+
else:
|
|
527
|
+
lines.append("*Abstract not available.*")
|
|
528
|
+
|
|
529
|
+
return "\n".join(lines) + "\n"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def save_abstract_markdown(doi, output_dir, email=None, verbose=False):
|
|
533
|
+
"""
|
|
534
|
+
Fetch abstract for a DOI and save as {safe_doi}_abstract.md in output_dir.
|
|
535
|
+
Returns the path if saved, else None.
|
|
536
|
+
"""
|
|
537
|
+
from .fetchpdf import doi_to_safe_filename
|
|
538
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
539
|
+
safe = doi_to_safe_filename(doi)
|
|
540
|
+
path = os.path.join(output_dir, f"{safe}_abstract.md")
|
|
541
|
+
|
|
542
|
+
meta = fetch_abstract_from_doi(doi, email=email, verbose=verbose)
|
|
543
|
+
if not meta.get("title") and not meta.get("abstract"):
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
md = format_abstract_markdown(meta, doi=doi)
|
|
547
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
548
|
+
f.write(md)
|
|
549
|
+
return path
|