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,256 @@
1
+ """Web-artifact resolver for URL-only ``@misc`` references (a blog post, software/project page,
2
+ dataset, documentation page — e.g. ``mordvintsev2022particle``).
3
+
4
+ A URL-only ``@misc`` names no scholarly identifier, so the DOI/ISBN/arXiv matcher cannot reach it
5
+ and the scholarly databases do not index it. This adapter instead fetches the cited page itself and
6
+ extracts the metadata the page declares about itself in its ``<head>`` — Open Graph
7
+ (``og:*``), Twitter cards, Highwire/Google-Scholar ``citation_*``, Dublin Core (``dc.*``), the
8
+ ``<title>``, and ``<meta name="author">`` — so the page's *own* claimed title/authors can be checked
9
+ against the ``.bib`` entry (the deterministic step 2), with the page text kept for an LLM fallback
10
+ (step 3, in `matching.webcheck`).
11
+
12
+ Reliability-first (per the project's two-level reporting goal):
13
+ * a transport failure, timeout, or bot-wall (a non-404 4xx / 5xx) is surfaced as an ``error`` —
14
+ reported, retried next run, never read as 'the page says no' and never cached;
15
+ * a 404/410 is a *dead link* — a real, cacheable finding, returned as a record flagged ``dead``;
16
+ * a live page with no usable metadata yields an empty-titled record, so the caller escalates to
17
+ the LLM rather than guessing a verdict from absence.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ from bs4 import BeautifulSoup
25
+
26
+ from reference_audit.models import (
27
+ EntryType,
28
+ Identifiers,
29
+ SourceQueryResult,
30
+ SourceRecord,
31
+ )
32
+ from reference_audit.sources.base import SourceAdapter
33
+ from reference_audit.sources.http import TransientHTTPError, get_html
34
+ from reference_audit.sources.render import RenderError, RenderUnavailable
35
+
36
+ # Metadata keys carrying the page's self-declared title, in preference order. Keys are matched
37
+ # case-folded against either a <meta> tag's `property` or its `name` attribute.
38
+ _TITLE_KEYS = ("og:title", "twitter:title", "citation_title", "dc.title", "dcterms.title")
39
+ # Author keys; several tags may repeat (one <meta> per author) — all are collected.
40
+ _AUTHOR_KEYS = ("citation_author", "article:author", "author", "dc.creator", "dcterms.creator")
41
+ _DESC_KEYS = ("og:description", "description", "twitter:description", "dc.description")
42
+ _SITE_KEYS = ("og:site_name", "application-name")
43
+ _DATE_KEYS = (
44
+ "citation_publication_date",
45
+ "citation_date",
46
+ "article:published_time",
47
+ "dc.date",
48
+ "dcterms.date",
49
+ "og:updated_time",
50
+ )
51
+
52
+ # Cap the visible-text slice we carry for the LLM fallback (the model never needs the whole page).
53
+ _TEXT_LIMIT = 8000
54
+ _YEAR_RE = re.compile(r"(1[5-9]\d{2}|20\d{2}|21\d{2})")
55
+
56
+ # Below this much visible text a 200 page is suspected to be an un-rendered single-page-app shell
57
+ # (a spinner + a script bundle, with the real content injected only after JavaScript runs).
58
+ _SHELL_TEXT_THRESHOLD = 200
59
+ # Markers that, on an otherwise text-empty page, identify a client-side-rendered app shell.
60
+ _SPA_MARKERS = (
61
+ 'id="app"',
62
+ "id='app'",
63
+ 'id="root"',
64
+ "id='root'",
65
+ "<app-root",
66
+ "ng-version",
67
+ "data-reactroot",
68
+ "__nuxt__",
69
+ 'type="module"',
70
+ "enable javascript",
71
+ "requires javascript",
72
+ )
73
+
74
+
75
+ def _looks_unrendered(html: str, record: SourceRecord) -> bool:
76
+ """True when a 200 page carries almost no readable text yet shows single-page-app markers — i.e.
77
+ the served HTML is an empty shell and the real content loads only via JavaScript. Gating on the
78
+ near-empty text keeps ordinary content pages (which legitimately use modules/roots) out."""
79
+ text = (record.raw or {}).get("text") or ""
80
+ if len(text) >= _SHELL_TEXT_THRESHOLD:
81
+ return False
82
+ low = html.lower()
83
+ return any(marker in low for marker in _SPA_MARKERS)
84
+
85
+
86
+ def _collect_meta(soup: BeautifulSoup) -> dict[str, list[str]]:
87
+ """Map each case-folded meta key (property OR name) to its non-empty content values, in order."""
88
+ by_key: dict[str, list[str]] = {}
89
+ for tag in soup.find_all("meta"):
90
+ content = (tag.get("content") or "").strip()
91
+ if not content:
92
+ continue
93
+ key = (tag.get("property") or tag.get("name") or "").strip().lower()
94
+ if key:
95
+ by_key.setdefault(key, []).append(content)
96
+ return by_key
97
+
98
+
99
+ def _first(by_key: dict[str, list[str]], keys: tuple[str, ...]) -> str:
100
+ for k in keys:
101
+ if by_key.get(k):
102
+ return by_key[k][0]
103
+ return ""
104
+
105
+
106
+ def _all(by_key: dict[str, list[str]], keys: tuple[str, ...]) -> list[str]:
107
+ out: list[str] = []
108
+ for k in keys:
109
+ out.extend(by_key.get(k, []))
110
+ # de-dup while preserving order (the same author can appear under several keys)
111
+ seen: set[str] = set()
112
+ deduped = []
113
+ for a in out:
114
+ if a not in seen:
115
+ seen.add(a)
116
+ deduped.append(a)
117
+ return deduped
118
+
119
+
120
+ def _meta_year(by_key: dict[str, list[str]]) -> int | None:
121
+ raw = _first(by_key, _DATE_KEYS)
122
+ m = _YEAR_RE.search(raw)
123
+ return int(m.group(1)) if m else None
124
+
125
+
126
+ def extract_web_metadata(html: str, final_url: str) -> SourceRecord:
127
+ """Parse a fetched HTML page into a `SourceRecord` describing what the page says about itself.
128
+
129
+ Title falls back to the ``<title>`` element when no metadata title is present; an empty title
130
+ signals the caller to escalate to the LLM (the page declared nothing checkable).
131
+ """
132
+ soup = BeautifulSoup(html or "", "html.parser")
133
+ by_key = _collect_meta(soup)
134
+
135
+ title = _first(by_key, _TITLE_KEYS)
136
+ if not title and soup.title and soup.title.string:
137
+ title = soup.title.string.strip()
138
+ authors = _all(by_key, _AUTHOR_KEYS)
139
+ description = _first(by_key, _DESC_KEYS)
140
+ site_name = _first(by_key, _SITE_KEYS)
141
+ year = _meta_year(by_key)
142
+
143
+ for junk in soup(["script", "style", "noscript", "template"]):
144
+ junk.decompose()
145
+ text = soup.get_text(" ", strip=True)[:_TEXT_LIMIT]
146
+
147
+ return SourceRecord(
148
+ source="web",
149
+ source_native_id=final_url,
150
+ title=" ".join(title.split()),
151
+ authors=authors,
152
+ year=year,
153
+ ids=Identifiers(url=final_url),
154
+ raw={
155
+ "status": 200,
156
+ "dead": False,
157
+ "description": description,
158
+ "site_name": site_name,
159
+ "text": text,
160
+ },
161
+ )
162
+
163
+
164
+ def _dead_record(url: str, status: int) -> SourceRecord:
165
+ return SourceRecord(
166
+ source="web",
167
+ source_native_id=url,
168
+ ids=Identifiers(url=url),
169
+ raw={"status": status, "dead": True},
170
+ )
171
+
172
+
173
+ class WebAdapter(SourceAdapter):
174
+ """Fetch the cited URL of a web ``@misc`` and extract its self-declared HTML metadata.
175
+
176
+ Not a scholarly source: it is queried only on the explicit web-check path (never via
177
+ `route_entry`'s by-id/by-metadata gathering), so its `lookup_by_id`/`search_by_metadata` stay
178
+ the inherited no-ops. `fetch=...` injects a stub fetcher (async ``(url) -> (status, final_url,
179
+ html)``) for tests, mirroring `PublisherAdapter`'s `export_fetch`.
180
+ """
181
+
182
+ name = "web"
183
+ handles = {EntryType.MISC}
184
+ rate_per_sec = 2.0 # be gentle on arbitrary third-party sites
185
+
186
+ def __init__(self, *, fetch=None, render=None, **kwargs):
187
+ super().__init__(**kwargs)
188
+ self._fetch = fetch
189
+ # `render` is an async ``(url) -> (status, final_url, html)`` that runs the page in a headless
190
+ # browser; None means rendering is not configured (a shell is then left unresolved, never
191
+ # read as 'a different page'). Injected as a stub in tests, wired to `render.ChromiumRenderer`
192
+ # in production. See `sources.render`.
193
+ self._render = render
194
+
195
+ async def _get(self, url: str) -> tuple[int, str, str]:
196
+ if self._fetch is not None:
197
+ return await self._fetch(url)
198
+ return await get_html(self.client, self.rate_limiter, url)
199
+
200
+ async def fetch_page(self, url: str) -> SourceQueryResult:
201
+ if not url:
202
+ return SourceQueryResult(source=self.name, query_kind="web", records=[])
203
+ try:
204
+ status, final_url, html = await self._get(url)
205
+ except TransientHTTPError as exc:
206
+ # Transport error / timeout / bot-wall (403) / 5xx after retries — a human-reachable page
207
+ # we could not auto-fetch. Reported as an error, retried next run, never read as 'absent'.
208
+ return SourceQueryResult(
209
+ source=self.name,
210
+ query_kind="web",
211
+ error=f"could not fetch the cited page ({exc}): {url}",
212
+ )
213
+ landed = final_url or url
214
+ if status in (404, 410):
215
+ return SourceQueryResult(
216
+ source=self.name, query_kind="web", records=[_dead_record(landed, status)]
217
+ )
218
+
219
+ record = extract_web_metadata(html, landed)
220
+ if _looks_unrendered(html, record):
221
+ # The served HTML is a client-side-rendered shell — the real content loads only via JS.
222
+ # Render it in a headless browser so the page can be read like any other; a render
223
+ # failure becomes an error (retried, uncached), and 'no browser' / 'still empty' is
224
+ # marked so the funnel leaves the entry unresolved rather than crying hallucination.
225
+ rendered = await self._maybe_render(url, landed)
226
+ if isinstance(rendered, SourceQueryResult):
227
+ return rendered # a render *error* — propagate (uncached, retried next run)
228
+ record = rendered
229
+ else:
230
+ record.raw["render"] = "not_needed"
231
+ return SourceQueryResult(source=self.name, query_kind="web", records=[record])
232
+
233
+ async def _maybe_render(self, url: str, landed: str) -> SourceRecord | SourceQueryResult:
234
+ """Render an SPA shell. Returns the rendered `SourceRecord`, or a `SourceQueryResult` error
235
+ when the render itself failed (so the caller propagates it as a retryable error, not a page).
236
+ The returned record carries ``raw['render']``: ``rendered`` (read successfully), ``unavailable``
237
+ (no browser configured), or ``rendered_empty`` (still a shell even after rendering)."""
238
+ if self._render is None:
239
+ shell = extract_web_metadata("", landed)
240
+ shell.raw["render"] = "unavailable"
241
+ return shell
242
+ try:
243
+ status, r_final, r_html = await self._render(url)
244
+ except RenderUnavailable:
245
+ shell = extract_web_metadata("", landed)
246
+ shell.raw["render"] = "unavailable"
247
+ return shell
248
+ except RenderError as exc:
249
+ return SourceQueryResult(
250
+ source=self.name,
251
+ query_kind="web",
252
+ error=f"cited page needs a browser to render and rendering failed ({exc}): {url}",
253
+ )
254
+ record = extract_web_metadata(r_html, r_final or landed)
255
+ record.raw["render"] = "rendered_empty" if _looks_unrendered(r_html, record) else "rendered"
256
+ return record
@@ -0,0 +1,125 @@
1
+ """Step 2: better-version detection.
2
+
3
+ For each exactly-matched artifact, report whether the .bib entry is citing a
4
+ suboptimal version:
5
+ - Paper citing an arXiv preprint when a real published DOI is known.
6
+ - Book with no edition specifier (or a low edition number) when multiple
7
+ editions are known to exist in OpenLibrary.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from reference_audit.models import BibEntry, EntryType, MatchedArtifact
15
+ from reference_audit.parsing.identifiers import arxiv_submission_year, extract_arxiv_id
16
+
17
+ _PREPRINT_DOI_PREFIX = "10.48550/arxiv"
18
+
19
+ _ORDINALS: dict[str, int] = {
20
+ "first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5,
21
+ "sixth": 6, "seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10,
22
+ }
23
+
24
+ _PAPER_TYPES = {EntryType.ARTICLE, EntryType.INPROCEEDINGS, EntryType.MISC}
25
+ _BOOK_TYPES = {EntryType.BOOK, EntryType.INCOLLECTION}
26
+
27
+
28
+ def _is_preprint_doi(doi: str) -> bool:
29
+ return doi.lower().startswith(_PREPRINT_DOI_PREFIX)
30
+
31
+
32
+ def _entry_cites_preprint(entry: BibEntry) -> bool:
33
+ """True if the entry's primary identifier is a preprint (arXiv) reference."""
34
+ if entry.ids.doi:
35
+ return _is_preprint_doi(entry.ids.doi)
36
+ return bool(entry.ids.arxiv_id)
37
+
38
+
39
+ def cited_arxiv_id(entry: BibEntry) -> str | None:
40
+ """The arXiv id the entry cites, given directly (`eprint`) or via a 10.48550/arXiv.* DOI."""
41
+ if entry.ids.arxiv_id:
42
+ return entry.ids.arxiv_id
43
+ if entry.ids.doi and _is_preprint_doi(entry.ids.doi):
44
+ return extract_arxiv_id(None, fallback_text=entry.ids.doi)
45
+ return None
46
+
47
+
48
+ def _find_published_doi(artifact: MatchedArtifact) -> str | None:
49
+ """Return the first real published DOI found across all artifact versions."""
50
+ for record in artifact.versions:
51
+ doi = record.ids.doi
52
+ if doi and not record.is_preprint and not _is_preprint_doi(doi):
53
+ return doi
54
+ return None
55
+
56
+
57
+ def _parse_edition_num(s: str) -> int | None:
58
+ """Parse '2', '2nd', 'Second edition', etc. to an int. Returns None if unparseable."""
59
+ s = s.strip()
60
+ m = re.match(r"^(\d+)", s)
61
+ if m:
62
+ return int(m.group(1))
63
+ first_word = s.lower().split()[0] if s else ""
64
+ return _ORDINALS.get(first_word)
65
+
66
+
67
+ def _newer_preprint_version_note(entry: BibEntry, artifact: MatchedArtifact) -> str | None:
68
+ """A preprint with no published version may still have been *updated*: the cited year is the
69
+ original arXiv submission year (encoded in the id), so a later canonical year means a newer
70
+ arXiv version exists. Gated on the cited year actually being the original-version year — a
71
+ genuinely wrong year is left to the field check, not mislabeled as 'newer version available'.
72
+ """
73
+ arxiv_id = cited_arxiv_id(entry)
74
+ if arxiv_id is None or entry.year is None:
75
+ return None
76
+ submitted = arxiv_submission_year(arxiv_id)
77
+ if submitted is None or entry.year != submitted:
78
+ return None
79
+ best = artifact.best_record
80
+ latest = best.year if best is not None else None
81
+ if latest is not None and latest > entry.year:
82
+ return (
83
+ f"citing the original arXiv version ({entry.year}); a newer version "
84
+ f"({latest}) is available — refresh the citation metadata"
85
+ )
86
+ return None
87
+
88
+
89
+ def better_version_notes(entry: BibEntry, artifact: MatchedArtifact) -> list[str]:
90
+ """Return upgrade notices for this entry (empty list means best version is already cited).
91
+
92
+ Called after step-1 verdict is exactly_one; the artifact is confirmed to
93
+ correspond to the entry so all records in artifact.versions are relevant.
94
+ """
95
+ notes: list[str] = []
96
+
97
+ if entry.entry_type in _PAPER_TYPES and _entry_cites_preprint(entry):
98
+ pub_doi = _find_published_doi(artifact)
99
+ if pub_doi:
100
+ notes.append(f"citing preprint; published version available: doi:{pub_doi}")
101
+ else:
102
+ newer = _newer_preprint_version_note(entry, artifact)
103
+ if newer:
104
+ notes.append(newer)
105
+
106
+ if entry.entry_type in _BOOK_TYPES:
107
+ best = artifact.best_record
108
+ # best.edition from OpenLibrary = edition_count (total editions known).
109
+ # If it is > 1 the work has multiple editions; verify the cited one is current.
110
+ if best is not None and best.edition is not None and best.edition > 1:
111
+ edition_str = entry.raw_fields.get("edition", "")
112
+ if not edition_str:
113
+ notes.append(
114
+ f"{best.edition} editions known in OpenLibrary; "
115
+ "add edition= field and verify you cite the latest"
116
+ )
117
+ else:
118
+ cited = _parse_edition_num(edition_str)
119
+ if cited is not None and best.edition > cited:
120
+ notes.append(
121
+ f"citing edition {cited}; {best.edition} editions known in OpenLibrary"
122
+ " — a later edition may be available"
123
+ )
124
+
125
+ return notes