bibdeskparser 0.2.1__tar.gz → 0.3.0__tar.gz

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 (28) hide show
  1. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/PKG-INFO +44 -32
  2. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/README.md +41 -30
  3. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/pyproject.toml +5 -4
  4. bibdeskparser-0.3.0/src/bibdeskparser/abstracts.py +325 -0
  5. bibdeskparser-0.3.0/src/bibdeskparser/abstracttext.py +443 -0
  6. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/cli.py +398 -77
  7. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/config.py +195 -2
  8. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/editing.py +18 -10
  9. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/entry.py +260 -1
  10. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/fetch.py +28 -13
  11. bibdeskparser-0.3.0/src/bibdeskparser/identifiers.py +51 -0
  12. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/importing.py +4 -1
  13. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/library.py +361 -55
  14. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/macros.py +31 -1
  15. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/middleware.py +54 -0
  16. bibdeskparser-0.3.0/src/bibdeskparser/preprints.py +364 -0
  17. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/__init__.py +0 -0
  18. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/bdskfile.py +0 -0
  19. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/entrytypes.py +0 -0
  20. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/exporting.py +0 -0
  21. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/groups.py +0 -0
  22. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/header.py +0 -0
  23. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/names.py +0 -0
  24. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/render.py +0 -0
  25. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/search.py +0 -0
  26. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/specifiers.py +0 -0
  27. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/texmap.py +0 -0
  28. {bibdeskparser-0.2.1 → bibdeskparser-0.3.0}/src/bibdeskparser/writer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bibdeskparser
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Parser for Bibdesk database files
5
5
  Author: Michael Goerz
6
6
  Author-email: Michael Goerz <mail@michaelgoerz.net>
@@ -19,8 +19,9 @@ Requires-Dist: bibtexparser==2.0.0b9
19
19
  Requires-Dist: click>=8
20
20
  Requires-Dist: habanero>=2.2
21
21
  Requires-Dist: httpx>=0.27.2
22
+ Requires-Dist: pylatexenc>=2.10
22
23
  Requires-Dist: tomli>=1.1.0 ; python_full_version < '3.11'
23
- Requires-Dist: pyobjc-framework-cocoa ; sys_platform == 'darwin' and extra == 'macos'
24
+ Requires-Dist: pyobjc-framework-cocoa>=8 ; sys_platform == 'darwin' and extra == 'macos'
24
25
  Requires-Python: >=3.10
25
26
  Project-URL: Homepage, https://github.com/goerz/bibdeskparser
26
27
  Provides-Extra: macos
@@ -105,7 +106,8 @@ uv tool install bibdeskparser
105
106
  ```
106
107
 
107
108
  > **Tip:** If you use the command-line tool frequently, consider creating a
108
- > shorter symlink for `bibdeskparser`, e.g. `bib`, so that you can write
109
+ > shorter symlink for `bibdeskparser`, e.g. `bib`, and set up a config file,
110
+ > with a `default_bib_file` so that you can write
109
111
  > `bib import 10.22331/q-2022-12-07-871`.
110
112
 
111
113
 
@@ -118,41 +120,51 @@ pip install git+https://github.com/goerz/bibdeskparser.git@master#egg=bibdeskpar
118
120
 
119
121
  ## Usage
120
122
 
123
+ `Library` loads an existing `.bib` file and behaves like a dict of
124
+ citation key to `Entry`. The examples below use the example database
125
+ shipped in this repository at `tests/Refs/refs.bib`; substitute the
126
+ path to your own library.
127
+
121
128
  ```python
122
- >>> import tempfile
123
- >>> from pathlib import Path
124
- >>> from bibdeskparser import Entry, Library
125
- >>> tmpdir = tempfile.TemporaryDirectory()
126
- >>> bib_path = Path(tmpdir.name) / "references.bib"
127
- >>> bib = Library()
128
- >>> bib["Smith2020"] = Entry(
129
- ... "article",
130
- ... "Smith2020",
131
- ... fields={
132
- ... "title": "A Title",
133
- ... "author": "Smith, John and Doe, Jane",
134
- ... "journal": "J. Test",
135
- ... "year": "2020",
136
- ... },
137
- ... )
138
- >>> bib.save(bib_path)
139
-
140
- >>> bib = Library(bib_path)
141
- >>> entry = bib["Smith2020"]
129
+ >>> from bibdeskparser import Library
130
+ >>> bib = Library("tests/Refs/refs.bib")
131
+ >>> len(bib)
132
+ 61
133
+ >>> entry = bib["GoerzQ2022"]
142
134
  >>> print(entry["title"])
