bibcite-cli 0.6.1__tar.gz → 0.6.3__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 (31) hide show
  1. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/PKG-INFO +5 -4
  2. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/Readme.md +3 -2
  3. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/pyproject.toml +1 -1
  4. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/resolve.py +45 -0
  5. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/sources.py +241 -19
  6. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_source_retries.py +42 -2
  7. bibcite_cli-0.6.3/tests/test_webpages.py +69 -0
  8. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/uv.lock +1 -1
  9. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/.github/workflows/ci.yml +0 -0
  10. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/.github/workflows/publish.yml +0 -0
  11. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/.gitignore +0 -0
  12. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/LICENSE +0 -0
  13. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/assets/bibcite.svg +0 -0
  14. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/skills/bibcite/SKILL.md +0 -0
  15. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/__init__.py +0 -0
  16. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/bibfile.py +0 -0
  17. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/cache.py +0 -0
  18. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/cli.py +0 -0
  19. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/data/strings.bib +0 -0
  20. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/normalize.py +0 -0
  21. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/src/bibcite/venues.py +0 -0
  22. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_bibfile.py +0 -0
  23. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_bugfixes.py +0 -0
  24. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_cli_status.py +0 -0
  25. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_entry_types.py +0 -0
  26. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_normalize.py +0 -0
  27. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_round2.py +0 -0
  28. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_round3.py +0 -0
  29. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_status_semantics.py +0 -0
  30. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_strings_override.py +0 -0
  31. {bibcite_cli-0.6.1 → bibcite_cli-0.6.3}/tests/test_venues.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: bibcite-cli
3
- Version: 0.6.1
3
+ Version: 0.6.3
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
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
  <h1 align="center">bibcite</h1>
19
19
 
20
20
  <p align="center">
21
- Turn an arXiv ID, DOI, or paper title into clean BibTeX, then keep the whole bibliography normalized and deduplicated.
21
+ Turn an arXiv ID, DOI, paper title, or web page into clean BibTeX, then keep the whole bibliography normalized and deduplicated.
22
22
  </p>
23
23
 
24
24
  <p align="center">
@@ -105,7 +105,8 @@ uvx --from bibcite-cli bibcite get "Attention is all you need"
105
105
 
106
106
  ## What it handles
107
107
 
108
- - It accepts arXiv IDs and URLs, arXiv DOIs such as `10.48550/arXiv.1706.03762`, standard DOIs, and paper titles.
108
+ - It accepts arXiv IDs and URLs, arXiv DOIs such as `10.48550/arXiv.1706.03762`, standard DOIs, paper titles, and the URL of a web page.
109
+ - A web page is cited from what the page says about itself, because no index carries blog posts, documentation, or standards. The entry is `@misc` with `howpublished = {\url{...}}`, which every conference `.bst` prints; `@online` is biblatex-only and would be dropped. A page with no byline is attributed to its site, and a page with no date takes the year from its own URL when it has one there.
109
110
  - It searches for a published version before falling back to an arXiv preprint, and it reports when source outages make that check incomplete.
110
111
  - It canonicalizes journal, conference, and workshop names against the bundled venue table, including year-sensitive names such as NIPS and NeurIPS.
111
112
  - It assigns the correct BibTeX entry type and field, such as `@inproceedings` with `booktitle` or `@article` with `journal`.
@@ -5,7 +5,7 @@
5
5
  <h1 align="center">bibcite</h1>
6
6
 
7
7
  <p align="center">
8
- Turn an arXiv ID, DOI, or paper title into clean BibTeX, then keep the whole bibliography normalized and deduplicated.
8
+ Turn an arXiv ID, DOI, paper title, or web page into clean BibTeX, then keep the whole bibliography normalized and deduplicated.
9
9
  </p>
10
10
 
11
11
  <p align="center">
@@ -92,7 +92,8 @@ uvx --from bibcite-cli bibcite get "Attention is all you need"
92
92
 
93
93
  ## What it handles
94
94
 
