bibdeskparser 0.2.1__tar.gz → 0.4.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.
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/PKG-INFO +44 -32
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/README.md +41 -30
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/pyproject.toml +5 -4
- bibdeskparser-0.4.0/src/bibdeskparser/abstracts.py +325 -0
- bibdeskparser-0.4.0/src/bibdeskparser/abstracttext.py +443 -0
- bibdeskparser-0.4.0/src/bibdeskparser/checks.py +149 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/cli.py +885 -131
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/config.py +266 -2
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/editing.py +29 -38
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/entry.py +274 -1
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/entrytypes.py +18 -2
- bibdeskparser-0.4.0/src/bibdeskparser/exporting.py +691 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/fetch.py +30 -14
- bibdeskparser-0.4.0/src/bibdeskparser/identifiers.py +202 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/importing.py +130 -35
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/library.py +508 -77
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/macros.py +47 -17
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/middleware.py +54 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/names.py +8 -1
- bibdeskparser-0.4.0/src/bibdeskparser/preprints.py +372 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/render.py +279 -61
- bibdeskparser-0.2.1/src/bibdeskparser/exporting.py +0 -331
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/__init__.py +0 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/bdskfile.py +0 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/groups.py +0 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/header.py +0 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/search.py +0 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/specifiers.py +0 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/texmap.py +0 -0
- {bibdeskparser-0.2.1 → bibdeskparser-0.4.0}/src/bibdeskparser/writer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: bibdeskparser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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`,
|
|
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
|
|
123
|
-
>>>
|
|
124
|
-
>>>
|
|
125
|
-
|
|
126
|
-
>>>
|
|
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
|
-
|
|
144
|
-
>>> print(entry.author[0].last[0])
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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`,
|
|
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
|
|
95
|
-
>>>
|
|
96
|
-
>>>
|
|
97
|
-
|
|
98
|
-
>>>
|
|
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
|
-
|
|
116
|
-
>>> print(entry.author[0].last[0])
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
7
|
+
version = "0.4.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)
|