143
- A Title
144
- >>> print(entry.author[0].last[0]) # 'Smith'
145
- Smith
146
-
147
- >>> entry["title"] = "A Better Title"
148
- >>> bib.groups["Favorites"] = ("Smith2020",) # BibDesk static group
149
- >>> entry.groups
150
- ('Favorites',)
135
+ Quantum Optimal Control via Semi-Automatic Differentiation
136
+ >>> print(entry.author[0].last[0])
137
+ Goerz
138
+ >>> entry.files # linked PDF attachment, relative to the .bib file
139
+ ['GoerzQ2022.pdf']
140
+
141
+ ```
142
+
143
+ Full-text search and rendering a formatted citation:
144
+
145
+ ```python
146
+ >>> [e.key for e in bib.search("tractor atom interferometry")]
147
+ ['RaithelQST2022']
148
+ >>> print(bib.render("RaithelQST2022"))
149
+ G. Raithel, A. Duspayev, B. Dash, *et al.* *Principles of tractor atom interferometry*. [Quantum Sci. Technol. **8**, p. 014001](https://doi.org/10.1088/2058-9565/ac9429) (2022), [arXiv:2207.09023](https://arxiv.org/abs/2207.09023).
150
+
151
+ ```
152
+
153
+ Any change is written back with `save()`, preserving BibDesk's file
154
+ format byte-for-byte for everything that was not touched:
155
+
156
+ ```python
157
+ >>> entry["note"] = "Implemented in the QuantumControl.jl framework."
158
+ >>> bib.groups["To Read"] = ("BrifNJP2010", "KochEPJQT2022")
159
+ >>> bib["KochEPJQT2022"].groups
160
+ ('To Read',)
151
161
  >>> bib.save()
152
- >>> tmpdir.cleanup()
153
162
 
154
163
  ```
155
164
 
165
+ A new `.bib` file can also be created from scratch, with `Library()`
166
+ in Python or `bibdeskparser create` on the command line.
167
+
156
168
 
157
169
  ## Development
158
170
 
@@ -77,7 +77,8 @@ uv tool install bibdeskparser
77
77
  ```
78
78
 
79
79
  > **Tip:** If you use the command-line tool frequently, consider creating a
80
- > shorter symlink for `bibdeskparser`, e.g. `bib`, so that you can write
80
+ > shorter symlink for `bibdeskparser`, e.g. `bib`, and set up a config file,
81
+ > with a `default_bib_file` so that you can write
81
82
  > `bib import 10.22331/q-2022-12-07-871`.
82
83
 
83
84
 
@@ -90,41 +91,51 @@ pip install git+https://github.com/goerz/bibdeskparser.git@master#egg=bibdeskpar
90
91
 
91
92
  ## Usage
92
93
 
94
+ `Library` loads an existing `.bib` file and behaves like a dict of
95
+ citation key to `Entry`. The examples below use the example database
96
+ shipped in this repository at `tests/Refs/refs.bib`; substitute the
97
+ path to your own library.
98
+
93
99
  ```python
94
- >>> import tempfile
95
- >>> from pathlib import Path
96
- >>> from bibdeskparser import Entry, Library
97
- >>> tmpdir = tempfile.TemporaryDirectory()
98
- >>> bib_path = Path(tmpdir.name) / "references.bib"
99
- >>> bib = Library()
100
- >>> bib["Smith2020"] = Entry(
101
- ... "article",
102
- ... "Smith2020",
103
- ... fields={
104
- ... "title": "A Title",
105
- ... "author": "Smith, John and Doe, Jane",
106
- ... "journal": "J. Test",
107
- ... "year": "2020",
108
- ... },
109
- ... )
110
- >>> bib.save(bib_path)
111
-
112
- >>> bib = Library(bib_path)
113
- >>> entry = bib["Smith2020"]
100
+ >>> from bibdeskparser import Library
101
+ >>> bib = Library("tests/Refs/refs.bib")
102
+ >>> len(bib)
103
+ 61
104
+ >>> entry = bib["GoerzQ2022"]
114
105
  >>> print(entry["title"])
115
- A Title
116
- >>> print(entry.author[0].last[0]) # 'Smith'
117
- Smith
118
-
119
- >>> entry["title"] = "A Better Title"
120
- >>> bib.groups["Favorites"] = ("Smith2020",) # BibDesk static group
121
- >>> entry.groups
122
- ('Favorites',)
106
+ Quantum Optimal Control via Semi-Automatic Differentiation
107
+ >>> print(entry.author[0].last[0])
108
+ Goerz
109
+ >>> entry.files # linked PDF attachment, relative to the .bib file
110
+ ['GoerzQ2022.pdf']
111
+
112
+ ```
113
+
114
+ Full-text search and rendering a formatted citation:
115
+
116
+ ```python
117
+ >>> [e.key for e in bib.search("tractor atom interferometry")]
118
+ ['RaithelQST2022']
119
+ >>> print(bib.render("RaithelQST2022"))
120
+ G. Raithel, A. Duspayev, B. Dash, *et al.* *Principles of tractor atom interferometry*. [Quantum Sci. Technol. **8**, p. 014001](https://doi.org/10.1088/2058-9565/ac9429) (2022), [arXiv:2207.09023](https://arxiv.org/abs/2207.09023).
121
+
122
+ ```
123
+
124
+ Any change is written back with `save()`, preserving BibDesk's file
125
+ format byte-for-byte for everything that was not touched:
126
+
127
+ ```python
128
+ >>> entry["note"] = "Implemented in the QuantumControl.jl framework."
129
+ >>> bib.groups["To Read"] = ("BrifNJP2010", "KochEPJQT2022")
130
+ >>> bib["KochEPJQT2022"].groups
131
+ ('To Read',)
123
132
  >>> bib.save()
124
- >>> tmpdir.cleanup()
125
133
 
126
134
  ```
127
135
 
136
+ A new `.bib` file can also be created from scratch, with `Library()`
137
+ in Python or `bibdeskparser create` on the command line.
138
+
128
139
 
129
140
  ## Development
130
141
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "bibdeskparser"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Parser for Bibdesk database files"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -29,6 +29,7 @@ dependencies = [
29
29
  "click>=8",
30
30
  "habanero>=2.2",
31
31
  "httpx>=0.27.2",
32
+ "pylatexenc>=2.10",
32
33
  "tomli>=1.1.0; python_version < '3.11'",
33
34
  ]
