bibcite-cli 0.5.0__tar.gz → 0.5.2__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.
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/PKG-INFO +11 -1
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/Readme.md +10 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/pyproject.toml +1 -1
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/__init__.py +1 -1
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/sources.py +87 -34
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/uv.lock +1 -1
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/.gitignore +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/LICENSE +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/bibfile.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/cache.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/cli.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/data/strings.bib +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/normalize.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/resolve.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/src/bibcite/venues.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_bibfile.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_bugfixes.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_entry_types.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_normalize.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_round2.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_round3.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_status_semantics.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_strings_override.py +0 -0
- {bibcite_cli-0.5.0 → bibcite_cli-0.5.2}/tests/test_venues.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bibcite-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Resolve papers (arXiv id / DOI / title) to canonical, normalized BibTeX for agents and humans
|
|
5
5
|
Project-URL: Repository, https://github.com/leo1oel/bibcite
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,6 +44,16 @@ npm install -g bibtex-tidy
|
|
|
44
44
|
|
|
45
45
|
To use your own venue table instead of the vendored one, set `BIBCITE_STRINGS=/path/to/strings.bib` or place it at `~/.config/bibcite/strings.bib`.
|
|
46
46
|
|
|
47
|
+
Environment variables that make the sources faster/more reliable:
|
|
48
|
+
|
|
49
|
+
| Variable | Effect |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `OPENALEX_API_KEY` | OpenAlex refuses anonymous search with 503 under load; a free key makes it dependable |
|
|
52
|
+
| `S2_API_KEY` | Semantic Scholar private quota (~1 req/s) instead of the shared global pool |
|
|
53
|
+
| `BIBCITE_MAILTO` | Your contact email for the CrossRef/OpenAlex/Unpaywall polite pools |
|
|
54
|
+
| `BIBCITE_CORE_SOURCES` | Override which sources count as "core" for `published_check` verdicts (default `dblp,semanticscholar,crossref,openalex`) |
|
|
55
|
+
| `BIBCITE_NO_CACHE=1` | Disable the local match cache |
|
|
56
|
+
|
|
47
57
|
## Usage
|
|
48
58
|
|
|
49
59
|
```bash
|
|
@@ -31,6 +31,16 @@ npm install -g bibtex-tidy
|
|
|
31
31
|
|
|
32
32
|
To use your own venue table instead of the vendored one, set `BIBCITE_STRINGS=/path/to/strings.bib` or place it at `~/.config/bibcite/strings.bib`.
|
|
33
33
|
|
|
34
|
+
Environment variables that make the sources faster/more reliable:
|
|
35
|
+
|
|
36
|
+
| Variable | Effect |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `OPENALEX_API_KEY` | OpenAlex refuses anonymous search with 503 under load; a free key makes it dependable |
|
|
39
|
+
| `S2_API_KEY` | Semantic Scholar private quota (~1 req/s) instead of the shared global pool |
|
|
40
|
+
| `BIBCITE_MAILTO` | Your contact email for the CrossRef/OpenAlex/Unpaywall polite pools |
|
|
41
|
+
| `BIBCITE_CORE_SOURCES` | Override which sources count as "core" for `published_check` verdicts (default `dblp,semanticscholar,crossref,openalex`) |
|
|
42
|
+
| `BIBCITE_NO_CACHE=1` | Disable the local match cache |
|
|
43
|
+
|
|
34
44
|
## Usage
|
|
35
45
|
|
|
36
46
|
```bash
|
|
@@ -18,7 +18,7 @@ import httpx
|
|
|
18
18
|
|
|
19
19
|
from .normalize import clean_title, mini_hash, norm_title, sig_tokens, titles_similar
|
|
20
20
|
|
|
21
|
-
UA = "bibcite/0.
|
|
21
|
+
UA = "bibcite/0.5 (https://github.com/leo1oel/bibcite)"
|
|
22
22
|
BROWSER_UA = (
|
|
23
23
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
24
24
|
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
@@ -53,6 +53,22 @@ def _s2_headers() -> dict:
|
|
|
53
53
|
return {"x-api-key": key} if key else {}
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _mailto() -> str:
|
|
57
|
+
"""Contact email for the polite pools (CrossRef/OpenAlex/Unpaywall).
|
|
58
|
+
Set BIBCITE_MAILTO to use your own."""
|
|
59
|
+
return os.environ.get("BIBCITE_MAILTO") or "bibcite@gmail.com"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _openalex_params(extra: dict) -> dict:
|
|
63
|
+
"""OpenAlex rejects ANONYMOUS search with 503 under heavy load ("use a
|
|
64
|
+
free API key for uninterrupted access"); OPENALEX_API_KEY unlocks it."""
|
|
65
|
+
params = {**extra, "mailto": _mailto()}
|
|
66
|
+
key = os.environ.get("OPENALEX_API_KEY")
|
|
67
|
+
if key:
|
|
68
|
+
params["api_key"] = key
|
|
69
|
+
return params
|
|
70
|
+
|
|
71
|
+
|
|
56
72
|
@dataclass
|
|
57
73
|
class Match:
|
|
58
74
|
source: str
|
|
@@ -147,40 +163,50 @@ def arxiv_metadata(arxiv_id: str) -> ArxivMeta:
|
|
|
147
163
|
# DBLP
|
|
148
164
|
# ---------------------------------------------------------------------------
|
|
149
165
|
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
# Client-side pacing + backoff-retry, shared by the throttle-prone sources.
|
|
167
|
+
# Pacing prevents the 429 in the first place; on a 429 we back off (honoring
|
|
168
|
+
# Retry-After) instead of instantly poisoning the rest of a batch run — only
|
|
169
|
+
# repeated failure raises SourceUnavailable (which disables the source).
|
|
170
|
+
_LAST_REQUEST: dict[str, float] = {}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _paced_get(
|
|
174
|
+
c: httpx.Client,
|
|
175
|
+
url: str,
|
|
176
|
+
source: str,
|
|
177
|
+
min_interval: float,
|
|
178
|
+
params: dict | None = None,
|
|
179
|
+
headers: dict | None = None,
|
|
180
|
+
) -> httpx.Response:
|
|
160
181
|
for attempt in range(3):
|
|
161
|
-
wait =
|
|
182
|
+
wait = min_interval - (time.monotonic() - _LAST_REQUEST.get(source, 0.0))
|
|
162
183
|
if wait > 0:
|
|
163
184
|
time.sleep(wait)
|
|
164
|
-
|
|
185
|
+
_LAST_REQUEST[source] = time.monotonic()
|
|
165
186
|
try:
|
|
166
|
-
r = c.get(url, params=params)
|
|
187
|
+
r = c.get(url, params=params, headers=headers)
|
|
167
188
|
except httpx.HTTPError as e: # TCP reset = temporary ban; retrying fast makes it worse
|
|
168
189
|
if attempt < 2:
|
|
169
190
|
time.sleep(5 * (attempt + 1))
|
|
170
191
|
continue
|
|
171
|
-
raise SourceUnavailable(f"
|
|
192
|
+
raise SourceUnavailable(f"{source} unreachable ({type(e).__name__})")
|
|
172
193
|
if r.status_code == 429:
|
|
173
194
|
retry_after = int(r.headers.get("Retry-After") or 0)
|
|
174
195
|
if retry_after > 30:
|
|
175
|
-
raise SourceUnavailable(f"
|
|
196
|
+
raise SourceUnavailable(f"{source} rate-limited (Retry-After {retry_after}s)")
|
|
176
197
|
if attempt < 2:
|
|
177
198
|
delay = max(retry_after, 4 * (attempt + 1))
|
|
178
|
-
_log(f"[
|
|
199
|
+
_log(f"[{source}] 429 — backing off {delay}s")
|
|
179
200
|
time.sleep(delay)
|
|
180
201
|
continue
|
|
181
|
-
raise SourceUnavailable("
|
|
202
|
+
raise SourceUnavailable(f"{source} rate-limited (429) after backoff retries")
|
|
182
203
|
return r
|
|
183
|
-
raise SourceUnavailable("
|
|
204
|
+
raise SourceUnavailable(f"{source} unavailable")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# DBLP throttles at roughly 1-2 req/s and escalates to temporary IP bans.
|
|
208
|
+
def _dblp_get(c: httpx.Client, url: str, params: dict | None = None) -> httpx.Response:
|
|
209
|
+
return _paced_get(c, url, "dblp", 0.8, params=params)
|
|
184
210
|
|
|
185
211
|
|
|
186
212
|
def try_dblp(title: str, author_hint: str = "") -> Match | None:
|
|
@@ -369,14 +395,22 @@ def arxiv_abs_metadata(arxiv_id: str) -> ArxivMeta | None:
|
|
|
369
395
|
)
|
|
370
396
|
|
|
371
397
|
|
|
398
|
+
def _s2_get(c: httpx.Client, url: str, params: dict) -> httpx.Response:
|
|
399
|
+
# With an API key S2 allows ~1 req/s on a private quota; unauthenticated
|
|
400
|
+
# requests share a global pool where backoff still beats instant defeat.
|
|
401
|
+
return _paced_get(
|
|
402
|
+
c, url, "semanticscholar", 1.0, params=params, headers=_s2_headers()
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
372
406
|
def s2_arxiv_metadata(arxiv_id: str) -> ArxivMeta | None:
|
|
373
407
|
"""Metadata (title/authors/year) for an arXiv id via Semantic Scholar —
|
|
374
408
|
the fallback when export.arxiv.org itself is throttled."""
|
|
375
409
|
with _client() as c:
|
|
376
|
-
r =
|
|
410
|
+
r = _s2_get(
|
|
411
|
+
c,
|
|
377
412
|
f"https://api.semanticscholar.org/graph/v1/paper/arXiv:{arxiv_id}",
|
|
378
413
|
params={"fields": "title,year,authors"},
|
|
379
|
-
headers=_s2_headers(),
|
|
380
414
|
)
|
|
381
415
|
if r.status_code != 200:
|
|
382
416
|
return None
|
|
@@ -398,24 +432,20 @@ def try_semantic_scholar(
|
|
|
398
432
|
with _client() as c:
|
|
399
433
|
# Direct id lookup first: unambiguous, no title-search needed.
|
|
400
434
|
if arxiv_id:
|
|
401
|
-
r =
|
|
435
|
+
r = _s2_get(
|
|
436
|
+
c,
|
|
402
437
|
f"https://api.semanticscholar.org/graph/v1/paper/arXiv:{arxiv_id}",
|
|
403
438
|
params={"fields": S2_FIELDS},
|
|
404
|
-
headers=_s2_headers(),
|
|
405
439
|
)
|
|
406
|
-
if r.status_code == 429:
|
|
407
|
-
raise SourceUnavailable("Semantic Scholar rate-limited (429)")
|
|
408
440
|
if r.status_code == 200:
|
|
409
441
|
m = _s2_to_match(r.json(), title, year)
|
|
410
442
|
if m:
|
|
411
443
|
return m
|
|
412
|
-
r =
|
|
444
|
+
r = _s2_get(
|
|
445
|
+
c,
|
|
413
446
|
"https://api.semanticscholar.org/graph/v1/paper/search",
|
|
414
447
|
params={"query": title, "fields": S2_FIELDS, "limit": 5},
|
|
415
|
-
headers=_s2_headers(),
|
|
416
448
|
)
|
|
417
|
-
if r.status_code == 429:
|
|
418
|
-
raise SourceUnavailable("Semantic Scholar rate-limited (429)")
|
|
419
449
|
r.raise_for_status()
|
|
420
450
|
for item in r.json().get("data") or []:
|
|
421
451
|
m = _s2_to_match(item, title, year)
|
|
@@ -491,11 +521,16 @@ def try_crossref(title: str) -> Match | None:
|
|
|
491
521
|
"rows": 3,
|
|
492
522
|
"query.title": title,
|
|
493
523
|
"select": "title,event,container-title,DOI,issued",
|
|
494
|
-
"mailto":
|
|
524
|
+
"mailto": _mailto(),
|
|
495
525
|
},
|
|
496
526
|
)
|
|
497
527
|
if r.status_code == 429:
|
|
498
528
|
raise SourceUnavailable("CrossRef rate-limited (429)")
|
|
529
|
+
if r.status_code >= 500:
|
|
530
|
+
# A dead endpoint (Unpaywall search 500s for days at a time)
|
|
531
|
+
# gets benched for the run instead of adding latency + noise
|
|
532
|
+
# to every remaining query.
|
|
533
|
+
raise SourceUnavailable(f"CrossRef server error ({r.status_code})")
|
|
499
534
|
r.raise_for_status()
|
|
500
535
|
payload = r.json()
|
|
501
536
|
if payload.get("status") != "ok":
|
|
@@ -544,10 +579,15 @@ def try_unpaywall(title: str) -> Match | None:
|
|
|
544
579
|
with _client() as c:
|
|
545
580
|
r = c.get(
|
|
546
581
|
"https://api.unpaywall.org/v2/search",
|
|
547
|
-
params={"query": title, "is_oa": "true", "email":
|
|
582
|
+
params={"query": title, "is_oa": "true", "email": _mailto()},
|
|
548
583
|
)
|
|
549
584
|
if r.status_code == 429:
|
|
550
585
|
raise SourceUnavailable("Unpaywall rate-limited (429)")
|
|
586
|
+
if r.status_code >= 500:
|
|
587
|
+
# A dead endpoint (Unpaywall search 500s for days at a time)
|
|
588
|
+
# gets benched for the run instead of adding latency + noise
|
|
589
|
+
# to every remaining query.
|
|
590
|
+
raise SourceUnavailable(f"Unpaywall server error ({r.status_code})")
|
|
551
591
|
r.raise_for_status()
|
|
552
592
|
ref = norm_title(title)
|
|
553
593
|
for res in r.json().get("results") or []:
|
|
@@ -586,10 +626,15 @@ def openalex_search(title: str) -> dict | None:
|
|
|
586
626
|
with _client() as c:
|
|
587
627
|
r = c.get(
|
|
588
628
|
"https://api.openalex.org/works",
|
|
589
|
-
params={"search": title, "per-page": 5
|
|
629
|
+
params=_openalex_params({"search": title, "per-page": 5}),
|
|
590
630
|
)
|
|
591
631
|
if r.status_code == 429:
|
|
592
632
|
raise SourceUnavailable("OpenAlex rate-limited (429)")
|
|
633
|
+
if r.status_code >= 500:
|
|
634
|
+
# A dead endpoint (Unpaywall search 500s for days at a time)
|
|
635
|
+
# gets benched for the run instead of adding latency + noise
|
|
636
|
+
# to every remaining query.
|
|
637
|
+
raise SourceUnavailable(f"OpenAlex server error ({r.status_code})")
|
|
593
638
|
r.raise_for_status()
|
|
594
639
|
ref = norm_title(title)
|
|
595
640
|
for w in r.json().get("results") or []:
|
|
@@ -651,7 +696,7 @@ def try_openalex(title: str) -> Match | None:
|
|
|
651
696
|
|
|
652
697
|
def crossref_by_doi(doi: str) -> Match | None:
|
|
653
698
|
with _client() as c:
|
|
654
|
-
r = c.get(f"https://api.crossref.org/works/{doi}", params={"mailto":
|
|
699
|
+
r = c.get(f"https://api.crossref.org/works/{doi}", params={"mailto": _mailto()})
|
|
655
700
|
if r.status_code != 200:
|
|
656
701
|
return None
|
|
657
702
|
data = r.json().get("message", {})
|
|
@@ -702,7 +747,15 @@ _DISABLED: dict[str, str] = {}
|
|
|
702
747
|
# Only these sources are authoritative enough that losing one taints a miss
|
|
703
748
|
# into "incomplete". Google Scholar captchas and Unpaywall flakiness are
|
|
704
749
|
# routine and must not stop "not_found" from ever being trustworthy.
|
|
705
|
-
|
|
750
|
+
# Override with BIBCITE_CORE_SOURCES="dblp,semanticscholar" if one of these
|
|
751
|
+
# is down for days and keeps every verdict incomplete.
|
|
752
|
+
CORE_SOURCES = frozenset(
|
|
753
|
+
s.strip()
|
|
754
|
+
for s in (
|
|
755
|
+
os.environ.get("BIBCITE_CORE_SOURCES") or "dblp,semanticscholar,crossref,openalex"
|
|
756
|
+
).split(",")
|
|
757
|
+
if s.strip()
|
|
758
|
+
)
|
|
706
759
|
|
|
707
760
|
|
|
708
761
|
def find_published(
|
|
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
|