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,158 @@
1
+ """Open Library adapter — books / ISBN backfill (gavrilets2004, mabook).
2
+
3
+ Matches on title+author (the pilot books have a publisher typo, so publisher is never a feature).
4
+ `fetch_editions` additionally enumerates a book's concrete editions (year/publisher/ISBN each) for
5
+ the edition-aware book check.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from reference_audit.models import (
13
+ BibEntry,
14
+ EntryType,
15
+ Identifiers,
16
+ SourceQueryResult,
17
+ SourceRecord,
18
+ )
19
+ from reference_audit.parsing.entities import decode_html_entities
20
+ from reference_audit.sources.base import SourceAdapter
21
+ from reference_audit.sources.http import TransientHTTPError, get_json, new_client
22
+ from reference_audit.sources.normalize import (
23
+ openlibrary_doc_to_record,
24
+ openlibrary_edition_to_record,
25
+ )
26
+
27
+ _SEARCH = "https://openlibrary.org/search.json"
28
+ _FIELDS = "key,title,author_name,first_publish_year,isbn,edition_count,publisher"
29
+
30
+ _OL = "https://openlibrary.org"
31
+ _EDITIONS_LIMIT = 50 # editions fetched per work
32
+ _MAX_WORKS = 6 # Open Library often splits one book across several "work" records
33
+
34
+
35
+ def _fold(s: str | None) -> str:
36
+ # `doc` titles are raw API text, not a decoded `SourceRecord`: "&" is no word "amp".
37
+ return re.sub(r"[^a-z0-9]+", " ", decode_html_entities(s or "").lower()).strip()
38
+
39
+
40
+ def _title_compatible(entry_title: str, work_title: str | None) -> bool:
41
+ """A work is the cited book if the folded titles are equal or one contains the other (Open
42
+ Library appends series subtitles like '(Frontiers in Physics, No. 46)' to some records)."""
43
+ a, b = _fold(entry_title), _fold(work_title)
44
+ return bool(a and b and (a == b or a in b or b in a))
45
+
46
+
47
+ def _author_query(authors: list[str]) -> str | None:
48
+ """A surname to constrain the title search ('Ma, Shang-Keng'/'Shang-Keng Ma' → 'Ma')."""
49
+ if not authors:
50
+ return None
51
+ first = authors[0].strip()
52
+ return (first.split(",")[0] if "," in first else first.split()[-1]).strip() or None
53
+
54
+
55
+ class OpenLibraryAdapter(SourceAdapter):
56
+ name = "openlibrary"
57
+ handles = {EntryType.BOOK, EntryType.INCOLLECTION}
58
+ rate_per_sec = 5.0
59
+
60
+ def __init__(self, *, email: str | None = None, client=None, limiter=None):
61
+ # Open Library asks API clients to identify themselves with a contact email *in the
62
+ # User-Agent header* (not a query param like Crossref's `mailto`); see
63
+ # https://openlibrary.org/developers/api#rate-limits. When configured and no client is
64
+ # injected (tests pass their own), build the client with an email-bearing User-Agent.
65
+ if client is None and email:
66
+ client = new_client(user_agent=f"reference-audit/0.1 (mailto:{email})")
67
+ super().__init__(client=client, limiter=limiter)
68
+ self.email = email
69
+
70
+ async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult:
71
+ if not ids.isbn13:
72
+ return SourceQueryResult(source=self.name, query_kind="id", records=[])
73
+ params = {"isbn": ids.isbn13, "fields": _FIELDS, "limit": 1}
74
+ try:
75
+ _status, data = await get_json(self.client, self.rate_limiter, _SEARCH, params=params)
76
+ except TransientHTTPError as exc:
77
+ return SourceQueryResult(source=self.name, query_kind="id", error=str(exc))
78
+ docs = (data or {}).get("docs") or []
79
+ records = [openlibrary_doc_to_record(d) for d in docs[:1]]
80
+ return SourceQueryResult(source=self.name, query_kind="id", records=records)
81
+
82
+ async def search_by_metadata(self, entry: BibEntry, limit: int = 10) -> SourceQueryResult:
83
+ if not entry.title:
84
+ return SourceQueryResult(source=self.name, query_kind="metadata", records=[])
85
+ params: dict[str, str | int] = {"title": entry.title, "fields": _FIELDS, "limit": limit}
86
+ if entry.authors:
87
+ params["author"] = entry.authors[0]
88
+ try:
89
+ _status, data = await get_json(self.client, self.rate_limiter, _SEARCH, params=params)
90
+ except TransientHTTPError as exc:
91
+ return SourceQueryResult(source=self.name, query_kind="metadata", error=str(exc))
92
+ docs = (data or {}).get("docs") or []
93
+ records: list[SourceRecord] = [openlibrary_doc_to_record(d) for d in docs]
94
+ return SourceQueryResult(source=self.name, query_kind="metadata", records=records)
95
+
96
+ async def fetch_editions(self, entry: BibEntry) -> SourceQueryResult:
97
+ """Every concrete Open Library edition of the cited book (one SourceRecord per edition).
98
+
99
+ Works keys come from both the ISBN search (anchors the cited edition) and a title+author
100
+ search (reaches editions Open Library scattered across sibling work records — needed to find
101
+ the latest one). A transport/HTTP failure is surfaced as `error` (never an empty 'no
102
+ editions'), so the caller reports the gap rather than silently passing the book unverified.
103
+ """
104
+ kind = "editions"
105
+ try:
106
+ keys = await self._work_keys(entry)
107
+ except TransientHTTPError as exc:
108
+ return SourceQueryResult(source=self.name, query_kind=kind, error=str(exc))
109
+
110
+ records: list[SourceRecord] = []
111
+ seen: set[str] = set()
112
+ errors: list[str] = []
113
+ for key in keys[:_MAX_WORKS]:
114
+ try:
115
+ editions = await self._editions_of_work(key)
116
+ except TransientHTTPError as exc:
117
+ errors.append(f"{key}: {exc}")
118
+ continue
119
+ for rec in editions:
120
+ dedup = rec.ids.isbn13 or rec.source_native_id or f"{rec.year}|{_fold(rec.publisher)}"
121
+ if dedup in seen:
122
+ continue
123
+ seen.add(dedup)
124
+ records.append(rec)
125
+ if errors and not records:
126
+ return SourceQueryResult(source=self.name, query_kind=kind, error="; ".join(errors))
127
+ return SourceQueryResult(source=self.name, query_kind=kind, records=records)
128
+
129
+ async def _work_keys(self, entry: BibEntry) -> list[str]:
130
+ keys: list[str] = []
131
+ # Try every ISBN the entry carries: one book registers several (print/electronic, per
132
+ # edition) and Open Library indexes only some, so a single ISBN can miss a book it does hold.
133
+ for isbn in sorted(entry.ids.all_isbn13()):
134
+ params: dict[str, str | int] = {"isbn": isbn, "fields": "key,title", "limit": 5}
135
+ _status, data = await get_json(self.client, self.rate_limiter, _SEARCH, params=params)
136
+ for doc in (data or {}).get("docs") or []:
137
+ key = (doc.get("key") or "").strip()
138
+ if key:
139
+ keys.append(key)
140
+ if entry.title:
141
+ params = {"title": entry.title, "fields": "key,title", "limit": 10}
142
+ author = _author_query(entry.authors)
143
+ if author:
144
+ params["author"] = author
145
+ _status, data = await get_json(self.client, self.rate_limiter, _SEARCH, params=params)
146
+ for doc in (data or {}).get("docs") or []:
147
+ key = (doc.get("key") or "").strip()
148
+ if key and _title_compatible(entry.title, doc.get("title")):
149
+ keys.append(key)
150
+ return list(dict.fromkeys(keys))
151
+
152
+ async def _editions_of_work(self, work_key: str) -> list[SourceRecord]:
153
+ url = f"{_OL}{work_key}/editions.json"
154
+ _status, data = await get_json(
155
+ self.client, self.rate_limiter, url, params={"limit": _EDITIONS_LIMIT}
156
+ )
157
+ entries = (data or {}).get("entries") or []
158
+ return [openlibrary_edition_to_record(e) for e in entries]
@@ -0,0 +1,186 @@
1
+ """Publisher-of-record resolver — the DOI's landing-page citation export.
2
+
3
+ The authority of record. Aggregators (Crossref/OpenAlex/Semantic Scholar) sometimes lack fields the
4
+ publisher exposes only through its own "Cite" export — notably pages/article numbers for
5
+ article-numbered venues (the MIT Press/Silverchair proceedings whose Crossref `page` is null but
6
+ whose BibTeX export carries `pages={131}`).
7
+
8
+ Resolution is deliberately conservative and reliability-first:
9
+ 1. dereference the DOI to its landing URL (a redirect — works even when the page body is
10
+ bot-protected, because we read only the final URL);
11
+ 2. derive the platform's citation-export URL (Silverchair: `/Citation/Download?...` keyed by the
12
+ numeric resource id in the landing path);
13
+ 3. fetch + parse the BibTeX.
14
+ The export fetch (step 3) goes through a browser-TLS-impersonating client (`get_text_impersonate`),
15
+ because these platforms sit behind Cloudflare, which fingerprints httpx's TLS ClientHello and 403s it
16
+ even with browser headers; replaying a real browser's ClientHello passes. If any step fails — unknown
17
+ platform, an escalated JS/interactive bot-challenge instead of BibTeX, a transport error — the result
18
+ is a reported gap (empty records, or an `error` for a transient block), never a guess and never
19
+ cached as "absent".
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from urllib.parse import quote, urlparse
25
+
26
+ import httpx
27
+
28
+ from reference_audit.models import EntryType, Identifiers, SourceQueryResult
29
+ from reference_audit.sources.base import SourceAdapter
30
+ from reference_audit.sources.http import (
31
+ TransientHTTPError,
32
+ get_text_impersonate,
33
+ new_impersonate_session,
34
+ )
35
+ from reference_audit.sources.normalize import publisher_bibtex_to_record
36
+
37
+ _DOI_RESOLVER = "https://doi.org/"
38
+ _HANDLE_API = "https://doi.org/api/handles/"
39
+
40
+ # Silverchair-hosted publishers expose a citation download at
41
+ # {scheme}://{host}/Citation/Download?resourceId={N}&resourceType=3&citationFormat=2 (BibTeX)
42
+ # where {N} is the trailing numeric segment of the article landing path
43
+ # .../isal2023/35/131/116921 -> resourceId=116921
44
+ _SILVERCHAIR_HOSTS = ("direct.mit.edu",)
45
+
46
+ # Atypon-hosted publishers (Sage, …) expose a citation download keyed directly by the DOI:
47
+ # {scheme}://{host}/action/downloadCitation?doi={DOI}&format=bibtex&include=cit (BibTeX)
48
+ # Unlike Silverchair, no landing-path resource id is needed — the DOI alone derives the URL.
49
+ # Like Silverchair, these endpoints are Cloudflare-walled, so the export fetch goes through the
50
+ # browser-impersonating client; an *escalated* interactive challenge still surfaces a reported error.
51
+ _ATYPON_HOSTS = ("journals.sagepub.com",)
52
+
53
+
54
+ def _silverchair_export_url(landing: str) -> str | None:
55
+ parts = urlparse(landing)
56
+ if not any(parts.netloc == h or parts.netloc.endswith("." + h) for h in _SILVERCHAIR_HOSTS):
57
+ return None
58
+ segments = [s for s in parts.path.split("/") if s]
59
+ if not segments or not segments[-1].isdigit():
60
+ return None
61
+ return (
62
+ f"{parts.scheme}://{parts.netloc}/Citation/Download"
63
+ f"?resourceId={segments[-1]}&resourceType=3&citationFormat=2"
64
+ )
65
+
66
+
67
+ def _atypon_export_url(landing: str, doi: str) -> str | None:
68
+ parts = urlparse(landing)
69
+ if not any(parts.netloc == h or parts.netloc.endswith("." + h) for h in _ATYPON_HOSTS):
70
+ return None
71
+ return (
72
+ f"{parts.scheme}://{parts.netloc}/action/downloadCitation"
73
+ f"?doi={quote(doi, safe='')}&format=bibtex&include=cit"
74
+ )
75
+
76
+
77
+ class PublisherAdapter(SourceAdapter):
78
+ """Fetch the DOI publisher's own citation export (Silverchair and Atypon platforms)."""
79
+
80
+ name = "publisher"
81
+ handles = {
82
+ EntryType.ARTICLE,
83
+ EntryType.INPROCEEDINGS,
84
+ EntryType.BOOK,
85
+ EntryType.INCOLLECTION,
86
+ EntryType.MISC,
87
+ }
88
+ rate_per_sec = 2.0 # be gentle on publisher sites
89
+
90
+ def __init__(self, *, export_fetch=None, **kwargs):
91
+ """`export_fetch` (async `(url) -> (status, text)`) overrides the citation-export fetcher;
92
+ injected by tests. In production it defaults to a lazily-created browser-impersonating
93
+ session (curl_cffi), kept separate from `self.client` (httpx) which still serves the DOI
94
+ resolution. DOI resolution stays on httpx because it reads only the redirect target, never a
95
+ Cloudflare-walled body.
96
+ """
97
+ super().__init__(**kwargs)
98
+ self._export_fetch = export_fetch
99
+ self._imp_session = None
100
+
101
+ async def _fetch_export(self, url: str) -> tuple[int, str]:
102
+ if self._export_fetch is not None:
103
+ return await self._export_fetch(url)
104
+ if self._imp_session is None:
105
+ self._imp_session = new_impersonate_session()
106
+ return await get_text_impersonate(self._imp_session, self.rate_limiter, url)
107
+
108
+ async def aclose(self) -> None:
109
+ if self._imp_session is not None:
110
+ await self._imp_session.close()
111
+ await super().aclose()
112
+
113
+ async def _landing_url(self, doi: str) -> str:
114
+ """Final URL the DOI resolves to (redirect only; the page body may be bot-protected)."""
115
+ await self.rate_limiter.acquire()
116
+ try:
117
+ resp = await self.client.get(f"{_DOI_RESOLVER}{doi}")
118
+ except httpx.TransportError as exc:
119
+ raise TransientHTTPError(f"transport: {exc}") from exc
120
+ return str(resp.url)
121
+
122
+ async def doi_registered(self, doi: str) -> bool | None:
123
+ """Whether the handle system (doi.org) actually knows this DOI.
124
+
125
+ A *backfilled* DOI is only useful if it resolves: authors sometimes record a bogus DOI in
126
+ their arXiv/preprint metadata (e.g. an ACM `10.5555/...` placeholder for a venue that mints
127
+ no real DOI) which the aggregators then echo; a cited DOI may be invented outright.
128
+
129
+ The Handle REST API answers this authoritatively, and is asked instead of the resolver: the
130
+ resolver answers HTTP 500, not 404, for a DOI under a prefix nobody registered
131
+ (`10.8888/...`), which read as an outage. The API's `responseCode` decides: 1 (found) or 200
132
+ (handle found, no URL value) ⇒ registered; 100 (handle not found) ⇒ not registered; 301
133
+ ("That prefix doesn't live here": a handle prefix the DOI system does not serve, e.g.
134
+ `10.77771/...`) ⇒ not registered, since every real DOI prefix is homed there. A transport
135
+ error, a 429/5xx or a body without a code returns None — an outage is never read as
136
+ 'invalid'.
137
+ """
138
+ await self.rate_limiter.acquire()
139
+ try:
140
+ resp = await self.client.get(f"{_HANDLE_API}{doi}", params={"type": "URL"})
141
+ data = resp.json()
142
+ except (httpx.HTTPError, ValueError):
143
+ return None
144
+ code = data.get("responseCode") if isinstance(data, dict) else None
145
+ if code in (1, 200):
146
+ return True
147
+ if code in (100, 301):
148
+ return False
149
+ return None # undetermined; never asserted invalid
150
+
151
+ async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult:
152
+ if not ids.doi:
153
+ return SourceQueryResult(source=self.name, query_kind="id", records=[])
154
+ try:
155
+ landing = await self._landing_url(ids.doi)
156
+ except TransientHTTPError as exc:
157
+ return SourceQueryResult(source=self.name, query_kind="id", error=str(exc))
158
+
159
+ export_url = _silverchair_export_url(landing) or _atypon_export_url(landing, ids.doi)
160
+ if export_url is None:
161
+ # Unknown publisher platform — nothing we know how to fetch. A clean "not found" here
162
+ # (not an error): field checks then report the gap against the sources we did consult.
163
+ return SourceQueryResult(source=self.name, query_kind="id", records=[])
164
+
165
+ try:
166
+ status, text = await self._fetch_export(export_url)
167
+ except TransientHTTPError as exc:
168
+ # Most commonly a Cloudflare/JS bot-challenge (HTTP 403). Surfaced as an error — a
169
+ # human-retrievable datum we could not auto-fetch — so it is reported, not treated as
170
+ # absent, and retried next run rather than cached.
171
+ return SourceQueryResult(
172
+ source=self.name,
173
+ query_kind="id",
174
+ error=f"publisher citation export not retrievable ({exc}): {export_url}",
175
+ )
176
+ if status == 404:
177
+ return SourceQueryResult(source=self.name, query_kind="id", records=[])
178
+
179
+ record = publisher_bibtex_to_record(text)
180
+ if record is None:
181
+ return SourceQueryResult(
182
+ source=self.name,
183
+ query_kind="id",
184
+ error=f"publisher response was not a citation export (bot-challenge?): {export_url}",
185
+ )
186
+ return SourceQueryResult(source=self.name, query_kind="id", records=[record])
@@ -0,0 +1,117 @@
1
+ """Adapter construction + per-entry routing (entry_type × available identifiers).
2
+
3
+ Builds the default adapter set from config, and decides which adapters to query by id vs by
4
+ metadata for a given entry. Metadata search ALWAYS runs (even when an id is present) so it can
5
+ backfill missing DOIs/ISBNs and supply corroborating candidates for the equivalence step.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from reference_audit.config import AuditConfig
13
+ from reference_audit.models import BibEntry, EntryType
14
+ from reference_audit.sources.arxiv import ArxivAdapter
15
+ from reference_audit.sources.base import SourceAdapter
16
+ from reference_audit.sources.clickhouse import build_clickhouse_adapters
17
+ from reference_audit.sources.crossref import CrossrefAdapter
18
+ from reference_audit.sources.dblp import DblpAdapter
19
+ from reference_audit.sources.google_books import GoogleBooksAdapter
20
+ from reference_audit.sources.openalex import OpenAlexAdapter
21
+ from reference_audit.sources.openlibrary import OpenLibraryAdapter
22
+ from reference_audit.sources.publisher import PublisherAdapter
23
+ from reference_audit.sources.render import ChromiumRenderer, find_browser
24
+ from reference_audit.sources.semantic_scholar import SemanticScholarAdapter
25
+ from reference_audit.sources.web import WebAdapter
26
+
27
+ # Reputable venues that legitimately mint no DOI — a no-DOI match here is not a defect (README 1.1).
28
+ VENUE_ALLOWLIST_NO_DOI = ("tmlr", "transactions on machine learning research")
29
+
30
+
31
+ def build_web_renderer(config: AuditConfig) -> ChromiumRenderer | None:
32
+ """A headless renderer for SPA shells, or None when rendering is disabled. The renderer self-
33
+ reports 'unavailable' at call time when no browser is found, so the funnel leaves such pages
34
+ unresolved rather than crying hallucination (see `sources.render`)."""
35
+ if not config.web_render_enabled:
36
+ return None
37
+ return ChromiumRenderer(
38
+ find_browser(config.web_render_browser_path),
39
+ timeout=config.web_render_timeout,
40
+ virtual_time_ms=config.web_render_virtual_time_ms,
41
+ )
42
+
43
+
44
+ def build_default_adapters(config: AuditConfig) -> list[SourceAdapter]:
45
+ mailto = config.resolved_mailto()
46
+ # Semantic Scholar, OpenAlex and DBLP come from their public APIs, or — with
47
+ # source_backend="clickhouse" — from a local ClickHouse mirror of the same databases.
48
+ if config.source_backend == "clickhouse":
49
+ mirrored: list[SourceAdapter] = build_clickhouse_adapters(config)
50
+ else:
51
+ mirrored = [
52
+ OpenAlexAdapter(mailto=mailto),
53
+ SemanticScholarAdapter(api_key=config.s2_api_key),
54
+ DblpAdapter(),
55
+ ]
56
+ return [
57
+ CrossrefAdapter(mailto=mailto),
58
+ *mirrored,
59
+ ArxivAdapter(),
60
+ OpenLibraryAdapter(email=config.openlibrary_email),
61
+ GoogleBooksAdapter(api_key=config.google_books_api_key),
62
+ PublisherAdapter(),
63
+ WebAdapter(render=build_web_renderer(config)),
64
+ ]
65
+
66
+
67
+ @dataclass
68
+ class Route:
69
+ id_adapters: list[SourceAdapter]
70
+ metadata_adapters: list[SourceAdapter]
71
+
72
+
73
+ def route_entry(entry: BibEntry, adapters: list[SourceAdapter]) -> Route:
74
+ """Pick adapters to query by-id and by-metadata for this entry."""
75
+ by_name = {a.name: a for a in adapters}
76
+
77
+ def present(*names: str) -> list[SourceAdapter]:
78
+ return [by_name[n] for n in names if n in by_name]
79
+
80
+ id_adapters: list[SourceAdapter] = []
81
+ if entry.ids.doi:
82
+ id_adapters += present("crossref", "openalex", "semantic_scholar")
83
+ if entry.ids.arxiv_id:
84
+ id_adapters += present("arxiv", "openalex", "semantic_scholar")
85
+ if entry.ids.isbn13:
86
+ id_adapters += present("openlibrary", "google_books")
87
+ # A cited Google Books volume id (books.google.…/books?id=…) resolves to exactly that volume —
88
+ # the authoritative key for a trade/book title both the article-centric search and Open
89
+ # Library's strict title match miss.
90
+ if entry.ids.google_books:
91
+ id_adapters += present("google_books")
92
+ # A cited OpenAlex Work id resolves to exactly that Work — the authoritative key for entries
93
+ # (notably books/trade titles) the article-centric metadata search and Crossref/Open Library
94
+ # miss. Routed for every entry type, since OpenAlex indexes books too.
95
+ if entry.ids.openalex:
96
+ id_adapters += present("openalex")
97
+ # NOTE: `publisher` (the DOI landing-page citation export) is deliberately NOT an identity
98
+ # source. It is queried only in the advisory enrichment pass — a bot-walled/blocked publisher
99
+ # must never set the `errored` flag and mask a hallucinated DOI as 'unresolved' vs 'no match'.
100
+
101
+ if entry.entry_type in (EntryType.BOOK, EntryType.INCOLLECTION):
102
+ metadata_adapters = present("openlibrary", "google_books", "crossref")
103
+ elif entry.entry_type == EntryType.MISC:
104
+ metadata_adapters = present("arxiv", "openalex", "crossref", "semantic_scholar", "dblp")
105
+ else: # ARTICLE / INPROCEEDINGS / UNKNOWN
106
+ # DBLP is the authority for the premier CS/ML venues (NeurIPS/ICLR/ICML), which mint no DOI
107
+ # and are thinly covered by the article-centric aggregators — decisive for @inproceedings.
108
+ metadata_adapters = present("crossref", "openalex", "semantic_scholar", "dblp")
109
+
110
+ # de-dup while preserving order
111
+ id_adapters = list(dict.fromkeys(id_adapters))
112
+ return Route(id_adapters=id_adapters, metadata_adapters=metadata_adapters)
113
+
114
+
115
+ def venue_allows_no_doi(venue: str) -> bool:
116
+ v = (venue or "").lower()
117
+ return any(allowed in v for allowed in VENUE_ALLOWLIST_NO_DOI)
@@ -0,0 +1,115 @@
1
+ """Headless-browser rendering for client-side-rendered (single-page-app) web pages.
2
+
3
+ Some cited URLs are JavaScript single-page apps: a plain HTTP GET returns only an empty app shell
4
+ (a spinner plus a ``<script>`` bundle), and the page's real title and text exist only *after* the
5
+ JavaScript runs. Reading such a shell as 'a different page' would be a false hallucination
6
+ (see `matching.webcheck`). This module renders the page in a headless Chromium and returns the
7
+ post-JavaScript DOM so the page's own metadata/text can be read like any other page.
8
+
9
+ Reliability-first (per the project's two-level reporting goal):
10
+ * if no browser is available, rendering is *unavailable* — the caller leaves the entry unresolved
11
+ and reports that a browser is needed, never guesses 'different page' from the empty shell;
12
+ * a render failure (timeout, crash, non-zero exit) is an *error* — reported, retried next run,
13
+ never cached and never read as 'absent'.
14
+
15
+ The renderer shells out to a system Chromium/Chrome binary (``--headless=new --dump-dom``) rather
16
+ than pulling in a Python browser-automation package and its ~150 MB browser download: the only thing
17
+ needed is a chromium-family binary on PATH (or an explicit path via config), and everything degrades
18
+ gracefully to 'unavailable' when none is present.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import shutil
25
+
26
+ # Candidate binary names searched on PATH, in preference order, when no explicit path is configured.
27
+ _BROWSER_NAMES = (
28
+ "chromium",
29
+ "chromium-browser",
30
+ "google-chrome",
31
+ "google-chrome-stable",
32
+ "chrome",
33
+ )
34
+
35
+
36
+ class RenderUnavailable(Exception):
37
+ """No headless browser is available to render the page (a configuration gap, not a failure)."""
38
+
39
+
40
+ class RenderError(Exception):
41
+ """A headless render was attempted but failed (timeout, crash, non-zero exit, empty output)."""
42
+
43
+
44
+ def find_browser(explicit_path: str | None = None) -> str | None:
45
+ """Resolve a chromium-family binary: the configured path if runnable, else the first on PATH."""
46
+ if explicit_path:
47
+ return explicit_path if shutil.which(explicit_path) or _is_executable(explicit_path) else None
48
+ for name in _BROWSER_NAMES:
49
+ found = shutil.which(name)
50
+ if found:
51
+ return found
52
+ return None
53
+
54
+
55
+ def _is_executable(path: str) -> bool:
56
+ import os
57
+
58
+ return os.path.isfile(path) and os.access(path, os.X_OK)
59
+
60
+
61
+ class ChromiumRenderer:
62
+ """An injectable async renderer: ``await renderer(url) -> (status, final_url, html)``.
63
+
64
+ Constructed once per run with a resolved browser path (or ``None`` — then every call raises
65
+ `RenderUnavailable`, so the funnel reports 'a browser is needed' rather than crashing). Matches
66
+ the ``fetch=`` stub contract of `WebAdapter`, so tests inject a plain callable instead.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ browser_path: str | None,
72
+ *,
73
+ timeout: float = 30.0,
74
+ virtual_time_ms: int = 15000,
75
+ ):
76
+ self.browser_path = browser_path
77
+ self.timeout = timeout
78
+ self.virtual_time_ms = virtual_time_ms
79
+
80
+ async def __call__(self, url: str) -> tuple[int, str, str]:
81
+ if not self.browser_path:
82
+ raise RenderUnavailable("no chromium-family browser found (set web_render_browser_path)")
83
+ cmd = [
84
+ self.browser_path,
85
+ "--headless=new",
86
+ "--disable-gpu",
87
+ "--no-sandbox",
88
+ "--hide-scrollbars",
89
+ "--disable-dev-shm-usage",
90
+ f"--virtual-time-budget={self.virtual_time_ms}",
91
+ "--dump-dom",
92
+ url,
93
+ ]
94
+ try:
95
+ proc = await asyncio.create_subprocess_exec(
96
+ *cmd,
97
+ stdout=asyncio.subprocess.PIPE,
98
+ stderr=asyncio.subprocess.PIPE,
99
+ )
100
+ except (OSError, ValueError) as exc: # binary vanished / not executable
101
+ raise RenderError(f"could not launch browser: {exc}") from exc
102
+ try:
103
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=self.timeout)
104
+ except (asyncio.TimeoutError, TimeoutError) as exc:
105
+ proc.kill()
106
+ await proc.wait()
107
+ raise RenderError(f"render timed out after {self.timeout}s") from exc
108
+ if proc.returncode != 0:
109
+ tail = (stderr or b"").decode("utf-8", "replace").strip().splitlines()[-1:] or [""]
110
+ raise RenderError(f"browser exited {proc.returncode}: {tail[0]}")
111
+ html = (stdout or b"").decode("utf-8", "replace")
112
+ if not html.strip():
113
+ raise RenderError("browser produced empty output")
114
+ # --dump-dom does not report redirects; the cited URL is the best 'final' URL we have.
115
+ return 200, url, html
@@ -0,0 +1,68 @@
1
+ """Semantic Scholar adapter — strong for CS/ML (zhang2018 CVPR, fu2023 NeurIPS, kumar2024)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from reference_audit.models import (
6
+ BibEntry,
7
+ EntryType,
8
+ Identifiers,
9
+ SourceQueryResult,
10
+ SourceRecord,
11
+ )
12
+ from reference_audit.sources.base import SourceAdapter
13
+ from reference_audit.sources.http import TransientHTTPError, get_json
14
+ from reference_audit.sources.normalize import s2_paper_to_record
15
+
16
+ _BASE = "https://api.semanticscholar.org/graph/v1/paper"
17
+ _FIELDS = "title,year,venue,authors,externalIds,citationCount,abstract"
18
+
19
+
20
+ class SemanticScholarAdapter(SourceAdapter):
21
+ name = "semantic_scholar"
22
+ handles = {EntryType.ARTICLE, EntryType.INPROCEEDINGS, EntryType.MISC}
23
+ rate_per_sec = 1.0 # public limit is strict; key (if set) is sent as a header
24
+
25
+ def __init__(self, api_key: str | None = None, **kw):
26
+ super().__init__(**kw)
27
+ self.api_key = api_key
28
+
29
+ def _headers(self) -> dict | None:
30
+ return {"x-api-key": self.api_key} if self.api_key else None
31
+
32
+ async def lookup_by_id(self, ids: Identifiers) -> SourceQueryResult:
33
+ key = None
34
+ if ids.doi:
35
+ key = f"DOI:{ids.doi}"
36
+ elif ids.arxiv_id:
37
+ key = f"ARXIV:{ids.arxiv_id}"
38
+ elif ids.pmid:
39
+ key = f"PMID:{ids.pmid}"
40
+ if key is None:
41
+ return SourceQueryResult(source=self.name, query_kind="id", records=[])
42
+ try:
43
+ status, data = await get_json(
44
+ self.client, self.rate_limiter, f"{_BASE}/{key}",
45
+ params={"fields": _FIELDS}, headers=self._headers(),
46
+ )
47
+ except TransientHTTPError as exc:
48
+ return SourceQueryResult(source=self.name, query_kind="id", error=str(exc))
49
+ if status == 404 or not data:
50
+ return SourceQueryResult(source=self.name, query_kind="id", records=[])
51
+ return SourceQueryResult(
52
+ source=self.name, query_kind="id", records=[s2_paper_to_record(data)]
53
+ )
54
+
55
+ async def search_by_metadata(self, entry: BibEntry, limit: int = 10) -> SourceQueryResult:
56
+ if not entry.title:
57
+ return SourceQueryResult(source=self.name, query_kind="metadata", records=[])
58
+ params = {"query": entry.title, "limit": limit, "fields": _FIELDS}
59
+ try:
60
+ _status, data = await get_json(
61
+ self.client, self.rate_limiter, f"{_BASE}/search",
62
+ params=params, headers=self._headers(),
63
+ )
64
+ except TransientHTTPError as exc:
65
+ return SourceQueryResult(source=self.name, query_kind="metadata", error=str(exc))
66
+ papers = (data or {}).get("data") or []
67
+ records: list[SourceRecord] = [s2_paper_to_record(p) for p in papers]
68
+ return SourceQueryResult(source=self.name, query_kind="metadata", records=records)
@@ -0,0 +1,31 @@
1
+ """The words of a cited title that a word index can match.
2
+
3
+ Shared by every title search that requires *all* words to match: DBLP's SPARQL word index and the
4
+ local ClickHouse full-text indexes. Under all-words matching a single word the index spells
5
+ differently hides the real record, so only words that are certain to be indexed the same way are
6
+ kept.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import re
13
+
14
+ # LaTeX that survives .bib decoding and is no word of the indexed title: inline math (`$\epsilon$`,
15
+ # which the databases store as "ε") and bare commands.
16
+ _LATEX_MATH_RE = re.compile(r"\$[^$]*\$")
17
+ _LATEX_CMD_RE = re.compile(r"\\[A-Za-z]+")
18
+ _WORD_RE = re.compile(r"[^\W_]+")
19
+
20
+
21
+ def title_search_words(title: str) -> list[str]:
22
+ """The searchable words of `title`, lowercased, in order.
23
+
24
+ HTML entities are decoded first (`Don&apos;t` would otherwise yield a phantom word "apos"), then
25
+ LaTeX math and commands are dropped. Only plain ASCII alphanumeric words are kept: a word with a
26
+ non-ASCII letter ("Kübler") or a LaTeX remnant could be indexed differently from how the `.bib`
27
+ spells it.
28
+ """
29
+ text = _LATEX_CMD_RE.sub(" ", _LATEX_MATH_RE.sub(" ", html.unescape(title)))
30
+ words = (w.lower() for w in _WORD_RE.findall(text))
31
+ return [w for w in words if w.isascii() and w.isalnum()]