34
35
 
@@ -36,14 +37,14 @@ dependencies = [
36
37
  bibdeskparser = "bibdeskparser.cli:main"
37
38
 
38
39
  [project.optional-dependencies]
39
- macos = ["pyobjc-framework-Cocoa; sys_platform == 'darwin'"]
40
+ macos = ["pyobjc-framework-Cocoa>=8; sys_platform == 'darwin'"]
40
41
 
41
42
  [project.urls]
42
43
  Homepage = "https://github.com/goerz/bibdeskparser"
43
44
 
44
45
  [dependency-groups]
45
46
  dev = [
46
- "pytest>=7",
47
+ "pytest>=7.2",
47
48
  "pytest-cov>=4",
48
49
  "pytest-xdist>=3",
49
50
  "packaging>=21",
@@ -74,7 +75,7 @@ release = [
74
75
  "click>=8",
75
76
  "gitpython>=3",
76
77
  "packaging>=21",
77
- "pytest>=7",
78
+ "pytest>=7.2",
78
79
  ]
79
80
 
80
81
  [tool.black]
@@ -0,0 +1,325 @@
1
+ """Retrieving abstracts from online sources and PDF attachments.
2
+
3
+ Provides {func}`fetch_abstract`, the free function backing
4
+ {meth}`bibdeskparser.Library.add_abstract`: gather candidate abstracts
5
+ for an entry from Crossref (by DOI), from the text of the entry's PDF
6
+ attachment, from the arXiv API (by eprint), and from Semantic Scholar
7
+ as a last resort; clean and validate each candidate (via
8
+ {mod}`bibdeskparser.abstracttext`); and combine the candidates into a
9
+ single result with an explicit confidence level.
10
+
11
+ This module is imported lazily (by `Library.add_abstract`): it pulls in
12
+ the network client libraries and `bibdeskparser.abstracttext` (and
13
+ through it `pylatexenc`), which nothing else in the package needs.
14
+ """
15
+
16
+ import re
17
+ import subprocess
18
+ import time
19
+ import urllib.parse
20
+ from collections import namedtuple
21
+
22
+ import arxiv
23
+ import httpx
24
+ from habanero import Crossref
25
+
26
+ from .abstracttext import _clean_text, _jats_to_text, _overlap, _validate
27
+ from .identifiers import _arxiv_id, _normalize_doi
28
+
29
+ __all__ = []
30
+
31
+ # All members whose name does not start with an underscore must be listed
32
+ # either in __all__ or in __private__
33
+ __private__ = [
34
+ "AbstractResult",
35
+ "CONFIDENCE_LEVELS",
36
+ "fetch_abstract",
37
+ ]
38
+
39
+
40
+ #: The result of `fetch_abstract` (and of `Library.add_abstract`,
41
+ #: which sets `applied`); see the `Library.add_abstract` docstring for
42
+ #: the meaning of each field.
43
+ AbstractResult = namedtuple(
44
+ "AbstractResult", ["abstract", "source", "confidence", "note", "applied"]
45
+ )
46
+
47
+ #: Confidence levels, in increasing order of trust.
48
+ CONFIDENCE_LEVELS = ("none", "low", "medium", "high")
49
+
50
+
51
+ # --------------------------------------------------------------------- #
52
+ # Candidate cleaning (delegated to `abstracttext`)
53
+ # --------------------------------------------------------------------- #
54
+
55
+
56
+ def _valid_or_none(text, label, note):
57
+ """`_clean_text(text)` if it validates, else `None` (appending
58
+ `label`-invalid:reason to the `note` list)."""
59
+ text = _clean_text(text)
60
+ if not text:
61
+ return None
62
+ ok, reason = _validate(text)
63
+ if not ok:
64
+ note.append(f"{label}-invalid:{reason}")
65
+ return None
66
+ return text
67
+
68
+
69
+ # --------------------------------------------------------------------- #
70
+ # PDF extraction
71
+ # --------------------------------------------------------------------- #
72
+
73
+
74
+ def _pdftotext(pdf_path, pages=4):
75
+ """The text of the first `pages` pages of `pdf_path` via the
76
+ `pdftotext` command-line tool; `(text, None)`, or `(None, reason)`
77
+ if the tool is missing or fails."""
78
+ cmd = ["pdftotext", "-q", "-f", "1", "-l", str(pages), str(pdf_path), "-"]
79
+ try:
80
+ out = subprocess.run(cmd, capture_output=True, timeout=60, check=False)
81
+ except FileNotFoundError:
82
+ return None, "pdftotext-missing"
83
+ # pylint: disable-next=broad-except
84
+ except Exception:
85
+ return None, "pdftotext-failed"
86
+ if out.returncode != 0:
87
+ return None, "pdftotext-failed"
88
+ return out.stdout.decode("utf-8", "replace"), None
89
+
90
+
91
+ # section headers that mark the END of an abstract
92
+ _END = re.compile(
93
+ r"(?im)^\s*("
94
+ r"DOI:|PACS|Keywords|Key words|Subject Areas|"
95
+ r"I\.\s+INTRODUCTION|1\.\s+Introduction|1\s+Introduction|"
96
+ r"INTRODUCTION\b|Contents\b|CONTENTS\b|"
97
+ r"\(Some figures may appear|Published by|©|"
98
+ r"This is an open access|Received\b)"
99
+ )
100
+
101
+ _RECVD_LINE = re.compile(
102
+ r"(?im)^.*?\b(Received|Dated|published|Published)\b.*\)\s*$"
103
+ )
104
+
105
+ _ABS_HEADER = re.compile(
106
+ r"(?im)^\s*(a\s?b\s?s\s?t\s?r\s?a\s?c\s?t|A\s?B\s?S\s?T\s?R\s?A\s?C\s?T)"
107
+ r"\.?\s*[:\-—]?\s*$"
108
+ )
109
+
110
+ _PARA_END = re.compile(r"\n[ \t]*\n") # blank line = paragraph break
111
+
112
+
113
+ def _abstract_until(rest):
114
+ """Trim `rest` to the abstract: up to the earliest of a blank
115
+ line, a PACS/DOI line, or a section header (`_END`)."""
116
+ ends = []
117
+ end_match = _END.search(rest)
118
+ if end_match:
119
+ ends.append(end_match.start())
120
+ # skip a leading blank line (between header and body), then find
121
+ # the first real paragraph break
122
+ leading = re.match(r"\s*", rest)
123
+ para = _PARA_END.search(rest, leading.end())
124
+ if para:
125
+ ends.append(para.start())
126
+ cut = min(ends) if ends else min(len(rest), 3500)
127
+ return rest[:cut].strip()
128
+
129
+
130
+ def _extract_pdf_abstract(text):
131
+ """The abstract paragraph of the PDF `text`, as
132
+ `(abstract_or_None, note)`; publisher-agnostic heuristics."""
133
+ if not text:
134
+ return None, "no-pdf-text"
135
+ # pdftotext sometimes renders the parens around "(Published ...)"
136
+ # as CJK brackets; normalize those (and sibling glyphs) first
137
+ text = text.translate(str.maketrans("共兲⬍⬎", "()<>"))
138
+
139
+ # APS / arXiv: the paragraph after a "(Received .../ Dated .../
140
+ # published ...)" line. Try each such line in reading order; the
141
+ # first that yields a valid paragraph wins (long review articles
142
+ # repeat "published" deep in the body).
143
+ for match in _RECVD_LINE.finditer(text):
144
+ block = _abstract_until(text[match.end() :])
145
+ if 150 < len(block) < 4000:
146
+ return block, "pdf-received"
147
+
148
+ # IOP / Elsevier: an explicit (possibly spaced) "Abstract" header
149
+ for match in _ABS_HEADER.finditer(text):
150
+ block = _abstract_until(text[match.end() :])
151
+ if 150 < len(block) < 4000:
152
+ return block, "pdf-abstract-header"
153
+
154
+ # inline "Abstract. ..." (text on the same line)
155
+ match = re.search(r"(?im)^\s*(abstract|ABSTRACT)[.:\-—]\s+(?=\S)", text)
156
+ if match:
157
+ block = _abstract_until(text[match.end() :])
158
+ if 150 < len(block) < 4000:
159
+ return block, "pdf-abstract-inline"
160
+
161
+ return None, "pdf-no-delimiter"
162
+
163
+
164
+ # --------------------------------------------------------------------- #
165
+ # Online sources
166
+ # --------------------------------------------------------------------- #
167
+
168
+
169
+ def _crossref_abstract(doi):
170
+ """The raw (JATS) abstract deposited with Crossref for `doi`, or
171
+ `None` (no deposit, or any network/data error)."""
172
+ try:
173
+ response = Crossref().works(ids=doi)
174
+ if not isinstance(response, dict):
175
+ return None
176
+ message = response.get("message") or {}
177
+ return message.get("abstract")
178
+ # pylint: disable-next=broad-except
179
+ except Exception:
180
+ return None
181
+
182
+
183
+ def _arxiv_summary(arxiv_id):
184
+ """The abstract ("summary") of `arxiv_id` from the arXiv API, or
185
+ `None` (no such paper, or any network error). `arxiv.Client`
186
+ enforces the arXiv API's rate limits."""
187
+ try:
188
+ search = arxiv.Search(id_list=[arxiv_id])
189
+ results = list(arxiv.Client().results(search))
190
+ return results[0].summary if results else None
191
+ # pylint: disable-next=broad-except
192
+ except Exception:
193
+ return None
194
+
195
+
196
+ _SS_API = "https://api.semanticscholar.org/graph/v1/paper/"
197
+
198
+
199
+ def _semanticscholar_abstract(ident):
200
+ """The abstract for `ident` (`"DOI:..."` or `"arXiv:..."`) from
201
+ the Semantic Scholar Graph API, or `None`. The public API is
202
+ rate-limited (~1 request/second): retry twice with a backoff."""
203
+ url = _SS_API + urllib.parse.quote(ident, safe=":/") + "?fields=abstract"
204
+ for attempt in range(3):
205
+ try:
206
+ response = httpx.get(url, timeout=30)
207
+ if response.status_code == 404:
208
+ return None
209
+ response.raise_for_status()
210
+ return response.json().get("abstract")
211
+ # pylint: disable-next=broad-except
212
+ except Exception:
213
+ time.sleep(1 + 2 * attempt)
214
+ return None
215
+
216
+
217
+ # --------------------------------------------------------------------- #
218
+ # Arbitration
219
+ # --------------------------------------------------------------------- #
220
+
221
+ # Token overlap above which two sources describe the same text (they
222
+ # are never identical: unicode cleanup, de-hyphenation, and JATS
223
+ # conversion all differ between sources).
224
+ _AGREE = 0.5
225
+
226
+
227
+ def _result(abstract, source, confidence, note):
228
+ return AbstractResult(abstract, source, confidence, "; ".join(note), False)
229
+
230
+
231
+ def fetch_abstract(*, doi=None, eprint=None, key=None, pdf_path=None):
232
+ """Fetch the best-supported abstract for an entry; the free
233
+ function backing {meth}`bibdeskparser.Library.add_abstract` (see
234
+ there for the source-selection and confidence semantics).
235
+
236
+ All identifying data is passed explicitly: the entry's `doi` and
237
+ `eprint` field values (either may be `None`), its citation `key`
238
+ (used only to guess an arXiv identifier when `eprint` is absent),
239
+ and the resolved path of its PDF attachment (`None` if it has
240
+ none). Returns an `AbstractResult` with `applied=False`; network
241
+ problems never raise (an unreachable source is skipped, see the
242
+ result's `note`).
243
+ """
244
+ note = []
245
+ doi = _normalize_doi(doi)
246
+ arxiv_id, arxiv_certain = _arxiv_id(eprint, key)
247
+
248
+ # --- Crossref (publisher JATS deposit, by DOI) -------------------- #
249
+ cr_text = None
250
+ if doi:
251
+ raw = _crossref_abstract(doi)
252
+ if raw is None:
253
+ note.append("cr-miss")
254
+ else:
255
+ cr_text = _valid_or_none(_jats_to_text(raw), "cr", note)
256
+
257
+ # --- PDF ----------------------------------------------------------- #
258
+ pdf_text = None
259
+ pdf_high = False
260
+ if pdf_path is None:
261
+ note.append("no-pdf")
262
+ else:
263
+ text, error = _pdftotext(pdf_path)
264
+ if text is None:
265
+ note.append(error)
266
+ else:
267
+ block, pdf_note = _extract_pdf_abstract(text)
268
+ note.append(pdf_note)
269
+ pdf_text = _valid_or_none(block, "pdf", note)
270
+ pdf_high = pdf_note in ("pdf-received", "pdf-abstract-header")
271
+
272
+ # The DOI guarantees the Crossref abstract belongs to this exact
273
+ # paper; when it agrees with the PDF, prefer Crossref (cleaner
274
+ # unicode). When the two *disagree*, one of them grabbed the wrong
275
+ # text: keep the PDF (it is literally this file) but flag it.
276
+ if cr_text and pdf_text:
277
+ overlap = _overlap(pdf_text, cr_text)
278
+ note.append(f"ov={overlap:.2f}")
279
+ if overlap >= _AGREE:
280
+ return _result(cr_text, "crossref", "high", note)
281
+ note.append("cr-disagree")
282
+ return _result(pdf_text, "pdf", "low", note)
283
+ if cr_text:
284
+ return _result(cr_text, "crossref", "high", note)
285
+ # An unambiguous PDF extraction needs no online confirmation
286
+ # (fast path: skips the rate-limited arXiv/Semantic Scholar calls)
287
+ if pdf_text and pdf_high:
288
+ return _result(pdf_text, "pdf", "high", note)
289
+
290
+ # --- arXiv (by eprint, or an id guessed from the key) ------------- #
291
+ if arxiv_id:
292
+ ax_text = _valid_or_none(_arxiv_summary(arxiv_id), "arxiv", note)
293
+ confidence = "high" if arxiv_certain else "medium"
294
+ if ax_text and pdf_text:
295
+ overlap = _overlap(pdf_text, ax_text)
296
+ note.append(f"arxiv-ov={overlap:.2f}")
297
+ if overlap >= _AGREE:
298
+ return _result(ax_text, "arxiv", confidence, note)
299
+ note.append("arxiv-disagree")
300
+ return _result(pdf_text, "pdf", "low", note)
301
+ if ax_text:
302
+ return _result(ax_text, "arxiv", confidence, note)
303
+
304
+ # --- Semantic Scholar (clean fallback; only when needed) ---------- #
305
+ ident = certain = None
306
+ if doi:
307
+ ident, certain = f"DOI:{doi}", True
308
+ elif arxiv_id:
309
+ ident, certain = f"arXiv:{arxiv_id}", arxiv_certain
310
+ if ident:
311
+ ss_text = _valid_or_none(_semanticscholar_abstract(ident), "ss", note)
312
+ confidence = "high" if certain else "medium"
313
+ if ss_text and pdf_text:
314
+ overlap = _overlap(pdf_text, ss_text)
315
+ note.append(f"ss-ov={overlap:.2f}")
316
+ if overlap >= _AGREE:
317
+ return _result(ss_text, "semanticscholar", confidence, note)
318
+ note.append("ss-disagree")
319
+ return _result(pdf_text, "pdf", "low", note)
320
+ if ss_text:
321
+ return _result(ss_text, "semanticscholar", confidence, note)
322
+
323
+ if pdf_text:
324
+ return _result(pdf_text, "pdf", "medium", note)
325
+ return _result("", "none", "none", note)