citegate 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.
- citegate/__init__.py +3 -0
- citegate/cli.py +144 -0
- citegate/core.py +388 -0
- citegate/report.py +117 -0
- citegate/sources.py +190 -0
- citegate-0.1.0.dist-info/METADATA +119 -0
- citegate-0.1.0.dist-info/RECORD +11 -0
- citegate-0.1.0.dist-info/WHEEL +5 -0
- citegate-0.1.0.dist-info/entry_points.txt +2 -0
- citegate-0.1.0.dist-info/licenses/LICENSE +21 -0
- citegate-0.1.0.dist-info/top_level.txt +1 -0
citegate/__init__.py
ADDED
citegate/cli.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""citegate command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import glob
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .core import (
|
|
14
|
+
Entry,
|
|
15
|
+
Result,
|
|
16
|
+
ResultCache,
|
|
17
|
+
Verdict,
|
|
18
|
+
entry_cache_key,
|
|
19
|
+
parse_bib_file,
|
|
20
|
+
result_from_dict,
|
|
21
|
+
verify_entry,
|
|
22
|
+
)
|
|
23
|
+
from .report import github_annotations, github_step_summary, print_console, write_json
|
|
24
|
+
from .sources import make_clients
|
|
25
|
+
|
|
26
|
+
FAIL_CHOICES = {
|
|
27
|
+
"not-found": Verdict.NOT_FOUND,
|
|
28
|
+
"retracted": Verdict.RETRACTED,
|
|
29
|
+
"mismatch": Verdict.MISMATCH,
|
|
30
|
+
"error": Verdict.ERROR,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="citegate",
|
|
37
|
+
description=(
|
|
38
|
+
"Verify BibTeX references against Crossref and OpenAlex: catch fabricated "
|
|
39
|
+
"citations, metadata errors, and retracted papers — and fail CI when they appear."
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("files", nargs="+", help=".bib files or globs to check")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--fail-on",
|
|
45
|
+
default="not-found,retracted",
|
|
46
|
+
help="comma-separated verdicts that fail the run "
|
|
47
|
+
"(choices: not-found, retracted, mismatch, error; default: not-found,retracted)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--mailto",
|
|
51
|
+
default=os.environ.get("CITEGATE_MAILTO", ""),
|
|
52
|
+
help="contact email sent to the APIs (enables the Crossref polite pool); "
|
|
53
|
+
"also read from CITEGATE_MAILTO",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--json", metavar="PATH", help="write a JSON report to PATH ('-' for stdout)")
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--cache",
|
|
58
|
+
metavar="PATH",
|
|
59
|
+
nargs="?",
|
|
60
|
+
const=".citegate-cache.json",
|
|
61
|
+
help="cache results in a JSON file so repeated runs skip unchanged entries "
|
|
62
|
+
"(default path: .citegate-cache.json)",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument("--quiet", action="store_true", help="only print entries with problems")
|
|
65
|
+
parser.add_argument("--version", action="version", version=f"citegate {__version__}")
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def collect_files(patterns: List[str]) -> List[Path]:
|
|
70
|
+
paths: List[Path] = []
|
|
71
|
+
for pattern in patterns:
|
|
72
|
+
matches = sorted(glob.glob(pattern, recursive=True))
|
|
73
|
+
if matches:
|
|
74
|
+
paths.extend(Path(m) for m in matches)
|
|
75
|
+
elif Path(pattern).exists():
|
|
76
|
+
paths.append(Path(pattern))
|
|
77
|
+
else:
|
|
78
|
+
print(f"citegate: no files match '{pattern}'", file=sys.stderr)
|
|
79
|
+
seen = set()
|
|
80
|
+
unique = []
|
|
81
|
+
for p in paths:
|
|
82
|
+
if p not in seen:
|
|
83
|
+
seen.add(p)
|
|
84
|
+
unique.append(p)
|
|
85
|
+
return unique
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: List[str] = None) -> int:
|
|
89
|
+
args = build_parser().parse_args(argv)
|
|
90
|
+
|
|
91
|
+
failing = set()
|
|
92
|
+
for name in args.fail_on.split(","):
|
|
93
|
+
name = name.strip().lower()
|
|
94
|
+
if not name:
|
|
95
|
+
continue
|
|
96
|
+
if name not in FAIL_CHOICES:
|
|
97
|
+
print(f"citegate: unknown --fail-on value '{name}'", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
failing.add(FAIL_CHOICES[name])
|
|
100
|
+
|
|
101
|
+
files = collect_files(args.files)
|
|
102
|
+
if not files:
|
|
103
|
+
print("citegate: no .bib files to check", file=sys.stderr)
|
|
104
|
+
return 2
|
|
105
|
+
|
|
106
|
+
entries: List[Entry] = []
|
|
107
|
+
for path in files:
|
|
108
|
+
try:
|
|
109
|
+
entries.extend(parse_bib_file(path))
|
|
110
|
+
except Exception as exc: # bibtexparser raises plain Exceptions on bad input
|
|
111
|
+
print(f"citegate: could not parse {path}: {exc}", file=sys.stderr)
|
|
112
|
+
return 2
|
|
113
|
+
|
|
114
|
+
cache = ResultCache(Path(args.cache)) if args.cache else None
|
|
115
|
+
crossref, openalex = make_clients(mailto=args.mailto or None)
|
|
116
|
+
|
|
117
|
+
results: List[Result] = []
|
|
118
|
+
for entry in entries:
|
|
119
|
+
cached = cache.get(entry_cache_key(entry)) if cache else None
|
|
120
|
+
if cached is not None:
|
|
121
|
+
result = result_from_dict(cached)
|
|
122
|
+
result.file = entry.file
|
|
123
|
+
else:
|
|
124
|
+
result = verify_entry(entry, crossref, openalex)
|
|
125
|
+
if cache and result.verdict is not Verdict.ERROR:
|
|
126
|
+
cache.put(entry_cache_key(entry), result)
|
|
127
|
+
results.append(result)
|
|
128
|
+
|
|
129
|
+
if cache:
|
|
130
|
+
cache.save()
|
|
131
|
+
|
|
132
|
+
print_console(results, quiet=args.quiet)
|
|
133
|
+
github_annotations(results, failing)
|
|
134
|
+
github_step_summary(results)
|
|
135
|
+
if args.json:
|
|
136
|
+
write_json(results, args.json)
|
|
137
|
+
|
|
138
|
+
if any(r.verdict in failing for r in results):
|
|
139
|
+
return 1
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(main())
|
citegate/core.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Entry parsing, matching logic, and verdicts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import unicodedata
|
|
11
|
+
from dataclasses import dataclass, field, asdict
|
|
12
|
+
from difflib import SequenceMatcher
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
import bibtexparser
|
|
17
|
+
from bibtexparser.bparser import BibTexParser
|
|
18
|
+
|
|
19
|
+
from .sources import (
|
|
20
|
+
CONCERN_UPDATE_TYPES,
|
|
21
|
+
RETRACTION_UPDATE_TYPES,
|
|
22
|
+
CandidateRecord,
|
|
23
|
+
Crossref,
|
|
24
|
+
OpenAlex,
|
|
25
|
+
SourceError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Entry types that generally cannot be verified against scholarly indexes
|
|
29
|
+
# unless they carry a DOI (websites, standards, lecture notes, ...).
|
|
30
|
+
UNVERIFIABLE_TYPES = {
|
|
31
|
+
"misc",
|
|
32
|
+
"online",
|
|
33
|
+
"electronic",
|
|
34
|
+
"www",
|
|
35
|
+
"manual",
|
|
36
|
+
"unpublished",
|
|
37
|
+
"software",
|
|
38
|
+
"dataset",
|
|
39
|
+
"standard",
|
|
40
|
+
"techreport",
|
|
41
|
+
"patent",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
STRONG_MATCH = 0.93
|
|
45
|
+
WEAK_MATCH = 0.75
|
|
46
|
+
DOI_TITLE_MATCH = 0.80
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Verdict(str, enum.Enum):
|
|
50
|
+
VERIFIED = "verified"
|
|
51
|
+
RETRACTED = "retracted"
|
|
52
|
+
NOT_FOUND = "not-found"
|
|
53
|
+
MISMATCH = "mismatch"
|
|
54
|
+
UNVERIFIABLE = "unverifiable"
|
|
55
|
+
ERROR = "error"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Entry:
|
|
60
|
+
key: str
|
|
61
|
+
entry_type: str
|
|
62
|
+
title: str = ""
|
|
63
|
+
authors: List[str] = field(default_factory=list)
|
|
64
|
+
year: Optional[int] = None
|
|
65
|
+
doi: Optional[str] = None
|
|
66
|
+
venue: str = ""
|
|
67
|
+
file: str = ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Result:
|
|
72
|
+
key: str
|
|
73
|
+
file: str
|
|
74
|
+
verdict: Verdict
|
|
75
|
+
problems: List[str] = field(default_factory=list)
|
|
76
|
+
similarity: Optional[float] = None
|
|
77
|
+
matched_doi: Optional[str] = None
|
|
78
|
+
matched_title: Optional[str] = None
|
|
79
|
+
source: Optional[str] = None
|
|
80
|
+
suggestion: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> Dict:
|
|
83
|
+
d = asdict(self)
|
|
84
|
+
d["verdict"] = self.verdict.value
|
|
85
|
+
return d
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_LATEX_CMD = re.compile(r"\\[a-zA-Z]+\s*")
|
|
89
|
+
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def normalize(text: str) -> str:
|
|
93
|
+
"""Normalize a title for comparison: strip LaTeX markup, accents, case, punctuation."""
|
|
94
|
+
text = re.sub(r"\\['\"^`~=.]", "", text) # accent commands: \'e -> e
|
|
95
|
+
text = _LATEX_CMD.sub(" ", text) # commands before brace stripping, so \emph{X} keeps X
|
|
96
|
+
text = text.replace("{", "").replace("}", "").replace("~", " ")
|
|
97
|
+
text = unicodedata.normalize("NFKD", text)
|
|
98
|
+
text = "".join(c for c in text if not unicodedata.combining(c))
|
|
99
|
+
return _NON_ALNUM.sub(" ", text.lower()).strip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def title_similarity(a: str, b: str) -> float:
|
|
103
|
+
na, nb = normalize(a), normalize(b)
|
|
104
|
+
if not na or not nb:
|
|
105
|
+
return 0.0
|
|
106
|
+
# A record title that merely extends the entry title (or vice versa, e.g.
|
|
107
|
+
# missing subtitle) should still count as a near-match.
|
|
108
|
+
if na == nb or na.startswith(nb) or nb.startswith(na):
|
|
109
|
+
return 1.0
|
|
110
|
+
return SequenceMatcher(None, na, nb).ratio()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_families(author_field: str) -> List[str]:
|
|
114
|
+
"""Extract family names from a BibTeX author field."""
|
|
115
|
+
families = []
|
|
116
|
+
for person in re.split(r"\s+and\s+", author_field):
|
|
117
|
+
person = person.strip().strip("{}")
|
|
118
|
+
if not person or person.lower() == "others":
|
|
119
|
+
continue
|
|
120
|
+
if "," in person:
|
|
121
|
+
families.append(person.split(",")[0].strip().strip("{}"))
|
|
122
|
+
else:
|
|
123
|
+
families.append(person.split()[-1].strip("{}"))
|
|
124
|
+
return [f for f in families if f]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def clean_doi(raw: str) -> Optional[str]:
|
|
128
|
+
doi = raw.strip().strip("{}").strip()
|
|
129
|
+
doi = re.sub(r"^(https?://)?(dx\.)?doi\.org/", "", doi, flags=re.I)
|
|
130
|
+
doi = doi.strip()
|
|
131
|
+
return doi.lower() or None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_bib_file(path: Path) -> List[Entry]:
|
|
135
|
+
parser = BibTexParser(common_strings=True)
|
|
136
|
+
parser.ignore_nonstandard_types = False
|
|
137
|
+
with open(path, encoding="utf-8") as fh:
|
|
138
|
+
db = bibtexparser.load(fh, parser=parser)
|
|
139
|
+
entries = []
|
|
140
|
+
for raw in db.entries:
|
|
141
|
+
year = None
|
|
142
|
+
raw_year = (raw.get("year") or "").strip("{} ")
|
|
143
|
+
match = re.search(r"\d{4}", raw_year)
|
|
144
|
+
if match:
|
|
145
|
+
year = int(match.group())
|
|
146
|
+
entries.append(
|
|
147
|
+
Entry(
|
|
148
|
+
key=raw.get("ID", "?"),
|
|
149
|
+
entry_type=(raw.get("ENTRYTYPE") or "misc").lower(),
|
|
150
|
+
title=(raw.get("title") or "").strip(),
|
|
151
|
+
authors=parse_families(raw.get("author", "")),
|
|
152
|
+
year=year,
|
|
153
|
+
doi=clean_doi(raw.get("doi", "")),
|
|
154
|
+
venue=(raw.get("journal") or raw.get("booktitle") or "").strip(),
|
|
155
|
+
file=str(path),
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return entries
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _author_overlap(entry: Entry, record: CandidateRecord) -> bool:
|
|
162
|
+
if not entry.authors or not record.families:
|
|
163
|
+
return True # nothing to compare; do not penalize
|
|
164
|
+
entry_set = {normalize(f) for f in entry.authors}
|
|
165
|
+
record_set = {normalize(f) for f in record.families}
|
|
166
|
+
return bool(entry_set & record_set)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _year_close(entry: Entry, record: CandidateRecord, slack: int = 1) -> bool:
|
|
170
|
+
if entry.year is None or record.year is None:
|
|
171
|
+
return True
|
|
172
|
+
return abs(entry.year - record.year) <= slack
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _check_retraction(doi: str, crossref: Crossref, openalex: OpenAlex) -> tuple:
|
|
176
|
+
"""Return (is_retracted, notes) for a DOI, consulting both sources."""
|
|
177
|
+
notes: List[str] = []
|
|
178
|
+
retracted = False
|
|
179
|
+
try:
|
|
180
|
+
oa = openalex.get_by_doi(doi)
|
|
181
|
+
if oa is not None and oa.is_retracted:
|
|
182
|
+
retracted = True
|
|
183
|
+
notes.append("OpenAlex marks this work as retracted")
|
|
184
|
+
except SourceError:
|
|
185
|
+
notes.append("OpenAlex unreachable for retraction check")
|
|
186
|
+
try:
|
|
187
|
+
updates = crossref.retraction_updates(doi)
|
|
188
|
+
except SourceError:
|
|
189
|
+
updates = []
|
|
190
|
+
notes.append("Crossref unreachable for retraction check")
|
|
191
|
+
hits = [u for u in updates if u in RETRACTION_UPDATE_TYPES]
|
|
192
|
+
if hits:
|
|
193
|
+
retracted = True
|
|
194
|
+
notes.append(f"Crossref/Retraction Watch records: {', '.join(sorted(set(hits)))}")
|
|
195
|
+
concerns = [u for u in updates if u in CONCERN_UPDATE_TYPES]
|
|
196
|
+
if concerns and not retracted:
|
|
197
|
+
notes.append("an expression of concern has been issued for this work")
|
|
198
|
+
return retracted, notes
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def verify_entry(entry: Entry, crossref: Crossref, openalex: OpenAlex) -> Result:
|
|
202
|
+
result = Result(key=entry.key, file=entry.file, verdict=Verdict.UNVERIFIABLE)
|
|
203
|
+
|
|
204
|
+
if not entry.doi and not entry.title:
|
|
205
|
+
result.problems.append("entry has neither a title nor a DOI")
|
|
206
|
+
return result
|
|
207
|
+
if not entry.doi and entry.entry_type in UNVERIFIABLE_TYPES:
|
|
208
|
+
result.problems.append(
|
|
209
|
+
f"@{entry.entry_type} entries without a DOI are not checked against scholarly indexes"
|
|
210
|
+
)
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
if entry.doi:
|
|
215
|
+
return _verify_by_doi(entry, result, crossref, openalex)
|
|
216
|
+
return _verify_by_search(entry, result, crossref, openalex)
|
|
217
|
+
except SourceError as exc:
|
|
218
|
+
result.verdict = Verdict.ERROR
|
|
219
|
+
result.problems.append(str(exc))
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _verify_by_doi(entry: Entry, result: Result, crossref: Crossref, openalex: OpenAlex) -> Result:
|
|
224
|
+
record = crossref.get_by_doi(entry.doi)
|
|
225
|
+
if record is None:
|
|
226
|
+
try:
|
|
227
|
+
record = openalex.get_by_doi(entry.doi)
|
|
228
|
+
except SourceError:
|
|
229
|
+
record = None
|
|
230
|
+
if record is None:
|
|
231
|
+
result.verdict = Verdict.NOT_FOUND
|
|
232
|
+
result.problems.append(f"DOI {entry.doi} resolves in neither Crossref nor OpenAlex")
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
result.matched_doi = record.doi or entry.doi
|
|
236
|
+
result.matched_title = record.title
|
|
237
|
+
result.source = record.source
|
|
238
|
+
sim = title_similarity(entry.title, record.title) if entry.title and record.title else None
|
|
239
|
+
result.similarity = sim
|
|
240
|
+
|
|
241
|
+
problems = []
|
|
242
|
+
if sim is not None and sim < DOI_TITLE_MATCH:
|
|
243
|
+
problems.append(
|
|
244
|
+
f'title does not match the DOI record (similarity {sim:.2f}): index has "{record.title}"'
|
|
245
|
+
)
|
|
246
|
+
if not _year_close(entry, record):
|
|
247
|
+
problems.append(f"year mismatch: entry says {entry.year}, index says {record.year}")
|
|
248
|
+
if not _author_overlap(entry, record):
|
|
249
|
+
problems.append(
|
|
250
|
+
"no author overlap with the DOI record "
|
|
251
|
+
f"(index authors: {', '.join(record.families[:4]) or 'unknown'})"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
retracted, notes = _check_retraction(result.matched_doi, crossref, openalex)
|
|
255
|
+
result.problems.extend(notes)
|
|
256
|
+
if retracted:
|
|
257
|
+
result.verdict = Verdict.RETRACTED
|
|
258
|
+
result.problems[:0] = problems
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
if problems:
|
|
262
|
+
result.verdict = Verdict.MISMATCH
|
|
263
|
+
result.problems[:0] = problems
|
|
264
|
+
else:
|
|
265
|
+
result.verdict = Verdict.VERIFIED
|
|
266
|
+
return result
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _verify_by_search(entry: Entry, result: Result, crossref: Crossref, openalex: OpenAlex) -> Result:
|
|
270
|
+
query = entry.title
|
|
271
|
+
author = entry.authors[0] if entry.authors else None
|
|
272
|
+
candidates: List[CandidateRecord] = []
|
|
273
|
+
try:
|
|
274
|
+
candidates.extend(crossref.search(query, author=author))
|
|
275
|
+
except SourceError:
|
|
276
|
+
result.problems.append("Crossref search unavailable; relying on OpenAlex only")
|
|
277
|
+
try:
|
|
278
|
+
candidates.extend(openalex.search(query))
|
|
279
|
+
except SourceError:
|
|
280
|
+
result.problems.append("OpenAlex search unavailable; relying on Crossref only")
|
|
281
|
+
if not candidates and result.problems:
|
|
282
|
+
result.verdict = Verdict.ERROR
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
best: Optional[CandidateRecord] = None
|
|
286
|
+
best_sim = 0.0
|
|
287
|
+
for cand in candidates:
|
|
288
|
+
sim = title_similarity(entry.title, cand.title)
|
|
289
|
+
if sim > best_sim:
|
|
290
|
+
best, best_sim = cand, sim
|
|
291
|
+
|
|
292
|
+
result.similarity = round(best_sim, 3)
|
|
293
|
+
if best is not None:
|
|
294
|
+
result.matched_doi = best.doi
|
|
295
|
+
result.matched_title = best.title
|
|
296
|
+
result.source = best.source
|
|
297
|
+
|
|
298
|
+
if best is None or best_sim < WEAK_MATCH:
|
|
299
|
+
result.verdict = Verdict.NOT_FOUND
|
|
300
|
+
result.problems.append(
|
|
301
|
+
"no record with a similar title in Crossref or OpenAlex — possibly a fabricated reference"
|
|
302
|
+
)
|
|
303
|
+
if best is not None and best.title:
|
|
304
|
+
result.problems.append(f'closest match (similarity {best_sim:.2f}): "{best.title}"')
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
problems = []
|
|
308
|
+
if best_sim < STRONG_MATCH:
|
|
309
|
+
problems.append(
|
|
310
|
+
f'best match is only similarity {best_sim:.2f}: "{best.title}" — check the title'
|
|
311
|
+
)
|
|
312
|
+
if not _year_close(entry, best):
|
|
313
|
+
problems.append(f"year mismatch: entry says {entry.year}, index says {best.year}")
|
|
314
|
+
if not _author_overlap(entry, best):
|
|
315
|
+
problems.append(
|
|
316
|
+
f"no author overlap with the matched record ({', '.join(best.families[:4]) or 'unknown'})"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if best.doi:
|
|
320
|
+
retracted, notes = _check_retraction(best.doi, crossref, openalex)
|
|
321
|
+
result.problems.extend(notes)
|
|
322
|
+
if retracted:
|
|
323
|
+
result.verdict = Verdict.RETRACTED
|
|
324
|
+
result.problems[:0] = problems
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
if problems:
|
|
328
|
+
result.verdict = Verdict.MISMATCH
|
|
329
|
+
result.problems[:0] = problems
|
|
330
|
+
else:
|
|
331
|
+
result.verdict = Verdict.VERIFIED
|
|
332
|
+
if best.doi and not entry.doi:
|
|
333
|
+
result.suggestion = f"add doi = {{{best.doi}}}"
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# Cache
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def entry_cache_key(entry: Entry) -> str:
|
|
342
|
+
payload = json.dumps(
|
|
343
|
+
[entry.doi or "", normalize(entry.title), entry.year or 0, sorted(entry.authors)],
|
|
344
|
+
ensure_ascii=False,
|
|
345
|
+
)
|
|
346
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ResultCache:
|
|
350
|
+
"""A small JSON file cache so repeated CI runs skip unchanged entries."""
|
|
351
|
+
|
|
352
|
+
def __init__(self, path: Path, ttl_days: float = 14.0):
|
|
353
|
+
self.path = path
|
|
354
|
+
self.ttl = ttl_days * 86400
|
|
355
|
+
self.data: Dict[str, Dict] = {}
|
|
356
|
+
if path.exists():
|
|
357
|
+
try:
|
|
358
|
+
self.data = json.loads(path.read_text(encoding="utf-8"))
|
|
359
|
+
except (ValueError, OSError):
|
|
360
|
+
self.data = {}
|
|
361
|
+
|
|
362
|
+
def get(self, key: str) -> Optional[Dict]:
|
|
363
|
+
item = self.data.get(key)
|
|
364
|
+
if not item:
|
|
365
|
+
return None
|
|
366
|
+
if time.time() - item.get("ts", 0) > self.ttl:
|
|
367
|
+
return None
|
|
368
|
+
return item.get("result")
|
|
369
|
+
|
|
370
|
+
def put(self, key: str, result: Result) -> None:
|
|
371
|
+
self.data[key] = {"ts": time.time(), "result": result.to_dict()}
|
|
372
|
+
|
|
373
|
+
def save(self) -> None:
|
|
374
|
+
self.path.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def result_from_dict(d: Dict) -> Result:
|
|
378
|
+
return Result(
|
|
379
|
+
key=d["key"],
|
|
380
|
+
file=d.get("file", ""),
|
|
381
|
+
verdict=Verdict(d["verdict"]),
|
|
382
|
+
problems=list(d.get("problems", [])),
|
|
383
|
+
similarity=d.get("similarity"),
|
|
384
|
+
matched_doi=d.get("matched_doi"),
|
|
385
|
+
matched_title=d.get("matched_title"),
|
|
386
|
+
source=d.get("source"),
|
|
387
|
+
suggestion=d.get("suggestion"),
|
|
388
|
+
)
|
citegate/report.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Console, JSON, and GitHub Actions reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from typing import Dict, List
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .core import Result, Verdict
|
|
13
|
+
|
|
14
|
+
_COLORS = {
|
|
15
|
+
Verdict.VERIFIED: "\033[32m", # green
|
|
16
|
+
Verdict.RETRACTED: "\033[31m", # red
|
|
17
|
+
Verdict.NOT_FOUND: "\033[31m", # red
|
|
18
|
+
Verdict.MISMATCH: "\033[33m", # yellow
|
|
19
|
+
Verdict.UNVERIFIABLE: "\033[90m", # gray
|
|
20
|
+
Verdict.ERROR: "\033[35m", # magenta
|
|
21
|
+
}
|
|
22
|
+
_RESET = "\033[0m"
|
|
23
|
+
|
|
24
|
+
_LABELS = {
|
|
25
|
+
Verdict.VERIFIED: "OK ",
|
|
26
|
+
Verdict.RETRACTED: "RETRACTED ",
|
|
27
|
+
Verdict.NOT_FOUND: "NOT FOUND ",
|
|
28
|
+
Verdict.MISMATCH: "MISMATCH ",
|
|
29
|
+
Verdict.UNVERIFIABLE: "SKIPPED ",
|
|
30
|
+
Verdict.ERROR: "ERROR ",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _use_color() -> bool:
|
|
35
|
+
if os.environ.get("NO_COLOR"):
|
|
36
|
+
return False
|
|
37
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
38
|
+
return True
|
|
39
|
+
return sys.stdout.isatty()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def print_console(results: List[Result], quiet: bool = False) -> None:
|
|
43
|
+
color = _use_color()
|
|
44
|
+
for res in results:
|
|
45
|
+
if quiet and res.verdict in (Verdict.VERIFIED, Verdict.UNVERIFIABLE):
|
|
46
|
+
continue
|
|
47
|
+
label = _LABELS[res.verdict]
|
|
48
|
+
if color:
|
|
49
|
+
label = f"{_COLORS[res.verdict]}{label}{_RESET}"
|
|
50
|
+
line = f" {label} {res.key}"
|
|
51
|
+
if res.similarity is not None and res.verdict is not Verdict.VERIFIED:
|
|
52
|
+
line += f" (best title similarity {res.similarity:.2f})"
|
|
53
|
+
print(line)
|
|
54
|
+
for problem in res.problems:
|
|
55
|
+
print(f" - {problem}")
|
|
56
|
+
if res.suggestion:
|
|
57
|
+
print(f" > suggestion: {res.suggestion}")
|
|
58
|
+
|
|
59
|
+
counts = Counter(r.verdict for r in results)
|
|
60
|
+
total = len(results)
|
|
61
|
+
summary = ", ".join(
|
|
62
|
+
f"{counts[v]} {v.value}" for v in Verdict if counts.get(v)
|
|
63
|
+
)
|
|
64
|
+
print(f"\ncitegate: checked {total} entries — {summary or 'nothing to check'}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def github_annotations(results: List[Result], failing: set) -> None:
|
|
68
|
+
"""Emit GitHub Actions workflow annotations for problem entries."""
|
|
69
|
+
if os.environ.get("GITHUB_ACTIONS") != "true":
|
|
70
|
+
return
|
|
71
|
+
for res in results:
|
|
72
|
+
if res.verdict in (Verdict.VERIFIED, Verdict.UNVERIFIABLE):
|
|
73
|
+
continue
|
|
74
|
+
level = "error" if res.verdict in failing else "warning"
|
|
75
|
+
detail = "; ".join(res.problems) or res.verdict.value
|
|
76
|
+
print(f"::{level} file={res.file},title=citegate {res.verdict.value}: {res.key}::{detail}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def github_step_summary(results: List[Result]) -> None:
|
|
80
|
+
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
81
|
+
if not path:
|
|
82
|
+
return
|
|
83
|
+
counts = Counter(r.verdict for r in results)
|
|
84
|
+
lines = [
|
|
85
|
+
"## citegate reference check",
|
|
86
|
+
"",
|
|
87
|
+
f"Checked **{len(results)}** entries: "
|
|
88
|
+
+ ", ".join(f"{counts[v]} {v.value}" for v in Verdict if counts.get(v)),
|
|
89
|
+
"",
|
|
90
|
+
]
|
|
91
|
+
problems = [r for r in results if r.verdict not in (Verdict.VERIFIED, Verdict.UNVERIFIABLE)]
|
|
92
|
+
if problems:
|
|
93
|
+
lines += ["| entry | verdict | detail |", "|---|---|---|"]
|
|
94
|
+
for res in problems:
|
|
95
|
+
detail = "; ".join(res.problems).replace("|", "\\|")
|
|
96
|
+
lines.append(f"| `{res.key}` | {res.verdict.value} | {detail} |")
|
|
97
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
98
|
+
fh.write("\n".join(lines) + "\n")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def to_json(results: List[Result]) -> Dict:
|
|
102
|
+
counts = Counter(r.verdict for r in results)
|
|
103
|
+
return {
|
|
104
|
+
"tool": "citegate",
|
|
105
|
+
"version": __version__,
|
|
106
|
+
"summary": {v.value: counts.get(v, 0) for v in Verdict},
|
|
107
|
+
"results": [r.to_dict() for r in results],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def write_json(results: List[Result], path: str) -> None:
|
|
112
|
+
payload = json.dumps(to_json(results), indent=2, ensure_ascii=False)
|
|
113
|
+
if path == "-":
|
|
114
|
+
print(payload)
|
|
115
|
+
else:
|
|
116
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
117
|
+
fh.write(payload)
|
citegate/sources.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""HTTP clients for the Crossref and OpenAlex APIs.
|
|
2
|
+
|
|
3
|
+
Both clients identify themselves with a descriptive User-Agent (including the
|
|
4
|
+
user's mailto address when provided, which places Crossref requests in the
|
|
5
|
+
polite pool), retry transient failures with backoff, and pause briefly between
|
|
6
|
+
requests so that CI runs stay well within published rate limits.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
|
|
20
|
+
CROSSREF_API = "https://api.crossref.org"
|
|
21
|
+
OPENALEX_API = "https://api.openalex.org"
|
|
22
|
+
|
|
23
|
+
# Crossref update-types that mean the cited work is no longer standing.
|
|
24
|
+
RETRACTION_UPDATE_TYPES = {
|
|
25
|
+
"retraction",
|
|
26
|
+
"retraction_note",
|
|
27
|
+
"partial_retraction",
|
|
28
|
+
"withdrawal",
|
|
29
|
+
"removal",
|
|
30
|
+
}
|
|
31
|
+
CONCERN_UPDATE_TYPES = {"expression_of_concern"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SourceError(Exception):
|
|
35
|
+
"""A source API could not be reached after retries."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CandidateRecord:
|
|
40
|
+
"""A bibliographic record from either source, normalized for comparison."""
|
|
41
|
+
|
|
42
|
+
title: str
|
|
43
|
+
year: Optional[int]
|
|
44
|
+
families: List[str]
|
|
45
|
+
doi: Optional[str]
|
|
46
|
+
source: str
|
|
47
|
+
is_retracted: bool = False
|
|
48
|
+
notes: List[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Http:
|
|
52
|
+
def __init__(self, mailto: Optional[str] = None, timeout: float = 20.0, pause: float = 0.1):
|
|
53
|
+
self.session = requests.Session()
|
|
54
|
+
ua = f"citegate/{__version__} (https://github.com/chrisyangsong/citegate"
|
|
55
|
+
if mailto:
|
|
56
|
+
ua += f"; mailto:{mailto}"
|
|
57
|
+
ua += ")"
|
|
58
|
+
self.session.headers["User-Agent"] = ua
|
|
59
|
+
self.mailto = mailto
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.pause = pause
|
|
62
|
+
|
|
63
|
+
def get_json(self, url: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
64
|
+
"""GET a JSON document. Returns None on 404; raises SourceError when unreachable."""
|
|
65
|
+
last_error: Optional[Exception] = None
|
|
66
|
+
for attempt in range(3):
|
|
67
|
+
if attempt:
|
|
68
|
+
time.sleep(1.5 * attempt)
|
|
69
|
+
try:
|
|
70
|
+
resp = self.session.get(url, params=params, timeout=self.timeout)
|
|
71
|
+
except requests.RequestException as exc:
|
|
72
|
+
last_error = exc
|
|
73
|
+
continue
|
|
74
|
+
if resp.status_code == 404:
|
|
75
|
+
return None
|
|
76
|
+
if resp.status_code in (429, 500, 502, 503, 504):
|
|
77
|
+
last_error = RuntimeError(f"HTTP {resp.status_code} from {url}")
|
|
78
|
+
continue
|
|
79
|
+
resp.raise_for_status()
|
|
80
|
+
time.sleep(self.pause)
|
|
81
|
+
try:
|
|
82
|
+
return resp.json()
|
|
83
|
+
except ValueError as exc:
|
|
84
|
+
last_error = exc
|
|
85
|
+
continue
|
|
86
|
+
raise SourceError(f"could not reach {url}: {last_error}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _crossref_year(message: Dict[str, Any]) -> Optional[int]:
|
|
90
|
+
for key in ("issued", "published-print", "published-online", "created"):
|
|
91
|
+
parts = (message.get(key) or {}).get("date-parts") or []
|
|
92
|
+
if parts and parts[0] and parts[0][0]:
|
|
93
|
+
return int(parts[0][0])
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _crossref_record(message: Dict[str, Any]) -> CandidateRecord:
|
|
98
|
+
titles = message.get("title") or []
|
|
99
|
+
families = [a.get("family", "") for a in message.get("author", []) if a.get("family")]
|
|
100
|
+
return CandidateRecord(
|
|
101
|
+
title=titles[0] if titles else "",
|
|
102
|
+
year=_crossref_year(message),
|
|
103
|
+
families=families,
|
|
104
|
+
doi=(message.get("DOI") or "").lower() or None,
|
|
105
|
+
source="crossref",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _openalex_record(work: Dict[str, Any]) -> CandidateRecord:
|
|
110
|
+
families = []
|
|
111
|
+
for authorship in work.get("authorships", []):
|
|
112
|
+
name = (authorship.get("author") or {}).get("display_name") or ""
|
|
113
|
+
if name:
|
|
114
|
+
families.append(name.split()[-1])
|
|
115
|
+
doi = work.get("doi") or ""
|
|
116
|
+
doi = doi.replace("https://doi.org/", "").lower() or None
|
|
117
|
+
return CandidateRecord(
|
|
118
|
+
title=work.get("display_name") or "",
|
|
119
|
+
year=work.get("publication_year"),
|
|
120
|
+
families=families,
|
|
121
|
+
doi=doi,
|
|
122
|
+
source="openalex",
|
|
123
|
+
is_retracted=bool(work.get("is_retracted")),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Crossref:
|
|
128
|
+
def __init__(self, http: _Http):
|
|
129
|
+
self.http = http
|
|
130
|
+
|
|
131
|
+
def get_by_doi(self, doi: str) -> Optional[CandidateRecord]:
|
|
132
|
+
data = self.http.get_json(f"{CROSSREF_API}/works/{quote(doi, safe='')}")
|
|
133
|
+
if data is None:
|
|
134
|
+
return None
|
|
135
|
+
return _crossref_record(data["message"])
|
|
136
|
+
|
|
137
|
+
def search(self, query: str, author: Optional[str] = None, rows: int = 5) -> List[CandidateRecord]:
|
|
138
|
+
params: Dict[str, Any] = {"query.bibliographic": query, "rows": rows}
|
|
139
|
+
if author:
|
|
140
|
+
params["query.author"] = author
|
|
141
|
+
if self.http.mailto:
|
|
142
|
+
params["mailto"] = self.http.mailto
|
|
143
|
+
data = self.http.get_json(f"{CROSSREF_API}/works", params=params)
|
|
144
|
+
items = ((data or {}).get("message") or {}).get("items") or []
|
|
145
|
+
return [_crossref_record(m) for m in items]
|
|
146
|
+
|
|
147
|
+
def retraction_updates(self, doi: str) -> List[str]:
|
|
148
|
+
"""Return update-types of any Crossref works that update (retract/withdraw) this DOI.
|
|
149
|
+
|
|
150
|
+
Crossref hosts the Retraction Watch database, so this catches formally
|
|
151
|
+
indexed retractions even when the publisher's own record is silent.
|
|
152
|
+
"""
|
|
153
|
+
params = {"filter": f"updates:{doi}", "rows": 10}
|
|
154
|
+
data = self.http.get_json(f"{CROSSREF_API}/works", params=params)
|
|
155
|
+
items = ((data or {}).get("message") or {}).get("items") or []
|
|
156
|
+
update_types: List[str] = []
|
|
157
|
+
for item in items:
|
|
158
|
+
for upd in item.get("update-to", []):
|
|
159
|
+
if (upd.get("DOI") or "").lower() == doi.lower() and upd.get("type"):
|
|
160
|
+
update_types.append(upd["type"])
|
|
161
|
+
return update_types
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class OpenAlex:
|
|
165
|
+
def __init__(self, http: _Http):
|
|
166
|
+
self.http = http
|
|
167
|
+
|
|
168
|
+
def _params(self, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
169
|
+
params = dict(extra or {})
|
|
170
|
+
if self.http.mailto:
|
|
171
|
+
params["mailto"] = self.http.mailto
|
|
172
|
+
return params
|
|
173
|
+
|
|
174
|
+
def get_by_doi(self, doi: str) -> Optional[CandidateRecord]:
|
|
175
|
+
url = f"{OPENALEX_API}/works/https://doi.org/{quote(doi, safe='')}"
|
|
176
|
+
data = self.http.get_json(url, params=self._params())
|
|
177
|
+
if data is None:
|
|
178
|
+
return None
|
|
179
|
+
return _openalex_record(data)
|
|
180
|
+
|
|
181
|
+
def search(self, query: str, per_page: int = 5) -> List[CandidateRecord]:
|
|
182
|
+
data = self.http.get_json(
|
|
183
|
+
f"{OPENALEX_API}/works", params=self._params({"search": query, "per-page": per_page})
|
|
184
|
+
)
|
|
185
|
+
return [_openalex_record(w) for w in (data or {}).get("results", [])]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def make_clients(mailto: Optional[str] = None) -> tuple:
|
|
189
|
+
http = _Http(mailto=mailto)
|
|
190
|
+
return Crossref(http), OpenAlex(http)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: citegate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Citation integrity as a CI gate: verify BibTeX references against Crossref and OpenAlex, catch fabricated citations, and get alerted when a paper you cite is retracted.
|
|
5
|
+
Author-email: Yang Song <songyang0714@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/chrisyangsong/citegate
|
|
8
|
+
Project-URL: Issues, https://github.com/chrisyangsong/citegate/issues
|
|
9
|
+
Keywords: bibtex,citations,references,hallucination,retraction,crossref,openalex,ci,research-integrity
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Classifier: Topic :: Text Processing :: Markup :: LaTeX
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests>=2.28
|
|
21
|
+
Requires-Dist: bibtexparser<2,>=1.4
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# citegate
|
|
27
|
+
|
|
28
|
+
**Citation integrity as a CI gate.** citegate verifies every entry in your BibTeX files against [Crossref](https://www.crossref.org/) and [OpenAlex](https://openalex.org/), then fails your build when a reference is fabricated, wrong, or retracted.
|
|
29
|
+
|
|
30
|
+
LLM writing assistants fabricate plausible-looking references, and even careful humans cite papers that later get retracted. Existing checkers are interactive tools you have to remember to run. citegate is the piece that belongs in your repository instead: a GitHub Action, a pre-commit hook, and a weekly monitor that opens an issue the day a paper you cite is retracted.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
OK he2016deep
|
|
34
|
+
NOT FOUND fabricated2023 (best title similarity 0.42)
|
|
35
|
+
- no record with a similar title in Crossref or OpenAlex — possibly a fabricated reference
|
|
36
|
+
RETRACTED wakefield1998retracted
|
|
37
|
+
- OpenAlex marks this work as retracted
|
|
38
|
+
- Crossref/Retraction Watch records: retraction
|
|
39
|
+
|
|
40
|
+
citegate: checked 3 entries — 1 verified, 1 retracted, 1 not-found
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## What it checks
|
|
44
|
+
|
|
45
|
+
| Verdict | Meaning |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `verified` | The entry matches a real indexed work (title, year, authors agree). |
|
|
48
|
+
| `not-found` | No similar record exists in Crossref or OpenAlex. Likely fabricated. |
|
|
49
|
+
| `retracted` | The cited work is retracted or withdrawn, per OpenAlex and the [Retraction Watch data in Crossref](https://www.crossref.org/blog/news-crossref-and-retraction-watch/). |
|
|
50
|
+
| `mismatch` | A real work exists, but the year, title, or authors in your entry disagree with the index. |
|
|
51
|
+
| `unverifiable` | Websites, standards, and other entries without a DOI that scholarly indexes do not cover. Never fails the build. |
|
|
52
|
+
| `error` | A source API was unreachable. |
|
|
53
|
+
|
|
54
|
+
Entries with a DOI are resolved directly and their metadata compared field by field. Entries without a DOI are matched by fuzzy bibliographic search across both indexes; strong matches also get a `suggestion` with the DOI you should add.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install citegate # or: pipx install citegate
|
|
60
|
+
citegate paper/references.bib --mailto you@example.edu
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`--mailto` is optional but recommended: it identifies you to the APIs and places you in Crossref's polite pool. The exit code is non-zero when any `not-found` or `retracted` entry appears (configurable with `--fail-on not-found,retracted,mismatch`), so the same command works locally and in CI. Add `--json report.json` for machine-readable output and `--cache` to skip unchanged entries on repeated runs.
|
|
64
|
+
|
|
65
|
+
## GitHub Action
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
name: References
|
|
69
|
+
on: [push, pull_request]
|
|
70
|
+
jobs:
|
|
71
|
+
citegate:
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
steps:
|
|
74
|
+
- uses: actions/checkout@v4
|
|
75
|
+
- uses: chrisyangsong/citegate@main
|
|
76
|
+
with:
|
|
77
|
+
files: '**/*.bib'
|
|
78
|
+
mailto: 'you@example.edu'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Failures show up as inline annotations and a job-summary table listing exactly which entries are suspect and why.
|
|
82
|
+
|
|
83
|
+
## Retraction monitoring
|
|
84
|
+
|
|
85
|
+
A bibliography that verified cleanly last month can go bad without you touching it: about [one in 500 published papers is eventually retracted](https://www.crossref.org/blog/news-crossref-and-retraction-watch/), and citing one in a submission is an avoidable reviewer complaint. Copy [`examples/retraction-monitor.yml`](examples/retraction-monitor.yml) into `.github/workflows/` and citegate re-verifies your references every Monday, opening an issue in your repository when a cited paper is retracted or stops resolving.
|
|
86
|
+
|
|
87
|
+
## pre-commit hook
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
# .pre-commit-config.yaml
|
|
91
|
+
repos:
|
|
92
|
+
- repo: https://github.com/chrisyangsong/citegate
|
|
93
|
+
rev: v0.1.0
|
|
94
|
+
hooks:
|
|
95
|
+
- id: citegate
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Relation to other tools
|
|
99
|
+
|
|
100
|
+
Several good interactive checkers exist, including [refchecker](https://github.com/markrussinovich/refchecker) and [hallucinator](https://github.com/gianlucasb/hallucinator) for auditing a finished paper or PDF, and browser tools like [BibTeX Verifier](https://merfanian.github.io/Bibtex-Verifier/). citegate covers the other half of the problem: it lives in the repository with your `.bib` files, runs automatically on every push, and keeps watching after you stop looking. If you want a one-off deep audit of a PDF, use those tools; if you want your references checked continuously, use citegate.
|
|
101
|
+
|
|
102
|
+
## Design notes
|
|
103
|
+
|
|
104
|
+
- Sources: Crossref (REST API, polite pool) and OpenAlex. Retraction status is the union of OpenAlex's `is_retracted` flag and Crossref update records, which include the Retraction Watch database.
|
|
105
|
+
- Matching is deliberately conservative: `@misc` and other non-indexed entry types without DOIs are skipped rather than flagged, and a one-year slack is allowed on years (print vs online dates). False alarms are the fastest way to get a checker removed from CI.
|
|
106
|
+
- No LLMs are involved in verification; every verdict is traceable to an index record.
|
|
107
|
+
|
|
108
|
+
## Roadmap
|
|
109
|
+
|
|
110
|
+
- Parallel lookups for large bibliographies
|
|
111
|
+
- DOCX/PDF reference-list extraction (currently BibTeX only)
|
|
112
|
+
- arXiv and DBLP as additional sources
|
|
113
|
+
- An `--only retractions` fast mode for high-frequency monitoring
|
|
114
|
+
|
|
115
|
+
Issues and pull requests are welcome.
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT © 2026 Yang Song. Not affiliated with Crossref, OpenAlex, or Retraction Watch; please respect their API terms.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
citegate/__init__.py,sha256=vlVuVdZDm3PXVCAVJ7kaxTeD4Wk0em0bq4pzXMXzjkE,72
|
|
2
|
+
citegate/cli.py,sha256=VFxagJSzumQTaIbuyXgOp89P6JcXlRizX-DmLzJxTEg,4448
|
|
3
|
+
citegate/core.py,sha256=r0oiK_opL6YQj4li-ivpe8Bq5EkYClPMxcXzu6_0e6s,12771
|
|
4
|
+
citegate/report.py,sha256=jYfepSZQ8UqxryuVEIZw2b--OJvdZG4SLoQO9zndBqw,3906
|
|
5
|
+
citegate/sources.py,sha256=gluMtnLWmUrTul2DLYYKSYRJE2xMAHW17r6Mk-BCSBk,6824
|
|
6
|
+
citegate-0.1.0.dist-info/licenses/LICENSE,sha256=5Ji97VnY5J8_o4TIdOM0sod_Gh-9sosgPL2tmwu2cjk,1066
|
|
7
|
+
citegate-0.1.0.dist-info/METADATA,sha256=-Suvjx1cfErx0-9jbzVtDzZ3cmIj8WciWbNwRRo2Sbw,6324
|
|
8
|
+
citegate-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
citegate-0.1.0.dist-info/entry_points.txt,sha256=JELUCBOjyhVMv_GZR3zNnBUUsCKL5nyy7F_PL895xtI,47
|
|
10
|
+
citegate-0.1.0.dist-info/top_level.txt,sha256=hHv1xl_lM1LYkeC9nxuwhExGFS_PnscOwbg6zutv2pU,9
|
|
11
|
+
citegate-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yang Song
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
citegate
|