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,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
|