reference-audit 0.1.0__py3-none-any.whl
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.
- reference_audit/__init__.py +8 -0
- reference_audit/alignmentcheck.py +136 -0
- reference_audit/bookcheck.py +98 -0
- reference_audit/cache/__init__.py +1 -0
- reference_audit/cache/db.py +108 -0
- reference_audit/cache/store.py +128 -0
- reference_audit/cli.py +190 -0
- reference_audit/config.py +179 -0
- reference_audit/fieldcheck.py +599 -0
- reference_audit/inputs.py +96 -0
- reference_audit/llm/__init__.py +6 -0
- reference_audit/llm/client.py +85 -0
- reference_audit/llm/prompts.py +206 -0
- reference_audit/llm/schemas.py +38 -0
- reference_audit/matching/__init__.py +6 -0
- reference_audit/matching/adjudicate.py +63 -0
- reference_audit/matching/features.py +185 -0
- reference_audit/matching/names.py +238 -0
- reference_audit/matching/pool.py +344 -0
- reference_audit/matching/sameobject.py +150 -0
- reference_audit/matching/scoring.py +75 -0
- reference_audit/matching/verdict.py +56 -0
- reference_audit/matching/webcheck.py +189 -0
- reference_audit/models.py +412 -0
- reference_audit/parsing/__init__.py +1 -0
- reference_audit/parsing/bib.py +392 -0
- reference_audit/parsing/context.py +55 -0
- reference_audit/parsing/entities.py +22 -0
- reference_audit/parsing/identifiers.py +165 -0
- reference_audit/parsing/latex.py +83 -0
- reference_audit/parsing/tex.py +159 -0
- reference_audit/pdf/__init__.py +16 -0
- reference_audit/pdf/embedded.py +117 -0
- reference_audit/pdf/grobid.py +234 -0
- reference_audit/pdf/tei.py +470 -0
- reference_audit/pipeline.py +1692 -0
- reference_audit/report.py +330 -0
- reference_audit/sources/__init__.py +6 -0
- reference_audit/sources/arxiv.py +81 -0
- reference_audit/sources/base.py +54 -0
- reference_audit/sources/clickhouse.py +575 -0
- reference_audit/sources/crossref.py +90 -0
- reference_audit/sources/dblp.py +140 -0
- reference_audit/sources/google_books.py +101 -0
- reference_audit/sources/http.py +260 -0
- reference_audit/sources/normalize.py +464 -0
- reference_audit/sources/openalex.py +93 -0
- reference_audit/sources/openlibrary.py +158 -0
- reference_audit/sources/publisher.py +186 -0
- reference_audit/sources/registry.py +117 -0
- reference_audit/sources/render.py +115 -0
- reference_audit/sources/semantic_scholar.py +68 -0
- reference_audit/sources/titlewords.py +31 -0
- reference_audit/sources/web.py +256 -0
- reference_audit/versioning.py +125 -0
- reference_audit-0.1.0.dist-info/METADATA +715 -0
- reference_audit-0.1.0.dist-info/RECORD +60 -0
- reference_audit-0.1.0.dist-info/WHEEL +4 -0
- reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
- reference_audit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""DBLP adapter — authoritative coverage of the premier CS/ML venues.
|
|
2
|
+
|
|
3
|
+
NeurIPS, ICLR, and ICML/PMLR mint **no DOI** and are thinly or ambiguously indexed by the
|
|
4
|
+
article-centric aggregators (Crossref/OpenAlex/S2), so a real conference paper cited only by its
|
|
5
|
+
proceedings URL can otherwise fall through to "unable to verify". DBLP indexes exactly these venues
|
|
6
|
+
with exact titles, full author lists, year, venue, and the proceedings landing page — the record
|
|
7
|
+
needed to confirm such an entry.
|
|
8
|
+
|
|
9
|
+
DBLP is queried through its **SPARQL endpoint** (`sparql.dblp.org`, a QLever instance), not the
|
|
10
|
+
`/search/publ/api` search API. The search API and its mirrors now answer automated clients with a
|
|
11
|
+
bot-challenge HTML page instead of JSON, which made every DBLP query an error and left every
|
|
12
|
+
unmatched entry unresolved. The SPARQL endpoint serves the same records as JSON.
|
|
13
|
+
|
|
14
|
+
A search is two requests:
|
|
15
|
+
1. A title search over QLever's word index. A publication matches when its title contains **every**
|
|
16
|
+
searchable word of the cited title (the search API also required every word). Hits are ordered
|
|
17
|
+
shortest-title-first, which puts the exact title ahead of the many longer titles that quote it
|
|
18
|
+
("Attention Is All You Need" vs "Not All Attention Is All You Need").
|
|
19
|
+
2. The properties and ordered author signatures of the top `limit` hits.
|
|
20
|
+
|
|
21
|
+
The records then go to central matching, which decides.
|
|
22
|
+
|
|
23
|
+
This is a metadata-search recall source (there is no DBLP-native id in a typical `.bib`); a DOI it
|
|
24
|
+
carries is still surfaced for backfill. A 429/5xx, a non-JSON body, or a title with no searchable
|
|
25
|
+
word surfaces as `error` (retry next run), never a silent "not found".
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
|
|
32
|
+
from reference_audit.models import (
|
|
33
|
+
BibEntry,
|
|
34
|
+
EntryType,
|
|
35
|
+
SourceQueryResult,
|
|
36
|
+
SourceRecord,
|
|
37
|
+
)
|
|
38
|
+
from reference_audit.sources.base import SourceAdapter
|
|
39
|
+
from reference_audit.sources.http import TransientHTTPError, get_json
|
|
40
|
+
from reference_audit.sources.normalize import dblp_sparql_to_records
|
|
41
|
+
from reference_audit.sources.titlewords import title_search_words
|
|
42
|
+
|
|
43
|
+
_ENDPOINT = "https://sparql.dblp.org/sparql"
|
|
44
|
+
|
|
45
|
+
_PREFIXES = """\
|
|
46
|
+
PREFIX dblp: <https://dblp.org/rdf/schema#>
|
|
47
|
+
PREFIX ql: <http://qlever.cs.uni-freiburg.de/builtin-functions/>
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# Step 1: the candidate publications. Kept separate from step 2 because QLever plans the two joined
|
|
51
|
+
# into one query poorly (seconds instead of tens of milliseconds).
|
|
52
|
+
_SEARCH = _PREFIXES + """\
|
|
53
|
+
SELECT DISTINCT ?publ ?title WHERE {{
|
|
54
|
+
?publ dblp:title ?title .
|
|
55
|
+
?text ql:contains-entity ?title .
|
|
56
|
+
?text ql:contains-word "{words}" .
|
|
57
|
+
}}
|
|
58
|
+
ORDER BY STRLEN(?title) LIMIT {limit}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# Step 2: the properties of those publications, one UNION branch per property so no branch multiplies
|
|
62
|
+
# another's rows. The normalizer folds the rows back into one record per publication.
|
|
63
|
+
_DETAILS = _PREFIXES + """\
|
|
64
|
+
SELECT ?publ ?type ?year ?venue ?pages ?doi ?ee ?ordinal ?name WHERE {{
|
|
65
|
+
VALUES ?publ {{ {iris} }}
|
|
66
|
+
{{ ?publ a ?type }}
|
|
67
|
+
UNION {{ ?publ dblp:yearOfPublication ?year }}
|
|
68
|
+
UNION {{ ?publ dblp:publishedIn ?venue }}
|
|
69
|
+
UNION {{ ?publ dblp:pagination ?pages }}
|
|
70
|
+
UNION {{ ?publ dblp:doi ?doi }}
|
|
71
|
+
UNION {{ ?publ dblp:primaryDocumentPage ?ee }}
|
|
72
|
+
UNION {{
|
|
73
|
+
?publ dblp:hasSignature ?sig .
|
|
74
|
+
?sig a dblp:AuthorSignature ; dblp:signatureOrdinal ?ordinal ; dblp:signatureDblpName ?name .
|
|
75
|
+
}}
|
|
76
|
+
}}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# A publication IRI as the endpoint returns it; anything else is not interpolated into a query.
|
|
80
|
+
_PUBL_IRI_RE = re.compile(r"^https://dblp\.org/rec/[^\s<>\"{}|\\^`]+$")
|
|
81
|
+
_ACCEPT = {"Accept": "application/sparql-results+json"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_search_query(words: list[str], limit: int) -> str:
|
|
85
|
+
# `words` are [a-z0-9]+ only (title_search_words), so they are safe inside the string literal.
|
|
86
|
+
return _SEARCH.format(words=" ".join(words), limit=int(limit))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_details_query(iris: list[str]) -> str:
|
|
90
|
+
return _DETAILS.format(iris=" ".join(f"<{iri}>" for iri in iris))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _UnexpectedResponse(Exception):
|
|
94
|
+
"""A SPARQL response without the result shape the query asked for."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _bindings(data: dict | None) -> list[dict]:
|
|
98
|
+
bindings = ((data or {}).get("results") or {}).get("bindings")
|
|
99
|
+
if not isinstance(bindings, list):
|
|
100
|
+
raise _UnexpectedResponse(f"unexpected SPARQL response: {str(data)[:200]}")
|
|
101
|
+
return bindings
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DblpAdapter(SourceAdapter):
|
|
105
|
+
name = "dblp"
|
|
106
|
+
handles = {EntryType.ARTICLE, EntryType.INPROCEEDINGS, EntryType.MISC}
|
|
107
|
+
rate_per_sec = 2.0
|
|
108
|
+
|
|
109
|
+
async def search_by_metadata(self, entry: BibEntry, limit: int = 10) -> SourceQueryResult:
|
|
110
|
+
if not entry.title:
|
|
111
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", records=[])
|
|
112
|
+
words = title_search_words(entry.title)
|
|
113
|
+
if not words:
|
|
114
|
+
# Nothing the word index can match. An empty result here would read as "DBLP has no
|
|
115
|
+
# such paper" when DBLP was never actually asked.
|
|
116
|
+
return SourceQueryResult(
|
|
117
|
+
source=self.name, query_kind="metadata",
|
|
118
|
+
error=f"title has no searchable word for DBLP: {entry.title!r}",
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
_status, found = await get_json(
|
|
122
|
+
self.client, self.rate_limiter, _ENDPOINT,
|
|
123
|
+
params={"query": build_search_query(words, limit)}, headers=_ACCEPT,
|
|
124
|
+
)
|
|
125
|
+
hits = _bindings(found)
|
|
126
|
+
iris = list(dict.fromkeys(r.get("publ", {}).get("value", "") for r in hits))
|
|
127
|
+
if not iris:
|
|
128
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", records=[])
|
|
129
|
+
bad = [iri for iri in iris if not _PUBL_IRI_RE.match(iri)]
|
|
130
|
+
if bad:
|
|
131
|
+
raise _UnexpectedResponse(f"unexpected publication IRI(s) from DBLP: {bad[:3]}")
|
|
132
|
+
_status, details = await get_json(
|
|
133
|
+
self.client, self.rate_limiter, _ENDPOINT,
|
|
134
|
+
params={"query": build_details_query(iris)}, headers=_ACCEPT,
|
|
135
|
+
)
|
|
136
|
+
rows = hits + _bindings(details)
|
|
137
|
+
except (TransientHTTPError, _UnexpectedResponse) as exc:
|
|
138
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", error=str(exc))
|
|
139
|
+
records: list[SourceRecord] = dblp_sparql_to_records(rows)
|
|
140
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", records=records)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Google Books adapter — broad book coverage where Open Library's strict title search misses.
|
|
2
|
+
|
|
3
|
+
Open Library's `title=` search is exact-ish: a cited title carrying its subtitle ("Why Nations
|
|
4
|
+
Fail: The Origins of Power, Prosperity, and Poverty") returns nothing, and a single off-by-one ISBN
|
|
5
|
+
anchors nothing — so a real, famous book can fall through to "not found". Google Books is far more
|
|
6
|
+
forgiving (`q=intitle:… inauthor:…`, `q=isbn:…`) and, crucially, a .bib often carries a Google Books
|
|
7
|
+
*volume id* in its URL (books.google.…/books?id=…) — the authoritative key for that exact volume,
|
|
8
|
+
resolvable with no fuzzy matching at all.
|
|
9
|
+
|
|
10
|
+
Quota note (the reliability contract): the keyless endpoint shares a global anonymous daily quota
|
|
11
|
+
that is routinely exhausted (HTTP 429); an API key gives a per-project quota. Either way a 429/5xx
|
|
12
|
+
surfaces as `error` (retry next run) via `get_json`, never a silent "not found".
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from reference_audit.models import (
|
|
18
|
+
BibEntry,
|
|
19
|
+
EntryType,
|
|
20
|
+
Identifiers,
|
|
21
|
+
SourceQueryResult,
|
|
22
|
+
SourceRecord,
|
|
23
|
+
)
|
|
24
|
+
from reference_audit.sources.base import SourceAdapter
|
|
25
|
+
from reference_audit.sources.http import TransientHTTPError, get_json
|
|
26
|
+
from reference_audit.sources.normalize import google_books_volume_to_record
|
|
27
|
+
|
|
28
|
+
_BASE = "https://www.googleapis.com/books/v1/volumes"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _author_surname(authors: list[str]) -> str | None:
|
|
32
|
+
"""A surname to constrain the title search ('Acemoglu, D.'/'Daron Acemoglu' → 'Acemoglu')."""
|
|
33
|
+
if not authors:
|
|
34
|
+
return None
|
|
35
|
+
first = authors[0].strip()
|
|
36
|
+
return (first.split(",")[0] if "," in first else first.split()[-1]).strip() or None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GoogleBooksAdapter(SourceAdapter):
|
|
40
|
+
name = "google_books"
|
|
41
|
+
handles = {EntryType.BOOK, EntryType.INCOLLECTION}
|
|
42
|
+
rate_per_sec = 5.0
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, api_key: str | None = None, client=None, limiter=None):
|
|
45
|
+
super().__init__(client=client, limiter=limiter)
|
|
46
|
+
self.api_key = api_key
|
|
47
|
+
|
|
48
|
+
def _params(self, **extra: str | int) -> dict:
|
|
49
|
+
params: dict[str, str | int] = dict(extra)
|
|
50
|
+
if self.api_key:
|
|
51
|
+
params["key"] = self.api_key
|
|
52
|
+
return params
|
|
53
|
+
|
|
54
|
+
async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult:
|
|
55
|
+
# The cited volume id is the authoritative key for that exact volume — no fuzzy matching.
|
|
56
|
+
# Otherwise fall back to an ISBN query (one volume).
|
|
57
|
+
try:
|
|
58
|
+
if ids.google_books:
|
|
59
|
+
status, data = await get_json(
|
|
60
|
+
self.client,
|
|
61
|
+
self.rate_limiter,
|
|
62
|
+
f"{_BASE}/{ids.google_books}",
|
|
63
|
+
params=self._params(),
|
|
64
|
+
)
|
|
65
|
+
if status == 404 or not data:
|
|
66
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=[])
|
|
67
|
+
return SourceQueryResult(
|
|
68
|
+
source=self.name,
|
|
69
|
+
query_kind="id",
|
|
70
|
+
records=[google_books_volume_to_record(data)],
|
|
71
|
+
)
|
|
72
|
+
if ids.isbn13:
|
|
73
|
+
_status, data = await get_json(
|
|
74
|
+
self.client,
|
|
75
|
+
self.rate_limiter,
|
|
76
|
+
_BASE,
|
|
77
|
+
params=self._params(q=f"isbn:{ids.isbn13}", maxResults=1),
|
|
78
|
+
)
|
|
79
|
+
items = (data or {}).get("items") or []
|
|
80
|
+
records = [google_books_volume_to_record(v) for v in items[:1]]
|
|
81
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=records)
|
|
82
|
+
except TransientHTTPError as exc:
|
|
83
|
+
return SourceQueryResult(source=self.name, query_kind="id", error=str(exc))
|
|
84
|
+
return SourceQueryResult(source=self.name, query_kind="id", records=[])
|
|
85
|
+
|
|
86
|
+
async def search_by_metadata(self, entry: BibEntry, limit: int = 10) -> SourceQueryResult:
|
|
87
|
+
if not entry.title:
|
|
88
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", records=[])
|
|
89
|
+
q = f'intitle:"{entry.title}"'
|
|
90
|
+
surname = _author_surname(entry.authors)
|
|
91
|
+
if surname:
|
|
92
|
+
q += f" inauthor:{surname}"
|
|
93
|
+
try:
|
|
94
|
+
_status, data = await get_json(
|
|
95
|
+
self.client, self.rate_limiter, _BASE, params=self._params(q=q, maxResults=limit)
|
|
96
|
+
)
|
|
97
|
+
except TransientHTTPError as exc:
|
|
98
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", error=str(exc))
|
|
99
|
+
items = (data or {}).get("items") or []
|
|
100
|
+
records: list[SourceRecord] = [google_books_volume_to_record(v) for v in items]
|
|
101
|
+
return SourceQueryResult(source=self.name, query_kind="metadata", records=records)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Shared async HTTP utilities: rate limiting + transient-error retry.
|
|
2
|
+
|
|
3
|
+
Modeled on `sciwrite-lint/rate_limiter.py`. `error` from a transport failure or 429/5xx is
|
|
4
|
+
surfaced distinctly so the pipeline never treats an outage as 'not found'.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from curl_cffi.requests import AsyncSession
|
|
16
|
+
from curl_cffi.requests.exceptions import RequestException as ImpersonateRequestError
|
|
17
|
+
from tenacity import (
|
|
18
|
+
retry,
|
|
19
|
+
retry_if_exception_type,
|
|
20
|
+
stop_after_attempt,
|
|
21
|
+
wait_exponential,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_TIMEOUT = 30.0
|
|
25
|
+
DEFAULT_USER_AGENT = "reference-audit/0.1 (https://github.com/; mailto:reference-audit@example.org)"
|
|
26
|
+
|
|
27
|
+
# Some publisher platforms (Silverchair direct.mit.edu, Atypon journals.sagepub.com) sit behind
|
|
28
|
+
# Cloudflare, which fingerprints the TLS ClientHello (JA3/JA4). httpx's fingerprint is flagged as a
|
|
29
|
+
# bot and 403'd even with a full browser header set + HTTP/2; curl/Firefox pass. curl_cffi replays a
|
|
30
|
+
# real browser's ClientHello, so the export resolves. Used ONLY for those publisher fetches —
|
|
31
|
+
# the clean-API sources stay on httpx.
|
|
32
|
+
DEFAULT_IMPERSONATE = "chrome"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TransientHTTPError(Exception):
|
|
36
|
+
"""A retryable failure: transport error or 429/5xx response."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MonotonicRateLimiter:
|
|
40
|
+
"""Token-free min-interval limiter shared across coroutines (monotonic clock), with an optional
|
|
41
|
+
cap on requests in flight.
|
|
42
|
+
|
|
43
|
+
The interval spaces request *starts*, which alone does not bound concurrency: a source whose
|
|
44
|
+
searches take seconds (Crossref) piles up dozens of open requests at 10 starts/s and answers 429.
|
|
45
|
+
`max_in_flight` caps them; a slot is held for a whole request, retries and backoff included.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, rate_per_sec: float, max_in_flight: int | None = None):
|
|
49
|
+
self._min_interval = 1.0 / rate_per_sec if rate_per_sec > 0 else 0.0
|
|
50
|
+
self._last = 0.0
|
|
51
|
+
self._lock = asyncio.Lock()
|
|
52
|
+
self.max_in_flight = max_in_flight
|
|
53
|
+
self._slots = asyncio.Semaphore(max_in_flight) if max_in_flight else None
|
|
54
|
+
|
|
55
|
+
async def acquire(self) -> None:
|
|
56
|
+
if self._min_interval <= 0:
|
|
57
|
+
return
|
|
58
|
+
async with self._lock:
|
|
59
|
+
now = time.monotonic()
|
|
60
|
+
wait = self._last + self._min_interval - now
|
|
61
|
+
if wait > 0:
|
|
62
|
+
await asyncio.sleep(wait)
|
|
63
|
+
self._last = time.monotonic()
|
|
64
|
+
|
|
65
|
+
@asynccontextmanager
|
|
66
|
+
async def slot(self) -> AsyncIterator[None]:
|
|
67
|
+
"""Hold one in-flight slot for the duration of a request (a no-op without a cap)."""
|
|
68
|
+
if self._slots is None:
|
|
69
|
+
yield
|
|
70
|
+
return
|
|
71
|
+
async with self._slots:
|
|
72
|
+
yield
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def new_client(user_agent: str = DEFAULT_USER_AGENT, timeout: float = DEFAULT_TIMEOUT) -> httpx.AsyncClient:
|
|
76
|
+
return httpx.AsyncClient(timeout=timeout, headers={"User-Agent": user_agent}, follow_redirects=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_transient_status(resp: httpx.Response) -> bool:
|
|
80
|
+
"""The default definition of a retryable response: rate-limited, or a server-side failure."""
|
|
81
|
+
return resp.status_code == 429 or resp.status_code >= 500
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@retry(
|
|
85
|
+
stop=stop_after_attempt(3),
|
|
86
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=8),
|
|
87
|
+
retry=retry_if_exception_type(TransientHTTPError),
|
|
88
|
+
reraise=True,
|
|
89
|
+
)
|
|
90
|
+
async def _send_with_retry(
|
|
91
|
+
send: Callable[[], Awaitable[httpx.Response]],
|
|
92
|
+
transient: Callable[[httpx.Response], bool] = is_transient_status,
|
|
93
|
+
) -> httpx.Response:
|
|
94
|
+
"""Verb-agnostic retry core: one definition of "what is transient" for every request we make.
|
|
95
|
+
|
|
96
|
+
`transient` is injectable because not every 5xx means "the server is unwell". GROBID reports a
|
|
97
|
+
*per-document* parse failure through a 500 body, and retrying that three times is pure latency
|
|
98
|
+
while reporting it as an outage would mislabel an unparseable PDF as a service problem.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
resp = await send()
|
|
102
|
+
except httpx.TransportError as exc: # network/DNS/timeout
|
|
103
|
+
raise TransientHTTPError(f"transport: {exc}") from exc
|
|
104
|
+
if transient(resp):
|
|
105
|
+
raise TransientHTTPError(f"http {resp.status_code}")
|
|
106
|
+
return resp
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _request_with_retry(
|
|
110
|
+
client: httpx.AsyncClient,
|
|
111
|
+
limiter: MonotonicRateLimiter,
|
|
112
|
+
url: str,
|
|
113
|
+
params: dict | None,
|
|
114
|
+
headers: dict | None,
|
|
115
|
+
) -> httpx.Response:
|
|
116
|
+
"""Every attempt, retries included, waits its turn at the limiter. The in-flight slot is held
|
|
117
|
+
across the backoff, so a source that answered 429 is not handed the next request meanwhile."""
|
|
118
|
+
|
|
119
|
+
async def send() -> httpx.Response:
|
|
120
|
+
await limiter.acquire()
|
|
121
|
+
return await client.get(url, params=params, headers=headers)
|
|
122
|
+
|
|
123
|
+
async with limiter.slot():
|
|
124
|
+
return await _send_with_retry(send)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def post_multipart(
|
|
128
|
+
client: httpx.AsyncClient,
|
|
129
|
+
url: str,
|
|
130
|
+
*,
|
|
131
|
+
files: dict,
|
|
132
|
+
data: dict | None = None,
|
|
133
|
+
timeout: float | None = None,
|
|
134
|
+
transient: Callable[[httpx.Response], bool] = is_transient_status,
|
|
135
|
+
) -> httpx.Response:
|
|
136
|
+
"""Multipart POST under the same transient-retry policy as the GET helpers.
|
|
137
|
+
|
|
138
|
+
Returns the raw response — unlike `get_json`/`get_text` the caller classifies non-2xx itself,
|
|
139
|
+
because a multipart-POST service (GROBID) encodes distinct per-request failure modes in the body
|
|
140
|
+
and they need different handling. Not rate-limited: the only caller talks to a single-tenant local
|
|
141
|
+
service one request per run.
|
|
142
|
+
"""
|
|
143
|
+
return await _send_with_retry(
|
|
144
|
+
lambda: client.post(url, files=files, data=data, timeout=timeout), transient
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def get_json(
|
|
149
|
+
client: httpx.AsyncClient,
|
|
150
|
+
limiter: MonotonicRateLimiter,
|
|
151
|
+
url: str,
|
|
152
|
+
*,
|
|
153
|
+
params: dict | None = None,
|
|
154
|
+
headers: dict | None = None,
|
|
155
|
+
) -> tuple[int, dict | None]:
|
|
156
|
+
"""Rate-limited GET with retry. Returns (status_code, json-or-None).
|
|
157
|
+
|
|
158
|
+
Raises TransientHTTPError after exhausting retries (caller maps to SourceQueryResult.error).
|
|
159
|
+
A 404 returns (404, None) — a genuine 'absent', distinct from an error.
|
|
160
|
+
"""
|
|
161
|
+
resp = await _request_with_retry(client, limiter, url, params, headers)
|
|
162
|
+
if resp.status_code == 404:
|
|
163
|
+
return 404, None
|
|
164
|
+
if resp.status_code >= 400:
|
|
165
|
+
# non-retryable client error (e.g. 400/403) — treat as a hard error, not 'absent'
|
|
166
|
+
raise TransientHTTPError(f"http {resp.status_code}")
|
|
167
|
+
try:
|
|
168
|
+
return resp.status_code, resp.json()
|
|
169
|
+
except ValueError as exc:
|
|
170
|
+
raise TransientHTTPError(f"invalid json: {exc}") from exc
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def get_text(
|
|
174
|
+
client: httpx.AsyncClient,
|
|
175
|
+
limiter: MonotonicRateLimiter,
|
|
176
|
+
url: str,
|
|
177
|
+
*,
|
|
178
|
+
params: dict | None = None,
|
|
179
|
+
headers: dict | None = None,
|
|
180
|
+
) -> tuple[int, str]:
|
|
181
|
+
"""Rate-limited GET with retry for text/XML payloads (e.g. the arXiv Atom API).
|
|
182
|
+
|
|
183
|
+
Mirrors `get_json`: exponential backoff on 429/5xx via `_request_with_retry`, then raises
|
|
184
|
+
TransientHTTPError after exhausting retries. A 404 returns (404, "").
|
|
185
|
+
"""
|
|
186
|
+
resp = await _request_with_retry(client, limiter, url, params, headers)
|
|
187
|
+
if resp.status_code == 404:
|
|
188
|
+
return 404, ""
|
|
189
|
+
if resp.status_code >= 400:
|
|
190
|
+
# non-retryable client error (e.g. 400/403) — treat as a hard error, not 'absent'
|
|
191
|
+
raise TransientHTTPError(f"http {resp.status_code}")
|
|
192
|
+
return resp.status_code, resp.text
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def get_html(
|
|
196
|
+
client: httpx.AsyncClient,
|
|
197
|
+
limiter: MonotonicRateLimiter,
|
|
198
|
+
url: str,
|
|
199
|
+
*,
|
|
200
|
+
headers: dict | None = None,
|
|
201
|
+
) -> tuple[int, str, str]:
|
|
202
|
+
"""Rate-limited GET of an HTML page, returning (status, final_url, text).
|
|
203
|
+
|
|
204
|
+
Unlike `get_text`, the *final* URL (after redirects) is returned too, so a web-artifact check can
|
|
205
|
+
record where the citation actually landed. A dead link — HTTP 404 or 410 (Gone) — returns
|
|
206
|
+
(status, final_url, "") so the caller can report it as a dead link rather than an outage; any
|
|
207
|
+
other 4xx (e.g. a 403 bot-wall) raises TransientHTTPError so it is reported as a block, never a
|
|
208
|
+
false 'page absent'.
|
|
209
|
+
"""
|
|
210
|
+
resp = await _request_with_retry(client, limiter, url, None, headers)
|
|
211
|
+
if resp.status_code in (404, 410):
|
|
212
|
+
return resp.status_code, str(resp.url), ""
|
|
213
|
+
if resp.status_code >= 400:
|
|
214
|
+
raise TransientHTTPError(f"http {resp.status_code}")
|
|
215
|
+
return resp.status_code, str(resp.url), resp.text
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def new_impersonate_session(timeout: float = DEFAULT_TIMEOUT) -> AsyncSession:
|
|
219
|
+
"""A browser-TLS-impersonating session (libcurl via curl_cffi) for Cloudflare-fingerprint-walled
|
|
220
|
+
endpoints. Reused across calls so the issued `__cf_bm` cookie persists. See DEFAULT_IMPERSONATE.
|
|
221
|
+
"""
|
|
222
|
+
return AsyncSession(timeout=timeout, impersonate=DEFAULT_IMPERSONATE)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@retry(
|
|
226
|
+
stop=stop_after_attempt(3),
|
|
227
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=8),
|
|
228
|
+
retry=retry_if_exception_type(TransientHTTPError),
|
|
229
|
+
reraise=True,
|
|
230
|
+
)
|
|
231
|
+
async def _impersonate_get_with_retry(
|
|
232
|
+
session: AsyncSession, limiter: MonotonicRateLimiter, url: str
|
|
233
|
+
):
|
|
234
|
+
await limiter.acquire() # per attempt, as in `_request_with_retry`
|
|
235
|
+
try:
|
|
236
|
+
resp = await session.get(url)
|
|
237
|
+
except ImpersonateRequestError as exc: # network/DNS/timeout/TLS
|
|
238
|
+
raise TransientHTTPError(f"transport: {exc}") from exc
|
|
239
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
240
|
+
raise TransientHTTPError(f"http {resp.status_code}")
|
|
241
|
+
return resp
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def get_text_impersonate(
|
|
245
|
+
session: AsyncSession,
|
|
246
|
+
limiter: MonotonicRateLimiter,
|
|
247
|
+
url: str,
|
|
248
|
+
) -> tuple[int, str]:
|
|
249
|
+
"""Browser-impersonating counterpart of `get_text` for Cloudflare-fingerprint-walled publisher
|
|
250
|
+
exports. Same contract: rate-limited, exponential backoff on transport/429/5xx, then raises
|
|
251
|
+
TransientHTTPError after exhausting retries; a 404 returns (404, ""); any other 4xx (e.g. a 403
|
|
252
|
+
bot-wall) raises TransientHTTPError so the caller reports a block rather than a false 'absent'.
|
|
253
|
+
"""
|
|
254
|
+
async with limiter.slot():
|
|
255
|
+
resp = await _impersonate_get_with_retry(session, limiter, url)
|
|
256
|
+
if resp.status_code == 404:
|
|
257
|
+
return 404, ""
|
|
258
|
+
if resp.status_code >= 400:
|
|
259
|
+
raise TransientHTTPError(f"http {resp.status_code}")
|
|
260
|
+
return resp.status_code, resp.text
|