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.
Files changed (60) hide show
  1. reference_audit/__init__.py +8 -0
  2. reference_audit/alignmentcheck.py +136 -0
  3. reference_audit/bookcheck.py +98 -0
  4. reference_audit/cache/__init__.py +1 -0
  5. reference_audit/cache/db.py +108 -0
  6. reference_audit/cache/store.py +128 -0
  7. reference_audit/cli.py +190 -0
  8. reference_audit/config.py +179 -0
  9. reference_audit/fieldcheck.py +599 -0
  10. reference_audit/inputs.py +96 -0
  11. reference_audit/llm/__init__.py +6 -0
  12. reference_audit/llm/client.py +85 -0
  13. reference_audit/llm/prompts.py +206 -0
  14. reference_audit/llm/schemas.py +38 -0
  15. reference_audit/matching/__init__.py +6 -0
  16. reference_audit/matching/adjudicate.py +63 -0
  17. reference_audit/matching/features.py +185 -0
  18. reference_audit/matching/names.py +238 -0
  19. reference_audit/matching/pool.py +344 -0
  20. reference_audit/matching/sameobject.py +150 -0
  21. reference_audit/matching/scoring.py +75 -0
  22. reference_audit/matching/verdict.py +56 -0
  23. reference_audit/matching/webcheck.py +189 -0
  24. reference_audit/models.py +412 -0
  25. reference_audit/parsing/__init__.py +1 -0
  26. reference_audit/parsing/bib.py +392 -0
  27. reference_audit/parsing/context.py +55 -0
  28. reference_audit/parsing/entities.py +22 -0
  29. reference_audit/parsing/identifiers.py +165 -0
  30. reference_audit/parsing/latex.py +83 -0
  31. reference_audit/parsing/tex.py +159 -0
  32. reference_audit/pdf/__init__.py +16 -0
  33. reference_audit/pdf/embedded.py +117 -0
  34. reference_audit/pdf/grobid.py +234 -0
  35. reference_audit/pdf/tei.py +470 -0
  36. reference_audit/pipeline.py +1692 -0
  37. reference_audit/report.py +330 -0
  38. reference_audit/sources/__init__.py +6 -0
  39. reference_audit/sources/arxiv.py +81 -0
  40. reference_audit/sources/base.py +54 -0
  41. reference_audit/sources/clickhouse.py +575 -0
  42. reference_audit/sources/crossref.py +90 -0
  43. reference_audit/sources/dblp.py +140 -0
  44. reference_audit/sources/google_books.py +101 -0
  45. reference_audit/sources/http.py +260 -0
  46. reference_audit/sources/normalize.py +464 -0
  47. reference_audit/sources/openalex.py +93 -0
  48. reference_audit/sources/openlibrary.py +158 -0
  49. reference_audit/sources/publisher.py +186 -0
  50. reference_audit/sources/registry.py +117 -0
  51. reference_audit/sources/render.py +115 -0
  52. reference_audit/sources/semantic_scholar.py +68 -0
  53. reference_audit/sources/titlewords.py +31 -0
  54. reference_audit/sources/web.py +256 -0
  55. reference_audit/versioning.py +125 -0
  56. reference_audit-0.1.0.dist-info/METADATA +715 -0
  57. reference_audit-0.1.0.dist-info/RECORD +60 -0
  58. reference_audit-0.1.0.dist-info/WHEEL +4 -0
  59. reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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