papers-cli 2.15.3__tar.gz → 2.15.5__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.
- {papers_cli-2.15.3 → papers_cli-2.15.5}/PKG-INFO +1 -1
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/_version.py +3 -3
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/extract.py +163 -28
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/PKG-INFO +1 -1
- {papers_cli-2.15.3 → papers_cli-2.15.5}/.gitattributes +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/.github/FUNDING.yml +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/.github/workflows/pypi.yml +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/.github/workflows/tests.yml +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/.gitignore +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/LICENSE +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/README.md +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/__init__.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/__main__.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/bib.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/config.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/duplicate.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/encoding.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/filename.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/latexenc.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/utils.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/SOURCES.txt +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/dependency_links.txt +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/entry_points.txt +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/requires.txt +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/top_level.txt +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/pyproject.toml +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/requirements.txt +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/setup.cfg +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/setup.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/__init__.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/common.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/config.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/download.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/downloadedpapers/.gitignore +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/downloadedpapers/bg-8-515-2011.txt +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_add.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_biblio.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_duplicates.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_encoding.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_extract.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_filecheck.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_install.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_list.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_misc.py +0 -0
- {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_undo.py +0 -0
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '2.15.
|
|
32
|
-
__version_tuple__ = version_tuple = (2, 15,
|
|
31
|
+
__version__ = version = '2.15.5'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 15, 5)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'gbad573f0d'
|
|
@@ -110,49 +110,178 @@ def readpdf_image(pdf, first=None, last=None):
|
|
|
110
110
|
|
|
111
111
|
return txt
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
# CrossRef DOI standard: /^10.\d{4,9}/[-._;()/:A-Z0-9]+$/i
|
|
114
|
+
REGEXP = re.compile(r'[doi,doi.org/][\s\.\:]{0,2}(10\.\d{4,9}/[-._;()/:a-z0-9]+)')
|
|
114
115
|
ARXIV = re.compile(r'arxiv:\s*(\d{4}\.\d{4,5})')
|
|
115
116
|
|
|
116
|
-
def
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
def _parse_doi_from_metadata_string(metadata):
|
|
118
|
+
"""Extract DOI from XMP metadata string."""
|
|
119
|
+
patterns = [
|
|
120
|
+
r'<prism:doi>(10\.\d{4,9}/[-._;()/:a-z0-9]+)</prism:doi>',
|
|
121
|
+
r'<dc:identifier>doi:(10\.\d{4,9}/[-._;()/:a-z0-9]+)</dc:identifier>',
|
|
122
|
+
r'<pdfx:doi>(10\.\d{4,9}/[-._;()/:a-z0-9]+)</pdfx:doi>',
|
|
123
|
+
r'<crossmark:DOI>(10\.\d{4,9}/[-._;()/:a-z0-9]+)</crossmark:DOI>',
|
|
124
|
+
]
|
|
125
|
+
for pattern in patterns:
|
|
126
|
+
match = re.search(pattern, metadata, re.IGNORECASE)
|
|
127
|
+
if match:
|
|
128
|
+
return match.group(1)
|
|
129
|
+
return None
|
|
119
130
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
def parse_doi_from_pdf_metadata_poppler(pdf_path):
|
|
132
|
+
"""Extract DOI from PDF metadata using pdfinfo (poppler-utils)."""
|
|
133
|
+
try:
|
|
134
|
+
result = sp.run(['pdfinfo', '-meta', pdf_path],
|
|
135
|
+
capture_output=True, text=True, timeout=5)
|
|
136
|
+
if result.returncode == 0:
|
|
137
|
+
return _parse_doi_from_metadata_string(result.stdout)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return None
|
|
127
141
|
|
|
128
|
-
|
|
142
|
+
def parse_doi_from_pdf_metadata_fitz(pdf_path):
|
|
143
|
+
"""Extract DOI from PDF metadata using PyMuPDF/fitz."""
|
|
144
|
+
import fitz
|
|
129
145
|
|
|
130
|
-
|
|
146
|
+
with fitz.open(pdf_path) as doc:
|
|
147
|
+
metadata = doc.metadata
|
|
131
148
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
149
|
+
# Try direct metadata fields first
|
|
150
|
+
if metadata:
|
|
151
|
+
# Check common metadata fields
|
|
152
|
+
for key in ['subject', 'keywords', 'title']:
|
|
153
|
+
value = metadata.get(key, '')
|
|
154
|
+
if value and '10.' in value:
|
|
155
|
+
# Try to extract DOI from the field
|
|
156
|
+
doi_match = re.search(r'10\.\d{4,9}/[-._;()/:a-z0-9]+', value, re.IGNORECASE)
|
|
157
|
+
if doi_match:
|
|
158
|
+
return doi_match.group(0)
|
|
137
159
|
|
|
138
|
-
|
|
139
|
-
|
|
160
|
+
# Fall back to XMP metadata
|
|
161
|
+
xmp = doc.get_xml_metadata() if hasattr(doc, 'get_xml_metadata') else doc.xref_get_key(-1, "Metadata")
|
|
162
|
+
if xmp:
|
|
163
|
+
xmp_str = xmp if isinstance(xmp, str) else xmp.decode('utf-8', errors='ignore')
|
|
164
|
+
return _parse_doi_from_metadata_string(xmp_str)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_doi_from_pdf_metadata(pdf_path):
|
|
168
|
+
"""Extract DOI from PDF metadata (tries fitz, falls back to poppler)."""
|
|
169
|
+
# Try fitz first (no subprocess overhead)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
return parse_doi_from_pdf_metadata_fitz(pdf_path)
|
|
173
|
+
except ImportError:
|
|
174
|
+
# Fall back to poppler-utils
|
|
175
|
+
return parse_doi_from_pdf_metadata_poppler(pdf_path)
|
|
176
|
+
|
|
177
|
+
def parse_doi(txt):
|
|
178
|
+
# Remove invisible Unicode characters and normalize dashes and slashes
|
|
179
|
+
# U+200B: zero-width space, U+00AD: soft hyphen, U+2013: en-dash, U+2014: em-dash
|
|
180
|
+
# \x02: STX (Start of Text) sometimes used instead of slash in PDFs
|
|
181
|
+
txt_clean = txt.replace('\u200b', '').replace('\xad', '').replace('\u2013', '-').replace('\u2014', '-').replace('\x02', '/')
|
|
182
|
+
txt_lower = txt_clean.lower()
|
|
140
183
|
|
|
141
|
-
|
|
184
|
+
# Handle DOI split at "10.\n<registrant>" (e.g., "10.\n1073/pnas.123")
|
|
185
|
+
# Requires 4-9 digits (valid registrant) followed by slash
|
|
186
|
+
txt_lower = re.sub(r'(10\.)\s*\n\s*(\d{4,9}/)', r'\1\2', txt_lower)
|
|
142
187
|
|
|
143
|
-
#
|
|
144
|
-
|
|
188
|
+
# Special case: PNAS DOIs may be split at "10.1073/pnas. \n<digits>"
|
|
189
|
+
# This is a very specific pattern that's safe to join
|
|
190
|
+
txt_lower = re.sub(r'(10\.1073/pnas\.)\s*\n\s*(\d)', r'\1\2', txt_lower)
|
|
145
191
|
|
|
146
|
-
|
|
147
|
-
|
|
192
|
+
# Handle DOIs split at dash-newline-alphanumeric (common in many publishers)
|
|
193
|
+
# e.g., "10.1175/jcli-d-21-\n0636.1", "10.1175/JCLI-\nD-17-0112.s1"
|
|
194
|
+
txt_lower = re.sub(r'(10\.\d{4,9}/[-._;()/:a-z0-9]+-)\s*\n\s*([a-z0-9])', r'\1\2', txt_lower)
|
|
148
195
|
|
|
149
|
-
#
|
|
196
|
+
# Don't join across newlines generally - it causes more problems than it solves
|
|
197
|
+
# The newline naturally stops incorrect matches from extending into following text
|
|
198
|
+
matches = REGEXP.findall(txt_lower)
|
|
199
|
+
|
|
200
|
+
if not matches:
|
|
201
|
+
# Try arxiv pattern
|
|
202
|
+
match = ARXIV.search(txt_lower)
|
|
203
|
+
if match:
|
|
204
|
+
return f"10.48550/arXiv.{match.group(1)}"
|
|
205
|
+
|
|
206
|
+
raise DOIParsingError('parse_doi::no matches')
|
|
207
|
+
|
|
208
|
+
# Start with first match as default
|
|
209
|
+
doi = matches[0]
|
|
210
|
+
|
|
211
|
+
# Prefer DOIs that appear in full URL context (e.g., www.pnas.org/cgi/doi/10.1073/...)
|
|
212
|
+
# These are more likely to be the article's own DOI rather than citations
|
|
213
|
+
if len(matches) > 1:
|
|
214
|
+
url_patterns = [
|
|
215
|
+
r'www\.[a-z]+\.org/[a-z/]+/(10\.\d{4,9}/[-._;()/:a-z0-9]+)',
|
|
216
|
+
r'doi\.org/(10\.\d{4,9}/[-._;()/:a-z0-9]+)',
|
|
217
|
+
r'dx\.doi\.org/(10\.\d{4,9}/[-._;()/:a-z0-9]+)',
|
|
218
|
+
]
|
|
219
|
+
for pattern in url_patterns:
|
|
220
|
+
url_matches = re.findall(pattern, txt_lower)
|
|
221
|
+
# If any of our DOI matches appear in a URL, prefer the first one
|
|
222
|
+
for doi_match in matches:
|
|
223
|
+
if doi_match in url_matches:
|
|
224
|
+
doi = doi_match
|
|
225
|
+
break
|
|
226
|
+
if doi in url_matches: # Found a URL match, stop searching patterns
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
# If multiple matches and first is supplemental, prefer non-supplemental with same base
|
|
230
|
+
# e.g., prefer "10.1175/jcli-d-16-0271.1" over "10.1175/jcli-d-16-0271.s1"
|
|
231
|
+
if len(matches) > 1 and doi == matches[0]:
|
|
232
|
+
# Check if first match is supplemental (.s<digit>)
|
|
233
|
+
if re.search(r'\.s\d+\.?$', matches[0]):
|
|
234
|
+
# Look for non-supplemental version with same base
|
|
235
|
+
base = re.sub(r'\.s\d+\.?$', '', matches[0])
|
|
236
|
+
for m in matches[1:]:
|
|
237
|
+
if m.startswith(base) and not re.search(r'\.s\d+\.?$', m):
|
|
238
|
+
doi = m
|
|
239
|
+
break
|
|
240
|
+
|
|
241
|
+
# Remove non-DOI suffixes: known publisher paths and common junk
|
|
242
|
+
# Strategy: if we find these patterns, remove them AND everything after
|
|
243
|
+
suffixes = [
|
|
244
|
+
# Publisher-specific paths
|
|
245
|
+
'/-/dcsupplemental',
|
|
246
|
+
'/-/dc',
|
|
247
|
+
# Common file extension
|
|
248
|
+
'.pdf',
|
|
249
|
+
# Document status markers that clearly shouldn't be in DOI
|
|
250
|
+
'preprint',
|
|
251
|
+
'received',
|
|
252
|
+
'published',
|
|
253
|
+
'edited',
|
|
254
|
+
'advance',
|
|
255
|
+
'full',
|
|
256
|
+
'abstract',
|
|
257
|
+
'-supplement',
|
|
258
|
+
'supplement',
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
doi_lower = doi.lower()
|
|
262
|
+
for suffix in suffixes:
|
|
263
|
+
pos = doi_lower.find(suffix)
|
|
264
|
+
if pos > 0: # Found suffix (not at start)
|
|
265
|
+
# For word-only suffixes, ensure they follow a valid DOI character
|
|
266
|
+
if suffix.isalpha():
|
|
267
|
+
prev_char = doi[pos-1]
|
|
268
|
+
# Accept if preceded by alphanumeric, dash, underscore, slash, or dot
|
|
269
|
+
if prev_char.isalnum() or prev_char in '.-_/':
|
|
270
|
+
doi = doi[:pos]
|
|
271
|
+
break
|
|
272
|
+
else:
|
|
273
|
+
# Non-word suffixes (paths, extensions) - just cut
|
|
274
|
+
doi = doi[:pos]
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
# Clean up trailing periods and colons
|
|
278
|
+
doi = doi.rstrip('.:')
|
|
279
|
+
|
|
280
|
+
# Quality check
|
|
150
281
|
if len(doi) <= 8:
|
|
151
282
|
raise DOIParsingError('failed to extract doi: '+doi)
|
|
152
283
|
|
|
153
284
|
return doi
|
|
154
|
-
|
|
155
|
-
|
|
156
285
|
def isvaliddoi(doi):
|
|
157
286
|
try:
|
|
158
287
|
doi2 = parse_doi('doi:'+doi)
|
|
@@ -178,6 +307,12 @@ def pdfhead(pdf, maxpages=10, minwords=200, image=False):
|
|
|
178
307
|
|
|
179
308
|
|
|
180
309
|
def extract_pdf_doi(pdf, image=False):
|
|
310
|
+
# Try PDF metadata first (fast and reliable for many publishers)
|
|
311
|
+
metadata_doi = parse_doi_from_pdf_metadata(pdf)
|
|
312
|
+
if metadata_doi:
|
|
313
|
+
return metadata_doi
|
|
314
|
+
|
|
315
|
+
# Fall back to text extraction if metadata doesn't have DOI
|
|
181
316
|
return parse_doi(pdfhead(pdf, image=image))
|
|
182
317
|
|
|
183
318
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|