refaudit 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.
- refaudit/__init__.py +39 -0
- refaudit/__main__.py +3 -0
- refaudit/bibtex.py +133 -0
- refaudit/cache.py +86 -0
- refaudit/checker.py +205 -0
- refaudit/cli.py +167 -0
- refaudit/http.py +229 -0
- refaudit/models.py +136 -0
- refaudit/normalize.py +136 -0
- refaudit/ratelimit.py +103 -0
- refaudit/resolvers/__init__.py +44 -0
- refaudit/resolvers/arxiv.py +91 -0
- refaudit/resolvers/base.py +81 -0
- refaudit/resolvers/crossref.py +113 -0
- refaudit/resolvers/openalex.py +112 -0
- refaudit/xmlsafe.py +85 -0
- refaudit-0.1.0.dist-info/METADATA +223 -0
- refaudit-0.1.0.dist-info/RECORD +21 -0
- refaudit-0.1.0.dist-info/WHEEL +4 -0
- refaudit-0.1.0.dist-info/entry_points.txt +2 -0
- refaudit-0.1.0.dist-info/licenses/LICENSE +21 -0
refaudit/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""refaudit -- verify bibliography entries against external indexes.
|
|
2
|
+
|
|
3
|
+
Built for the case where a venue runs an automated check for hallucinated or
|
|
4
|
+
malformed references and a false entry costs you a desk reject.
|
|
5
|
+
|
|
6
|
+
from refaudit import Checker, parse_file, default_resolvers
|
|
7
|
+
|
|
8
|
+
entries = parse_file("refs.bib")
|
|
9
|
+
checker = Checker(default_resolvers("you@example.org"))
|
|
10
|
+
for result in checker.check_all(entries):
|
|
11
|
+
print(result.key, result.verdict.value)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .bibtex import cited_keys, find_tex, parse_file, parse_string
|
|
15
|
+
from .cache import Cache
|
|
16
|
+
from .checker import Checker, Thresholds
|
|
17
|
+
from .models import CheckResult, Entry, Found, NotFound, Record, Unavailable, Verdict
|
|
18
|
+
from .resolvers import default_resolvers
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Cache",
|
|
24
|
+
"CheckResult",
|
|
25
|
+
"Checker",
|
|
26
|
+
"Entry",
|
|
27
|
+
"Found",
|
|
28
|
+
"NotFound",
|
|
29
|
+
"Record",
|
|
30
|
+
"Thresholds",
|
|
31
|
+
"Unavailable",
|
|
32
|
+
"Verdict",
|
|
33
|
+
"__version__",
|
|
34
|
+
"cited_keys",
|
|
35
|
+
"default_resolvers",
|
|
36
|
+
"find_tex",
|
|
37
|
+
"parse_file",
|
|
38
|
+
"parse_string",
|
|
39
|
+
]
|
refaudit/__main__.py
ADDED
refaudit/bibtex.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""A small BibTeX reader.
|
|
2
|
+
|
|
3
|
+
Only what a checker needs: entry type, cite key, and fields as raw strings. It
|
|
4
|
+
does not expand ``@string`` macros or resolve crossrefs, because a checker that
|
|
5
|
+
silently mis-parses is worse than one that reports what it saw.
|
|
6
|
+
|
|
7
|
+
Brace matching is done by walking the source rather than with a regular
|
|
8
|
+
expression, since BibTeX values nest braces freely (``title = {The {LLM} Era}``)
|
|
9
|
+
and a regex will either stop early or run away.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .models import Entry
|
|
18
|
+
|
|
19
|
+
_ENTRY_START = re.compile(r"@(?P<type>\w+)\s*[{(]\s*(?P<key>[^,\s{}]+)\s*,", re.MULTILINE)
|
|
20
|
+
_FIELD = re.compile(r"(\w+)\s*=\s*", re.MULTILINE)
|
|
21
|
+
|
|
22
|
+
# Entry types that are not published records and have no external index.
|
|
23
|
+
NON_ARCHIVAL_TYPES = frozenset({"misc", "online", "manual", "unpublished", "software"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _match_brace(src: str, open_idx: int) -> int:
|
|
27
|
+
"""Index of the brace closing the one at ``open_idx``; ``len(src)`` if unbalanced."""
|
|
28
|
+
depth = 0
|
|
29
|
+
i = open_idx
|
|
30
|
+
n = len(src)
|
|
31
|
+
while i < n:
|
|
32
|
+
c = src[i]
|
|
33
|
+
if c == "\\": # skip escaped char
|
|
34
|
+
i += 2
|
|
35
|
+
continue
|
|
36
|
+
if c == "{":
|
|
37
|
+
depth += 1
|
|
38
|
+
elif c == "}":
|
|
39
|
+
depth -= 1
|
|
40
|
+
if depth == 0:
|
|
41
|
+
return i
|
|
42
|
+
i += 1
|
|
43
|
+
return n
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_value(src: str, i: int) -> tuple[str, int]:
|
|
47
|
+
"""Read a field value starting at ``i``; returns (value, index after it)."""
|
|
48
|
+
n = len(src)
|
|
49
|
+
while i < n and src[i].isspace():
|
|
50
|
+
i += 1
|
|
51
|
+
if i >= n:
|
|
52
|
+
return "", i
|
|
53
|
+
if src[i] == "{":
|
|
54
|
+
end = _match_brace(src, i)
|
|
55
|
+
return src[i + 1:end], min(end + 1, n)
|
|
56
|
+
if src[i] == '"':
|
|
57
|
+
j = i + 1
|
|
58
|
+
while j < n:
|
|
59
|
+
if src[j] == "\\":
|
|
60
|
+
j += 2
|
|
61
|
+
continue
|
|
62
|
+
if src[j] == '"':
|
|
63
|
+
break
|
|
64
|
+
j += 1
|
|
65
|
+
return src[i + 1:j], min(j + 1, n)
|
|
66
|
+
# bare value (number, macro name) up to the next comma or closing brace
|
|
67
|
+
j = i
|
|
68
|
+
while j < n and src[j] not in ",}\n":
|
|
69
|
+
j += 1
|
|
70
|
+
return src[i:j].strip(), j
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clean(value: str) -> str:
|
|
74
|
+
return re.sub(r"\s+", " ", value).strip()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_string(src: str) -> list[Entry]:
|
|
78
|
+
entries: list[Entry] = []
|
|
79
|
+
for m in _ENTRY_START.finditer(src):
|
|
80
|
+
etype = m.group("type").lower()
|
|
81
|
+
if etype in {"comment", "preamble", "string"}:
|
|
82
|
+
continue
|
|
83
|
+
body_start = src.index("{", m.start()) if "{" in src[m.start():m.end()] else m.end()
|
|
84
|
+
body_end = _match_brace(src, body_start)
|
|
85
|
+
body = src[m.end():body_end]
|
|
86
|
+
|
|
87
|
+
fields: dict[str, str] = {}
|
|
88
|
+
i = 0
|
|
89
|
+
while True:
|
|
90
|
+
fm = _FIELD.search(body, i)
|
|
91
|
+
if not fm:
|
|
92
|
+
break
|
|
93
|
+
value, i = _read_value(body, fm.end())
|
|
94
|
+
fields[fm.group(1).lower()] = _clean(value)
|
|
95
|
+
entries.append(Entry(key=m.group("key").strip(), entry_type=etype, fields=fields))
|
|
96
|
+
return entries
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_file(path: str | Path) -> list[Entry]:
|
|
100
|
+
return parse_string(Path(path).read_text(encoding="utf-8", errors="replace"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_CITE = re.compile(r"\\[a-zA-Z]*cite[a-zA-Z]*\*?\s*(?:\[[^\]]*\]\s*)*\{([^}]*)\}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cited_keys(tex_paths: list[Path]) -> set[str]:
|
|
107
|
+
"""Cite keys appearing in live (non-commented) LaTeX.
|
|
108
|
+
|
|
109
|
+
Uncited entries never reach the reference list, so checking them is optional
|
|
110
|
+
work; separating them also keeps the report focused on what a reviewer sees.
|
|
111
|
+
"""
|
|
112
|
+
keys: set[str] = set()
|
|
113
|
+
for path in tex_paths:
|
|
114
|
+
try:
|
|
115
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
116
|
+
except OSError:
|
|
117
|
+
continue
|
|
118
|
+
for line in text.splitlines():
|
|
119
|
+
stripped = line.lstrip()
|
|
120
|
+
if stripped.startswith("%"):
|
|
121
|
+
continue
|
|
122
|
+
# drop trailing comments, honouring \%
|
|
123
|
+
line = re.sub(r"(?<!\\)%.*$", "", line)
|
|
124
|
+
for m in _CITE.finditer(line):
|
|
125
|
+
keys.update(k.strip() for k in m.group(1).split(",") if k.strip())
|
|
126
|
+
return keys
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_tex(root: str | Path) -> list[Path]:
|
|
130
|
+
root = Path(root)
|
|
131
|
+
if root.is_file():
|
|
132
|
+
return [root]
|
|
133
|
+
return sorted(p for p in root.rglob("*.tex") if p.is_file())
|
refaudit/cache.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Versioned, on-disk cache of resolver results.
|
|
2
|
+
|
|
3
|
+
A run over a few hundred references takes minutes because it is deliberately
|
|
4
|
+
slow, so it must be resumable. Three properties matter:
|
|
5
|
+
|
|
6
|
+
* **Versioned.** The cache key includes a schema version; changing how records
|
|
7
|
+
are interpreted invalidates old entries instead of silently mixing them.
|
|
8
|
+
* **Only successes are cached.** A failure usually means a service was busy, and
|
|
9
|
+
caching that would bake a transient outage into every later run.
|
|
10
|
+
* **Written atomically.** A run interrupted mid-write must not leave a truncated
|
|
11
|
+
JSON file that breaks the next run.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Cache:
|
|
27
|
+
def __init__(self, path: str | Path, ttl_days: float = 90.0) -> None:
|
|
28
|
+
self.path = Path(path)
|
|
29
|
+
self.ttl = ttl_days * 86400.0
|
|
30
|
+
self._data: dict[str, dict[str, Any]] = {}
|
|
31
|
+
self._dirty = False
|
|
32
|
+
self._load()
|
|
33
|
+
|
|
34
|
+
def _load(self) -> None:
|
|
35
|
+
if not self.path.exists():
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
blob = json.loads(self.path.read_text(encoding="utf-8"))
|
|
39
|
+
except (OSError, json.JSONDecodeError):
|
|
40
|
+
return # corrupt or unreadable: start clean rather than crash
|
|
41
|
+
if blob.get("schema") != SCHEMA_VERSION:
|
|
42
|
+
return
|
|
43
|
+
self._data = blob.get("entries", {})
|
|
44
|
+
|
|
45
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
46
|
+
item = self._data.get(key)
|
|
47
|
+
if not item:
|
|
48
|
+
return None
|
|
49
|
+
if self.ttl and time.time() - item.get("stored_at", 0) > self.ttl:
|
|
50
|
+
self._data.pop(key, None)
|
|
51
|
+
self._dirty = True
|
|
52
|
+
return None
|
|
53
|
+
return item.get("value")
|
|
54
|
+
|
|
55
|
+
def put(self, key: str, value: dict[str, Any]) -> None:
|
|
56
|
+
self._data[key] = {"stored_at": time.time(), "value": value}
|
|
57
|
+
self._dirty = True
|
|
58
|
+
|
|
59
|
+
def flush(self) -> None:
|
|
60
|
+
if not self._dirty:
|
|
61
|
+
return
|
|
62
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
payload = json.dumps(
|
|
64
|
+
{"schema": SCHEMA_VERSION, "entries": self._data}, ensure_ascii=False
|
|
65
|
+
)
|
|
66
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), suffix=".tmp")
|
|
67
|
+
try:
|
|
68
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
69
|
+
fh.write(payload)
|
|
70
|
+
fh.flush()
|
|
71
|
+
os.fsync(fh.fileno())
|
|
72
|
+
os.replace(tmp, self.path) # atomic on POSIX and Windows
|
|
73
|
+
self._dirty = False
|
|
74
|
+
finally:
|
|
75
|
+
if os.path.exists(tmp):
|
|
76
|
+
try:
|
|
77
|
+
os.unlink(tmp)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# PYI034 prefers Self, which is 3.11+; this package supports 3.10.
|
|
82
|
+
def __enter__(self) -> Cache: # noqa: PYI034
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, *exc: object) -> None:
|
|
86
|
+
self.flush()
|
refaudit/checker.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Orchestration: try resolvers in order, then judge the match.
|
|
2
|
+
|
|
3
|
+
Two rules govern the whole design.
|
|
4
|
+
|
|
5
|
+
**A resolver that could not be reached produces no verdict.** If every resolver
|
|
6
|
+
that applies to an entry came back ``Unavailable``, the result is ``UNVERIFIED``
|
|
7
|
+
and the report says so separately from real findings. This is what stops the
|
|
8
|
+
tool crying wolf when a service is rate-limiting the network.
|
|
9
|
+
|
|
10
|
+
**Identifier evidence outranks title evidence.** A DOI that Crossref says does
|
|
11
|
+
not exist is a finding. A title search that returns something different is only
|
|
12
|
+
a finding if we had no identifier to go on -- otherwise it is just a weak search
|
|
13
|
+
result, and treating it as a mismatch would flag every arXiv-only workshop paper
|
|
14
|
+
that Crossref happens not to index.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from .bibtex import NON_ARCHIVAL_TYPES
|
|
23
|
+
from .cache import Cache
|
|
24
|
+
from .models import (
|
|
25
|
+
CheckResult,
|
|
26
|
+
Entry,
|
|
27
|
+
Found,
|
|
28
|
+
NotFound,
|
|
29
|
+
Record,
|
|
30
|
+
Unavailable,
|
|
31
|
+
Verdict,
|
|
32
|
+
)
|
|
33
|
+
from .normalize import clean_arxiv_id, clean_doi, first_surname, similarity, surnames_match
|
|
34
|
+
from .resolvers.base import Resolver
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Thresholds:
|
|
39
|
+
title_match: float = 0.75 # at or above this, the titles are the same work
|
|
40
|
+
title_suspect: float = 0.45 # below this, a resolved record is clearly a different paper
|
|
41
|
+
year_slack: int = 1 # preprint/publication years legitimately differ by one
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DEFAULT_THRESHOLDS = Thresholds()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Checker:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
resolvers: Sequence[Resolver],
|
|
51
|
+
*,
|
|
52
|
+
cache: Cache | None = None,
|
|
53
|
+
thresholds: Thresholds = DEFAULT_THRESHOLDS,
|
|
54
|
+
) -> None:
|
|
55
|
+
if not resolvers:
|
|
56
|
+
raise ValueError("at least one resolver is required")
|
|
57
|
+
self.resolvers = list(resolvers)
|
|
58
|
+
self.cache = cache
|
|
59
|
+
self.thresholds = thresholds
|
|
60
|
+
|
|
61
|
+
# -- public ------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def check(self, entry: Entry, *, cited: bool | None = None) -> CheckResult:
|
|
64
|
+
cached = self.cache.get(self._cache_key(entry)) if self.cache else None
|
|
65
|
+
if cached:
|
|
66
|
+
return CheckResult(
|
|
67
|
+
key=entry.key,
|
|
68
|
+
verdict=Verdict(cached["verdict"]),
|
|
69
|
+
entry_title=entry.title,
|
|
70
|
+
found_title=cached.get("found_title", ""),
|
|
71
|
+
source=cached.get("source", ""),
|
|
72
|
+
similarity=cached.get("similarity"),
|
|
73
|
+
note=cached.get("note", ""),
|
|
74
|
+
cited=cited,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
result = self._check_uncached(entry, cited)
|
|
78
|
+
|
|
79
|
+
# Only cache outcomes that reflect the entry, not our connectivity.
|
|
80
|
+
if self.cache and result.verdict is not Verdict.UNVERIFIED:
|
|
81
|
+
self.cache.put(
|
|
82
|
+
self._cache_key(entry),
|
|
83
|
+
{
|
|
84
|
+
"verdict": result.verdict.value,
|
|
85
|
+
"found_title": result.found_title,
|
|
86
|
+
"source": result.source,
|
|
87
|
+
"similarity": result.similarity,
|
|
88
|
+
"note": result.note,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
def check_all(self, entries: Iterable[Entry], cited: set[str] | None = None):
|
|
94
|
+
for entry in entries:
|
|
95
|
+
yield self.check(entry, cited=(entry.key in cited) if cited is not None else None)
|
|
96
|
+
|
|
97
|
+
# -- internals ---------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _cache_key(entry: Entry) -> str:
|
|
101
|
+
# Include the fields we compare, so editing an entry re-checks it.
|
|
102
|
+
return "|".join([
|
|
103
|
+
entry.key,
|
|
104
|
+
clean_doi(entry.doi),
|
|
105
|
+
clean_arxiv_id(entry.arxiv_id),
|
|
106
|
+
entry.title.strip().lower()[:200],
|
|
107
|
+
str(entry.year or ""),
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
def _check_uncached(self, entry: Entry, cited: bool | None) -> CheckResult:
|
|
111
|
+
applicable = [r for r in self.resolvers if r.can_handle(entry)]
|
|
112
|
+
|
|
113
|
+
has_identifier = bool(clean_doi(entry.doi) or clean_arxiv_id(entry.arxiv_id))
|
|
114
|
+
if not applicable:
|
|
115
|
+
if entry.entry_type in NON_ARCHIVAL_TYPES and not has_identifier:
|
|
116
|
+
return CheckResult(entry.key, Verdict.SKIPPED, entry.title,
|
|
117
|
+
note=f"@{entry.entry_type} with no identifier", cited=cited)
|
|
118
|
+
return CheckResult(entry.key, Verdict.NOT_FOUND, entry.title,
|
|
119
|
+
note="no resolver could handle this entry", cited=cited)
|
|
120
|
+
|
|
121
|
+
any_unavailable: list[str] = []
|
|
122
|
+
authoritative_absence: list[str] = []
|
|
123
|
+
|
|
124
|
+
for resolver in applicable:
|
|
125
|
+
outcome = resolver.resolve(entry)
|
|
126
|
+
|
|
127
|
+
if isinstance(outcome, Unavailable):
|
|
128
|
+
any_unavailable.append(f"{outcome.source}: {outcome.reason[:60]}")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if isinstance(outcome, NotFound):
|
|
132
|
+
# A DOI the registrar does not know is a real problem. A failed
|
|
133
|
+
# title search is not, on its own.
|
|
134
|
+
if resolver.name.endswith(":doi") and clean_doi(entry.doi):
|
|
135
|
+
return CheckResult(entry.key, Verdict.DEAD_DOI, entry.title,
|
|
136
|
+
source=resolver.name,
|
|
137
|
+
note=f"DOI {clean_doi(entry.doi)} not registered",
|
|
138
|
+
cited=cited)
|
|
139
|
+
authoritative_absence.append(f"{outcome.source}: {outcome.detail[:50]}")
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if isinstance(outcome, Found):
|
|
143
|
+
return self._judge(entry, outcome.record, resolver, has_identifier, cited)
|
|
144
|
+
|
|
145
|
+
if any_unavailable and not authoritative_absence:
|
|
146
|
+
return CheckResult(entry.key, Verdict.UNVERIFIED, entry.title,
|
|
147
|
+
note="; ".join(any_unavailable)[:160], cited=cited)
|
|
148
|
+
if entry.entry_type in NON_ARCHIVAL_TYPES and not has_identifier:
|
|
149
|
+
return CheckResult(entry.key, Verdict.SKIPPED, entry.title,
|
|
150
|
+
note=f"@{entry.entry_type} not indexed", cited=cited)
|
|
151
|
+
note = "; ".join(authoritative_absence + any_unavailable)[:160]
|
|
152
|
+
return CheckResult(entry.key, Verdict.NOT_FOUND, entry.title, note=note, cited=cited)
|
|
153
|
+
|
|
154
|
+
def _judge(
|
|
155
|
+
self,
|
|
156
|
+
entry: Entry,
|
|
157
|
+
record: Record,
|
|
158
|
+
resolver: Resolver,
|
|
159
|
+
has_identifier: bool,
|
|
160
|
+
cited: bool | None,
|
|
161
|
+
) -> CheckResult:
|
|
162
|
+
score = similarity(entry.title, record.title)
|
|
163
|
+
|
|
164
|
+
def result(verdict: Verdict, note: str = "") -> CheckResult:
|
|
165
|
+
# Built explicitly rather than by unpacking a dict: the shared
|
|
166
|
+
# fields are identical for every branch, but keyword unpacking
|
|
167
|
+
# erases their types and hides genuine mistakes from the checker.
|
|
168
|
+
return CheckResult(
|
|
169
|
+
key=entry.key,
|
|
170
|
+
verdict=verdict,
|
|
171
|
+
entry_title=entry.title,
|
|
172
|
+
found_title=record.title,
|
|
173
|
+
source=resolver.name,
|
|
174
|
+
similarity=score,
|
|
175
|
+
note=note,
|
|
176
|
+
cited=cited,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if score < self.thresholds.title_match:
|
|
180
|
+
resolved_by_identifier = resolver.name.endswith(":doi") or "arxiv" in resolver.name
|
|
181
|
+
if resolved_by_identifier:
|
|
182
|
+
# The identifier points at a different paper. This is the
|
|
183
|
+
# signature of a fabricated or mis-copied citation.
|
|
184
|
+
return result(Verdict.TITLE_MISMATCH,
|
|
185
|
+
"identifier resolves to a different title")
|
|
186
|
+
if score < self.thresholds.title_suspect:
|
|
187
|
+
# A dataset or web resource with no identifier was never going
|
|
188
|
+
# to be in a citation index; a stray title hit is not a finding.
|
|
189
|
+
if entry.entry_type in NON_ARCHIVAL_TYPES and not has_identifier:
|
|
190
|
+
return result(Verdict.SKIPPED,
|
|
191
|
+
f"@{entry.entry_type} with no identifier; not indexed")
|
|
192
|
+
return result(Verdict.NOT_FOUND, "no close title match found")
|
|
193
|
+
return result(Verdict.UNVERIFIED,
|
|
194
|
+
"only a weak title match; no identifier to confirm")
|
|
195
|
+
|
|
196
|
+
want = first_surname(entry.get("author"))
|
|
197
|
+
if (want and record.first_author_surname
|
|
198
|
+
and not surnames_match(want, first_surname(record.first_author_surname))):
|
|
199
|
+
return result(Verdict.AUTHOR_MISMATCH,
|
|
200
|
+
f"bib={want} vs {record.first_author_surname.lower()}")
|
|
201
|
+
|
|
202
|
+
if entry.year and record.year and abs(entry.year - record.year) > self.thresholds.year_slack:
|
|
203
|
+
return result(Verdict.YEAR_MISMATCH, f"bib={entry.year} vs {record.year}")
|
|
204
|
+
|
|
205
|
+
return result(Verdict.OK)
|
refaudit/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .bibtex import cited_keys, find_tex, parse_file
|
|
13
|
+
from .cache import Cache
|
|
14
|
+
from .checker import Checker, Thresholds
|
|
15
|
+
from .models import Verdict
|
|
16
|
+
from .resolvers import AVAILABLE, default_resolvers
|
|
17
|
+
|
|
18
|
+
EPILOG = """\
|
|
19
|
+
examples:
|
|
20
|
+
refaudit refs.bib --email you@uni.edu
|
|
21
|
+
refaudit refs.bib --email you@uni.edu --tex paper/sections --only-cited
|
|
22
|
+
refaudit refs.bib --email you@uni.edu --resolvers crossref:doi,openalex
|
|
23
|
+
|
|
24
|
+
exit status:
|
|
25
|
+
0 no findings
|
|
26
|
+
1 at least one entry needs a human look
|
|
27
|
+
2 usage or input error
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
p = argparse.ArgumentParser(
|
|
33
|
+
prog="refaudit",
|
|
34
|
+
description="Verify .bib entries against Crossref, arXiv and OpenAlex.",
|
|
35
|
+
epilog=EPILOG,
|
|
36
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
37
|
+
)
|
|
38
|
+
p.add_argument("bib", type=Path, help="path to the .bib file")
|
|
39
|
+
p.add_argument("--email", default=os.environ.get("REFAUDIT_EMAIL", ""),
|
|
40
|
+
help="contact address sent to the APIs (or set REFAUDIT_EMAIL). "
|
|
41
|
+
"Crossref and OpenAlex give identified callers a better pool.")
|
|
42
|
+
p.add_argument("--tex", type=Path,
|
|
43
|
+
help="directory or file of LaTeX sources, to determine which keys are cited")
|
|
44
|
+
p.add_argument("--only-cited", action="store_true",
|
|
45
|
+
help="check only keys cited in --tex (uncited entries never reach the PDF)")
|
|
46
|
+
p.add_argument("--resolvers", default="",
|
|
47
|
+
help=f"comma-separated subset of: {', '.join(AVAILABLE)}")
|
|
48
|
+
p.add_argument("--out", type=Path, default=Path("refaudit-out"),
|
|
49
|
+
help="output directory (default: refaudit-out)")
|
|
50
|
+
p.add_argument("--cache", type=Path, help="cache file (default: <out>/cache.json)")
|
|
51
|
+
p.add_argument("--no-cache", action="store_true", help="ignore and do not write the cache")
|
|
52
|
+
p.add_argument("--ttl-days", type=float, default=90.0, help="cache lifetime (default: 90)")
|
|
53
|
+
p.add_argument("--timeout", type=float, default=20.0, help="per-request timeout seconds")
|
|
54
|
+
p.add_argument("--title-match", type=float, default=Thresholds.title_match,
|
|
55
|
+
help="similarity at or above which two titles are the same work")
|
|
56
|
+
p.add_argument("--quiet", action="store_true", help="only print the summary")
|
|
57
|
+
p.add_argument("--version", action="version", version=f"refaudit {__version__}")
|
|
58
|
+
return p
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
args = build_parser().parse_args(argv)
|
|
63
|
+
|
|
64
|
+
if not args.bib.is_file():
|
|
65
|
+
print(f"error: no such file: {args.bib}", file=sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
if not args.email:
|
|
68
|
+
print("error: --email is required (or set REFAUDIT_EMAIL).\n"
|
|
69
|
+
" Crossref and OpenAlex ask callers to identify themselves, and\n"
|
|
70
|
+
" doing so puts you in a more reliable request pool.", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
if args.only_cited and not args.tex:
|
|
73
|
+
print("error: --only-cited requires --tex", file=sys.stderr)
|
|
74
|
+
return 2
|
|
75
|
+
|
|
76
|
+
entries = parse_file(args.bib)
|
|
77
|
+
if not entries:
|
|
78
|
+
print(f"error: no entries parsed from {args.bib}", file=sys.stderr)
|
|
79
|
+
return 2
|
|
80
|
+
|
|
81
|
+
cited: set[str] | None = None
|
|
82
|
+
if args.tex:
|
|
83
|
+
cited = cited_keys(find_tex(args.tex))
|
|
84
|
+
if args.only_cited:
|
|
85
|
+
entries = [e for e in entries if e.key in cited]
|
|
86
|
+
if not entries:
|
|
87
|
+
print("error: no cited entries found; is --tex pointing at the right place?",
|
|
88
|
+
file=sys.stderr)
|
|
89
|
+
return 2
|
|
90
|
+
|
|
91
|
+
only = [r.strip() for r in args.resolvers.split(",") if r.strip()] or None
|
|
92
|
+
try:
|
|
93
|
+
resolvers = default_resolvers(args.email, only=only, timeout=args.timeout)
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
print(f"error: {e}", file=sys.stderr)
|
|
96
|
+
return 2
|
|
97
|
+
|
|
98
|
+
args.out.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
cache = None if args.no_cache else Cache(args.cache or (args.out / "cache.json"),
|
|
100
|
+
ttl_days=args.ttl_days)
|
|
101
|
+
checker = Checker(resolvers, cache=cache,
|
|
102
|
+
thresholds=Thresholds(title_match=args.title_match))
|
|
103
|
+
|
|
104
|
+
results = []
|
|
105
|
+
try:
|
|
106
|
+
for i, result in enumerate(checker.check_all(entries, cited=cited), 1):
|
|
107
|
+
results.append(result)
|
|
108
|
+
if not args.quiet:
|
|
109
|
+
print(f"[{i}/{len(entries)}] {result.verdict.value:<15} {result.key}", flush=True)
|
|
110
|
+
if cache and i % 10 == 0:
|
|
111
|
+
cache.flush()
|
|
112
|
+
except KeyboardInterrupt:
|
|
113
|
+
print("\ninterrupted; partial results kept", file=sys.stderr)
|
|
114
|
+
finally:
|
|
115
|
+
if cache:
|
|
116
|
+
cache.flush()
|
|
117
|
+
|
|
118
|
+
return _report(results, args.out, bool(args.only_cited))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _report(results, out: Path, only_cited: bool) -> int:
|
|
122
|
+
order = list(Verdict)
|
|
123
|
+
results.sort(key=lambda r: (order.index(r.verdict), r.key.lower()))
|
|
124
|
+
|
|
125
|
+
csv_path = out / "reference_check.csv"
|
|
126
|
+
with csv_path.open("w", newline="", encoding="utf-8") as fh:
|
|
127
|
+
rows = [r.as_row() for r in results]
|
|
128
|
+
writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
|
|
129
|
+
writer.writeheader()
|
|
130
|
+
writer.writerows(rows)
|
|
131
|
+
|
|
132
|
+
counts: dict[str, int] = {}
|
|
133
|
+
for r in results:
|
|
134
|
+
counts[r.verdict.value] = counts.get(r.verdict.value, 0) + 1
|
|
135
|
+
|
|
136
|
+
findings = [r for r in results if r.verdict.is_finding]
|
|
137
|
+
unverified = [r for r in results if r.verdict is Verdict.UNVERIFIED]
|
|
138
|
+
|
|
139
|
+
lines = ["refaudit", "=" * 72,
|
|
140
|
+
f"entries checked : {len(results)}" + (" (cited only)" if only_cited else ""),
|
|
141
|
+
"counts : " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())), ""]
|
|
142
|
+
|
|
143
|
+
if unverified:
|
|
144
|
+
lines += [f"{len(unverified)} entries could not be checked (a source was unreachable).",
|
|
145
|
+
"These are not findings. Re-run later or from another network.", ""]
|
|
146
|
+
|
|
147
|
+
if findings:
|
|
148
|
+
lines.append(f"--- {len(findings)} entries need a human look, worst first")
|
|
149
|
+
for r in findings:
|
|
150
|
+
lines.append(f" {r.verdict.value:<15} {r.key}")
|
|
151
|
+
lines.append(f" bib : {r.entry_title[:110]}")
|
|
152
|
+
if r.found_title:
|
|
153
|
+
lines.append(f" found : {r.found_title[:110]} [{r.source}]")
|
|
154
|
+
if r.note:
|
|
155
|
+
lines.append(f" why : {r.note}")
|
|
156
|
+
else:
|
|
157
|
+
lines.append("No findings.")
|
|
158
|
+
|
|
159
|
+
text = "\n".join(lines)
|
|
160
|
+
(out / "reference_check.txt").write_text(text + "\n", encoding="utf-8")
|
|
161
|
+
print("\n" + text)
|
|
162
|
+
print(f"\nwritten to {out}/")
|
|
163
|
+
return 1 if findings else 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__": # pragma: no cover
|
|
167
|
+
raise SystemExit(main())
|