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 ADDED
@@ -0,0 +1,3 @@
1
+ """citegate: citation integrity as a CI gate."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ citegate = citegate.cli:main
@@ -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