95
- - It accepts arXiv IDs and URLs, arXiv DOIs such as `10.48550/arXiv.1706.03762`, standard DOIs, and paper titles.
95
+ - It accepts arXiv IDs and URLs, arXiv DOIs such as `10.48550/arXiv.1706.03762`, standard DOIs, paper titles, and the URL of a web page.
96
+ - A web page is cited from what the page says about itself, because no index carries blog posts, documentation, or standards. The entry is `@misc` with `howpublished = {\url{...}}`, which every conference `.bst` prints; `@online` is biblatex-only and would be dropped. A page with no byline is attributed to its site, and a page with no date takes the year from its own URL when it has one there.
96
97
  - It searches for a published version before falling back to an arXiv preprint, and it reports when source outages make that check incomplete.
97
98
  - It canonicalizes journal, conference, and workshop names against the bundled venue table, including year-sensitive names such as NIPS and NeurIPS.
98
99
  - It assigns the correct BibTeX entry type and field, such as `@inproceedings` with `booktitle` or `@article` with `journal`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bibcite-cli"
3
- version = "0.6.1"
3
+ version = "0.6.3"
4
4
  description = "Resolve papers (arXiv id / DOI / title) to canonical, normalized BibTeX for agents and humans"
5
5
  readme = "Readme.md"
6
6
  license = "MIT"
@@ -22,6 +22,9 @@ from .sources import (
22
22
  Match,
23
23
  arxiv_metadata,
24
24
  crossref_by_doi,
25
+ PageUnreadable,
26
+ WebPage,
27
+ fetch_web_page,
25
28
  find_published,
26
29
  )
27
30
  from .venues import canonicalize
@@ -43,6 +46,7 @@ ARXIV_URL = re.compile(
43
46
  re.I,
44
47
  )
45
48
  DOI_URL = re.compile(r"doi\.org/(10\.\S+)", re.I)
49
+ WEB_URL = re.compile(r"^https?://\S+$", re.I)
46
50
  DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$")
47
51
  ARXIV_DOI = re.compile(
48
52
  r"^10\.48550/arxiv\."
@@ -74,6 +78,12 @@ def classify(query: str) -> tuple[str, str]:
74
78
  return "arxiv", m.group(1)
75
79
  if DOI_RE.match(q):
76
80
  return "doi", q
81
+ # A URL that is not arXiv and not a DOI is a page: a blog post, a standard,
82
+ # a documentation page. Nothing indexes those, so searching for it as a
83
+ # title only ever returns "no match anywhere" — the page itself is the
84
+ # source.
85
+ if WEB_URL.match(q):
86
+ return "webpage", q
77
87
  return "title", query.strip()
78
88
 
79
89
 
@@ -198,6 +208,31 @@ def _arxiv_only_entry(meta: ArxivMeta) -> dict:
198
208
  }
199
209
 
200
210
 
211
+ def _web_entry(page: WebPage) -> dict:
212
+ """A page cited as @misc, the one type every conference .bst understands.
213
+
214
+ @online is biblatex-only; a NeurIPS or IEEE style would drop the entry
215
+ entirely. howpublished carries the link for the same reason the arXiv
216
+ preprint entry uses it: classic styles print it and ignore `url`. No access
217
+ date, because the tidy step omits `note` from every entry in the file.
218
+ """
219
+ entry = {
220
+ "ENTRYTYPE": "misc",
221
+ "title": page.title,
222
+ "howpublished": f"\\url{{{page.url}}}",
223
+ "url": page.url,
224
+ }
225
+ if page.authors:
226
+ entry["author"] = " and ".join(page.authors)
227
+ elif page.site:
228
+ # No byline: the site is the closest thing to a corporate author, and a
229
+ # key of `anonymousXXXX…` is worse than one naming where it came from.
230
+ entry["author"] = f"{{{page.site}}}"
231
+ if page.year:
232
+ entry["year"] = page.year
233
+ return entry
234
+
235
+
201
236
  def resolve(query: str, require_published: bool = False) -> Resolved:
202
237
  kind, value = classify(query)
203
238
  _log(f"[bibcite] query understood as {kind}: {value}")
@@ -248,6 +283,16 @@ def resolve(query: str, require_published: bool = False) -> Resolved:
248
283
  entry = _arxiv_only_entry(meta)
249
284
  return Resolved(_finalize(entry, meta), "arxiv", "", False, check)
250
285
 
286
+ if kind == "webpage":
287
+ try:
288
+ page = fetch_web_page(value)
289
+ except PageUnreadable as e:
290
+ raise NotFound(f"could not read {value}: {e}") from e
291
+ if not page.title:
292
+ raise NotFound(f"No title found on the page: {value}")
293
+ _log(f"[web] {page.title}" + (f" ({page.year})" if page.year else ""))
294
+ return Resolved(_finalize(_web_entry(page), None), "webpage", page.site, False)
295
+
251
296
  if kind == "doi":
