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,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'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()]
|