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