252
297
  match = crossref_by_doi(value)
253
298
  if not match or not match.title:
@@ -10,6 +10,7 @@ import html
10
10
  import os
11
11
  import re
12
12
  import sys
13
+ import threading
13
14
  import time
14
15
  import xml.etree.ElementTree as ET
15
16
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -30,6 +31,10 @@ BROWSER_UA = (
30
31
  # never a false "not published". The arXiv metadata fetch sets its own longer
31
32
  # timeout on the request itself, so this does not affect it.
32
33
  TIMEOUT = 8.0
34
+ # Publication matching is enrichment on top of a valid arXiv citation. Keep
35
+ # the entire concurrent cascade, including its DBLP title-drift fallback,
36
+ # within an interactive budget instead of letting sequential retries add up.
37
+ PUBLICATION_TIMEOUT = 10.0
33
38
 
34
39
  PREPRINT_VENUES = re.compile(r"arxiv|corr|biorxiv|medrxiv|chemrxiv|ssrn|preprint", re.I)
35
40
  ARXIV_DOI = re.compile(r"^10\.48550/", re.I)
@@ -43,11 +48,67 @@ class SourceUnavailable(Exception):
43
48
  """Raised when a source rate-limits/blocks us; the cascade skips it."""
44
49
 
45
50
 
51
+ class PageUnreadable(Exception):
52
+ """A page refused to be read and will refuse again — a different
53
+ identifier, or a hand-written entry, is the next step rather than a
54
+ retry."""
55
+
56
+
46
57
  class TransientSourceError(SourceUnavailable):
47
58
  """Raised after request retries are exhausted without tripping the
48
59
  process-wide circuit breaker for later batch entries."""
49
60
 
50
61
 
62
+ class PublicationTimeout(TransientSourceError):
63
+ """The total publication-matching budget was exhausted."""
64
+
65
+
66
+ _REQUEST_DEADLINE = threading.local()
67
+
68
+
69
+ def _request_timeout(cap: float = TIMEOUT) -> float:
70
+ deadline = getattr(_REQUEST_DEADLINE, "value", None)
71
+ if deadline is None:
72
+ return cap
73
+ remaining = deadline - time.monotonic()
74
+ if remaining <= 0:
75
+ raise PublicationTimeout("publication lookup timed out")
76
+ return max(0.001, min(cap, remaining))
77
+
78
+
79
+ def _get(
80
+ c: httpx.Client, url: str, *, timeout: float = TIMEOUT, **kwargs
81
+ ) -> httpx.Response:
82
+ """GET with the per-request cap narrowed by the active total deadline."""
83
+ return c.get(url, timeout=_request_timeout(timeout), **kwargs)
84
+
85
+
86
+ def _sleep(delay: float):
87
+ """Sleep for pacing/backoff without crossing the publication deadline."""
88
+ deadline = getattr(_REQUEST_DEADLINE, "value", None)
89
+ if deadline is None:
90
+ time.sleep(delay)
91
+ return
92
+ remaining = deadline - time.monotonic()
93
+ if remaining <= 0:
94
+ raise PublicationTimeout("publication lookup timed out")
95
+ time.sleep(min(delay, remaining))
96
+ if delay >= remaining:
97
+ raise PublicationTimeout("publication lookup timed out")
98
+
99
+
100
+ def _with_deadline(deadline: float, fn, *args):
101
+ previous = getattr(_REQUEST_DEADLINE, "value", None)
102
+ _REQUEST_DEADLINE.value = deadline
103
+ try:
104
+ return fn(*args)
105
+ finally:
106
+ if previous is None:
107
+ del _REQUEST_DEADLINE.value
108
+ else:
109
+ _REQUEST_DEADLINE.value = previous
110
+
111
+
51
112
  def _client(browser: bool = False) -> httpx.Client:
52
113
  return httpx.Client(
53
114
  headers={"User-Agent": BROWSER_UA if browser else UA},
@@ -123,7 +184,8 @@ def arxiv_api_get(params: dict) -> httpx.Response:
123
184
  time.sleep(3 * attempt)
124
185
  try:
125
186
  with _client() as c:
126
- r = c.get(
187
+ r = _get(
188
+ c,
127
189
  "https://export.arxiv.org/api/query",
128
190
  params=params,
129
191
  timeout=30.0,
@@ -192,13 +254,13 @@ def _paced_get(
192
254
  for attempt in range(2):
193
255
  wait = min_interval - (time.monotonic() - _LAST_REQUEST.get(source, 0.0))
194
256
  if wait > 0:
195
- time.sleep(wait)
257
+ _sleep(wait)
196
258
  _LAST_REQUEST[source] = time.monotonic()
197
259
  try:
198
- r = c.get(url, params=params, headers=headers)
260
+ r = _get(c, url, params=params, headers=headers)
199
261
  except httpx.HTTPError as e: # Retry transport errors once before failing.
200
262
  if attempt < 1:
201
- time.sleep(1)
263
+ _sleep(1)
202
264
  continue
203
265
  raise TransientSourceError(
204
266
  f"{source} unreachable ({type(e).__name__})"
@@ -212,7 +274,7 @@ def _paced_get(
212
274
  # skip this source for the rest of the run.
213
275
  retry_after = int(r.headers.get("Retry-After") or 0)
214
276
  if attempt < 1 and retry_after <= 2:
215
- time.sleep(max(retry_after, 1))
277
+ _sleep(max(retry_after, 1))
216
278
  continue
217
279
  raise SourceUnavailable(f"{source} rate-limited (429)")
218
280
  return r
@@ -392,7 +454,7 @@ def arxiv_abs_metadata(arxiv_id: str) -> ArxivMeta | None:
392
454
  """Scrape the arxiv.org abs page's Highwire meta tags — the abs pages stay
393
455
  up when the export API throttles."""
394
456
  with _client(browser=True) as c:
395
- r = c.get(f"https://arxiv.org/abs/{arxiv_id}")
457
+ r = _get(c, f"https://arxiv.org/abs/{arxiv_id}")
396
458
  if r.status_code != 200:
397
459
  return None
398
460
  page = r.text
@@ -483,7 +545,8 @@ def try_semantic_scholar(
483
545
 
484
546
  def try_google_scholar(title: str) -> Match | None:
485
547
  with _client(browser=True) as c:
486
- r = c.get(
548
+ r = _get(
549
+ c,
487
550
  "https://scholar.google.com/scholar",
488
551
  params={"q": title, "hl": "en"},
489
552
  )
@@ -510,12 +573,12 @@ def try_google_scholar(title: str) -> Match | None:
510
573
  "https://scholar.google.com/scholar?q=info:"
511
574
  f"{data_id}:scholar.google.com/&output=cite&scirp=0&hl=en"
512
575
  )
513
- cite_html = c.get(cite_url).text
576
+ cite_html = _get(c, cite_url).text
514
577
  bm = re.search(r'<a[^>]*href="([^">]+)"[^>]*>BibTex</a>', cite_html, re.I)
515
578
  if not bm:
516
579
  return None
517
580
  bib_url = re.sub(r"\s+", "", bm.group(1).replace("&amp;", "&"))
518
- bibtex = c.get(bib_url).text
581
+ bibtex = _get(c, bib_url).text
519
582
  from .bibfile import parse_bibtex_entry # local import to avoid cycle
520
583
 
521
584
  entry = parse_bibtex_entry(bibtex)
@@ -538,7 +601,8 @@ def try_google_scholar(title: str) -> Match | None:
538
601
 
539
602
  def try_crossref(title: str) -> Match | None:
540
603
  with _client() as c:
541
- r = c.get(
604
+ r = _get(
605
+ c,
542
606
  "https://api.crossref.org/works",
543
607
  params={
544
608
  "rows": 3,
@@ -577,8 +641,9 @@ def try_crossref(title: str) -> Match | None:
577
641
  year = str(parts[0][0])
578
642
  bibtex = ""
579
643
  if doi:
580
- br = c.get(
581
- f"https://api.crossref.org/works/{doi}/transform/application/x-bibtex"
644
+ br = _get(
645
+ c,
646
+ f"https://api.crossref.org/works/{doi}/transform/application/x-bibtex",
582
647
  )
583
648
  if br.status_code == 200:
584
649
  bibtex = br.text
@@ -600,7 +665,8 @@ def try_crossref(title: str) -> Match | None:
600
665
 
601
666
  def try_unpaywall(title: str) -> Match | None:
602
667
  with _client() as c:
603
- r = c.get(
668
+ r = _get(
669
+ c,
604
670
  "https://api.unpaywall.org/v2/search",
605
671
  params={"query": title, "is_oa": "true", "email": _mailto()},
606
672
  )
@@ -647,7 +713,8 @@ def try_unpaywall(title: str) -> Match | None:
647
713
  def openalex_search(title: str) -> dict | None:
648
714
  """OpenAlex work with an exactly-matching normalized title, or None."""
649
715
  with _client() as c:
650
- r = c.get(
716
+ r = _get(
717
+ c,
651
718
  "https://api.openalex.org/works",
652
719
  params=_openalex_params({"search": title, "per-page": 5}),
653
720
  )
@@ -719,7 +786,9 @@ def try_openalex(title: str) -> Match | None:
719
786
 
720
787
  def crossref_by_doi(doi: str) -> Match | None:
721
788
  with _client() as c:
722
- r = c.get(f"https://api.crossref.org/works/{doi}", params={"mailto": _mailto()})
789
+ r = _get(
790
+ c, f"https://api.crossref.org/works/{doi}", params={"mailto": _mailto()}
791
+ )
723
792
  if r.status_code != 200:
724
793
  return None
725
794
  data = r.json().get("message", {})
@@ -731,7 +800,9 @@ def crossref_by_doi(doi: str) -> Match | None:
731
800
  if parts and parts[0]:
732
801
  year = str(parts[0][0])
733
802
  bibtex = ""
734
- br = c.get(f"https://api.crossref.org/works/{doi}/transform/application/x-bibtex")
803
+ br = _get(
804
+ c, f"https://api.crossref.org/works/{doi}/transform/application/x-bibtex"
805
+ )
735
806
  if br.status_code == 200:
736
807
  bibtex = br.text
737
808
  authors = [
@@ -809,12 +880,21 @@ def find_published(
809
880
  # version (the common case) misses everywhere, and used to pay the *sum* of
810
881
  # each source's latency; now the wall-clock is the slowest single source.
811
882
  # The first verified hit by CASCADE priority still wins.
883
+ deadline = time.monotonic() + PUBLICATION_TIMEOUT
812
884
  active = [(name, fn) for name, fn in CASCADE if name not in _DISABLED]
813
885
  outcomes: dict[str, tuple] = {}
814
886
  if active:
815
887
  with ThreadPoolExecutor(max_workers=len(active)) as pool:
816
888
  futures = {
817
- pool.submit(fn, title, year, arxiv_id, author_hint): name
889
+ pool.submit(
890
+ _with_deadline,
891
+ deadline,
892
+ fn,
893
+ title,
894
+ year,
895
+ arxiv_id,
896
+ author_hint,
897
+ ): name
818
898
  for name, fn in active
819
899
  }
820
900
  for future in as_completed(futures):
@@ -852,9 +932,18 @@ def find_published(
852
932
  # Exact-title search missed everywhere. Before concluding "no published
853
933
  # version", try the title-drift fallback — camera-ready titles frequently
854
934
  # differ from the arXiv ones, which is precisely the upgrade scenario.
855
- if author_hint and "dblp" not in _DISABLED:
935
+ # Only a clean exact DBLP miss justifies another query. A timeout or other
936
+ # failure has already spent its chance for this entry, and retrying the
937
+ # fuzzy form was doubling the worst-case interactive latency.
938
+ dblp_outcome = outcomes.get("dblp")
939
+ if (
940
+ author_hint
941
+ and dblp_outcome is not None
942
+ and dblp_outcome[0] == "miss"
943
+ and time.monotonic() < deadline
944
+ ):
856
945
  try:
857
- m = try_dblp_fuzzy(title, author_hint, year)
946
+ m = _with_deadline(deadline, try_dblp_fuzzy, title, author_hint, year)
858
947
  if m:
859
948
  cache.put(cache_key, m.__dict__)
860
949
  return m, "found"
@@ -871,3 +960,136 @@ def find_published(
871
960
  if not clean_misses:
872
961
  return None, "unavailable"
873
962
  return None, ("incomplete" if incomplete else "not_found")
963
+
964
+
965
+ @dataclass
966
+ class WebPage:
967
+ """What a web page can say about itself, for citing it as an @misc."""
968
+
969
+ url: str
970
+ title: str
971
+ authors: list[str] = field(default_factory=list)
972
+ year: str = ""
973
+ site: str = ""
974
+
975
+
976
+ def _meta_content(html_text: str, *keys: str) -> str:
977
+ """The content of the first <meta> whose name/property matches a key.
978
+
979
+ Attribute order varies between generators, so both orders are tried rather
980
+ than assuming content comes last.
981
+ """
982
+ for key in keys:
983
+ for pattern in (
984
+ rf'<meta[^>]+(?:name|property)=["\']{re.escape(key)}["\'][^>]*'
985
+ rf'content=["\'](.*?)["\']',
986
+ rf'<meta[^>]+content=["\'](.*?)["\'][^>]*'
987
+ rf'(?:name|property)=["\']{re.escape(key)}["\']',
988
+ ):
989
+ m = re.search(pattern, html_text, re.I | re.S)
990
+ if m and m.group(1).strip():
991
+ return html.unescape(m.group(1).strip())
992
+ return ""
993
+
994
+
995
+ def fetch_web_page(url: str) -> WebPage:
996
+ """Read a page's own description of itself.
997
+
998
+ Blogs, documentation and standards pages are cited constantly and are in
999
+ none of the academic indexes, so there is nothing to look them up in — the
1000
+ page itself is the only source. Highwire and Dublin Core tags come first
1001
+ because sites that carry them mean them; Open Graph and <title> are the
1002
+ fallback every site has.
1003
+ """
1004
+ try:
1005
+ with _client(browser=True) as client:
1006
+ response = _get(client, url)
1007
+ response.raise_for_status()
1008
+ body = response.text[:400_000]
1009
+ except httpx.HTTPStatusError as e:
1010
+ status = e.response.status_code
1011
+ # 403 and 404 are settled answers: the page will not become readable on
1012
+ # a retry, so say so rather than sending the caller back to wait.
1013
+ if status in (401, 403, 404, 410) or 400 <= status < 500:
1014
+ raise PageUnreadable(
1015
+ f"the page answered {status} — cite it with --bibtex, or use its DOI if it has one"
1016
+ ) from e
1017
+ raise SourceUnavailable(f"could not fetch {url}: {e}") from e
1018
+ except httpx.HTTPError as e:
1019
+ raise SourceUnavailable(f"could not fetch {url}: {e}") from e
1020
+
1021
+ title = (
1022
+ _meta_content(body, "citation_title", "DC.title", "og:title", "twitter:title")
1023
+ or _title_tag(body)
1024
+ )
1025
+ authors = [
1026
+ author
1027
+ for author in (
1028
+ _meta_content(body, "citation_author", "DC.creator", "author", "article:author"),
1029
+ )
1030
+ if author
1031
+ ]
1032
+ date = _meta_content(
1033
+ body,
1034
+ "citation_publication_date",
1035
+ "citation_date",
1036
+ "DC.date",
1037
+ "article:published_time",
1038
+ "og:updated_time",
1039
+ "date",
1040
+ )
1041
+ year = _year_in(date) or _year_in_path(url)
1042
+ site = _meta_content(body, "og:site_name") or _site_author(_host(url))
1043
+ return WebPage(url=str(response.url), title=title, authors=authors, year=year, site=site)
1044
+
1045
+
1046
+ def _year_in(text: str) -> str:
1047
+ m = re.search(r"(?:19|20)\d{2}", text)
1048
+ return m.group(0) if m else ""
1049
+
1050
+
1051
+ def _year_in_path(url: str) -> str:
1052
+ """The year a dateless page puts in its own URL.
1053
+
1054
+ Blog engines write `/2015/05/21/title`, and a citation key of
1055
+ `karpathyXXXXunreasonable` is worse than one carrying the year the post
1056
+ announces about itself. Only the path is read: a query string can hold any
1057
+ number at all.
1058
+ """
1059
+ path = re.sub(r"^https?://[^/]+", "", url).split("?")[0].split("#")[0]
1060
+ for segment in path.split("/"):
1061
+ if re.fullmatch(r"(?:19|20)\d{2}", segment):
1062
+ return segment
1063
+ return ""
1064
+
1065
+
1066
+ def _title_tag(html_text: str) -> str:
1067
+ m = re.search(r"<title[^>]*>(.*?)</title>", html_text, re.I | re.S)
1068
+ if not m:
1069
+ return ""
1070
+ # Strip the trailing " — Site Name" many templates append; the site name is
1071
+ # recorded separately, and repeating it in the title reads badly in a
1072
+ # bibliography.
1073
+ title = html.unescape(re.sub(r"\s+", " ", m.group(1))).strip()
1074
+ return re.sub(r"\s*[|·—–-]\s*[^|·—–-]{1,40}$", "", title).strip() or title
1075
+
1076
+
1077
+ def _host(url: str) -> str:
1078
+ m = re.match(r"https?://(?:www\.)?([^/:]+)", url, re.I)
1079
+ return m.group(1) if m else ""
1080
+
1081
+
1082
+ def _site_author(host: str) -> str:
1083
+ """A readable stand-in author for a page with no byline.
1084
+
1085
+ The bare host makes an unreadable key — `karpathygithubioXXXXunreasonable`
1086
+ — so the hosting suffix goes and the name that identifies the site stays:
1087
+ `karpathy.github.io` reads as Karpathy, `docs.python.org` as Python.
1088
+ """
1089
+ labels = [label for label in host.lower().split(".") if label]
1090
+ if not labels:
1091
+ return host
1092
+ generic = {"github", "io", "com", "org", "net", "edu", "gov", "ai", "dev",
1093
+ "co", "uk", "cn", "blog", "www", "docs", "pages", "medium"}
1094
+ named = [label for label in labels if label not in generic]
1095
+ return (named[0] if named else labels[0]).capitalize()
@@ -5,7 +5,12 @@ import bibcite.sources as sources
5
5
  from bibcite import cache
6
6
  from bibcite.bibfile import load_bib_file
7
7
  from bibcite.cli import _upgrade_entries
8
- from bibcite.sources import Match, SourceUnavailable, find_published
8
+ from bibcite.sources import (
9
+ Match,
10
+ SourceUnavailable,
11
+ TransientSourceError,
12
+ find_published,
13
+ )
9
14
 
10
15
 
11
16
  @pytest.fixture(autouse=True)
@@ -22,7 +27,7 @@ class _ReadErrorClient:
22
27
  self.failures = failures
23
28
  self.calls = 0
24
29
 
25
- def get(self, url, params=None, headers=None):
30
+ def get(self, url, params=None, headers=None, timeout=None):
26
31
  self.calls += 1
27
32
  request = httpx.Request("GET", url, params=params, headers=headers)
28
33
  if self.calls <= self.failures:
@@ -70,6 +75,41 @@ def test_dblp_read_failures_do_not_disable_later_batch_entries(monkeypatch):
70
75
  assert second_match.venue == "TMLR"
71
76
 
72
77
 
78
+ def test_dblp_transport_failure_skips_the_fuzzy_retry(monkeypatch):
79
+ fuzzy_calls = 0
80
+
81
+ def dblp(*args):
82
+ raise TransientSourceError("simulated timeout")
83
+
84
+ def fuzzy(*args):
85
+ nonlocal fuzzy_calls
86
+ fuzzy_calls += 1
87
+
88
+ monkeypatch.setattr(
89
+ sources,
90
+ "CASCADE",
91
+ (
92
+ ("dblp", dblp),
93
+ ("crossref", lambda *args: None),
94
+ ),
95
+ )
96
+ monkeypatch.setattr(sources, "try_dblp_fuzzy", fuzzy)
97
+
98
+ match, status = find_published("First paper", author_hint="doe")
99
+
100
+ assert (match, status) == (None, "incomplete")
101
+ assert fuzzy_calls == 0
102
+
103
+
104
+ def test_requests_use_only_the_remaining_publication_budget(monkeypatch):
105
+ monkeypatch.setattr(sources.time, "monotonic", lambda: 7.0)
106
+ sources._REQUEST_DEADLINE.value = 10.0
107
+ try:
108
+ assert sources._request_timeout(8.0) == 3.0
109
+ finally:
110
+ del sources._REQUEST_DEADLINE.value
111
+
112
+
73
113
  def test_upgrade_retries_dblp_after_previous_entry_read_failures(
74
114
  tmp_path, monkeypatch
75
115
  ):
@@ -0,0 +1,69 @@
1
+ """Citing a page that no index knows about.
2
+
3
+ Blogs, documentation and standards pages are cited constantly and appear in
4
+ none of the academic sources, so the page itself has to be the source.
5
+ """
6
+
7
+ from bibcite.resolve import _web_entry, classify
8
+ from bibcite.sources import WebPage, _meta_content, _site_author, _title_tag, _year_in_path
9
+
10
+
11
+ def test_a_plain_url_is_a_webpage_and_the_others_still_are_not():
12
+ assert classify("https://karpathy.github.io/2015/05/21/rnn-effectiveness/")[0] == "webpage"
13
+ assert classify("http://example.edu/notes")[0] == "webpage"
14
+ # The identifiers that resolve properly must keep their own paths.
15
+ assert classify("https://arxiv.org/abs/1706.03762") == ("arxiv", "1706.03762")
16
+ assert classify("https://doi.org/10.1038/s41586-021-03819-2")[0] == "doi"
17
+ assert classify("10.1109/CVPR.2016.90")[0] == "doi"
18
+ assert classify("Attention Is All You Need")[0] == "title"
19
+
20
+
21
+ def test_entry_is_misc_so_conference_styles_can_print_it():
22
+ # @online is biblatex-only: a NeurIPS or IEEE .bst drops the entry.
23
+ entry = _web_entry(WebPage(url="https://example.org/post", title="A Post", year="2024"))
24
+ assert entry["ENTRYTYPE"] == "misc"
25
+ assert entry["howpublished"] == r"\url{https://example.org/post}"
26
+ assert entry["url"] == "https://example.org/post"
27
+ assert entry["year"] == "2024"
28
+
29
+
30
+ def test_a_page_with_no_byline_is_attributed_to_its_site():
31
+ entry = _web_entry(WebPage(url="https://example.org/post", title="A Post", site="Example"))
32
+ # Braced, so the .bst treats it as a corporate name and does not invert it.
33
+ assert entry["author"] == "{Example}"
34
+
35
+
36
+ def test_a_byline_wins_over_the_site():
37
+ entry = _web_entry(
38
+ WebPage(url="https://example.org/post", title="A Post", authors=["Ada Lovelace"], site="Example")
39
+ )
40
+ assert entry["author"] == "Ada Lovelace"
41
+
42
+
43
+ def test_the_site_author_drops_hosting_suffixes():
44
+ # `karpathygithubio…` makes an unreadable citation key.
45
+ assert _site_author("karpathy.github.io") == "Karpathy"
46
+ assert _site_author("www.distill.pub") == "Distill"
47
+ assert _site_author("docs.python.org") == "Python"
48
+
49
+
50
+ def test_a_dateless_page_takes_the_year_from_its_own_url():
51
+ assert _year_in_path("https://karpathy.github.io/2015/05/21/rnn-effectiveness/") == "2015"
52
+ assert _year_in_path("https://example.org/blog/2016/misread-tsne/") == "2016"
53
+ # Not from a query string, where any number at all can appear.
54
+ assert _year_in_path("https://example.org/page?id=2019") == ""
55
+ assert _year_in_path("https://example.org/about") == ""
56
+
57
+
58
+ def test_the_title_loses_the_site_name_templates_append():
59
+ assert _title_tag("<title>How to Use t-SNE | Distill</title>") == "How to Use t-SNE"
60
+ assert _title_tag("<title>Plain Title</title>") == "Plain Title"
61
+ # A title that is only a site name must survive rather than become empty.
62
+ assert _title_tag("<title>Distill</title>") == "Distill"
63
+
64
+
65
+ def test_meta_is_read_in_either_attribute_order():
66
+ assert _meta_content('<meta property="og:title" content="Hello">', "og:title") == "Hello"
67
+ assert _meta_content('<meta content="Hello" name="og:title">', "og:title") == "Hello"
68
+ assert _meta_content('<meta property="og:title" content="A &amp; B">', "og:title") == "A & B"
69
+ assert _meta_content("<html></html>", "og:title") == ""
@@ -18,7 +18,7 @@ wheels = [
18
18
 
19
19
  [[package]]
20
20
  name = "bibcite-cli"
21
- version = "0.6.1"
21
+ version = "0.6.3"
22
22
  source = { editable = "." }
23
23
  dependencies = [
24
24
  { name = "bibtexparser" },
File without changes
File without changes