claimcite 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.
claimcite/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
claimcite/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
claimcite/cache.py ADDED
@@ -0,0 +1,118 @@
1
+ """On-disk cache for registry responses. Standard library only.
2
+
3
+ Wraps the ``http_get(url) -> str`` callable: successful responses are stored by
4
+ URL (which encodes registry + identifier), so a second run after a one-line edit
5
+ does not repeat every request. Lookup failures are never written.
6
+
7
+ The cache is best-effort: if the directory or database cannot be used (read-only
8
+ home, full disk, locked file), requests go straight to the network.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import sqlite3
16
+ import sys
17
+ import time
18
+ import zlib
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+
22
+ DB_NAME = "cache.sqlite3"
23
+ DEFAULT_TTL_DAYS = 30
24
+ # Credentials must not be written to disk, and changing them must not empty the cache.
25
+ _SECRET_PARAM = re.compile(r"[?&]api_key=[^&]*")
26
+
27
+
28
+ def cache_key(url: str) -> str:
29
+ return _SECRET_PARAM.sub("", url)
30
+
31
+
32
+ def default_cache_dir() -> Path:
33
+ """Honour XDG_CACHE_HOME; fall back to ~/.cache/claimcite/v1."""
34
+ base = os.environ.get("XDG_CACHE_HOME")
35
+ return (Path(base) if base else Path.home() / ".cache") / "claimcite" / "v1"
36
+
37
+
38
+ class CachedHttp:
39
+ """A thread-safe, file-backed wrapper around an HTTP callable."""
40
+
41
+ def __init__(
42
+ self,
43
+ http: Callable[[str], str],
44
+ cache_dir: Path | None = None,
45
+ ttl_days: float = DEFAULT_TTL_DAYS,
46
+ ):
47
+ self.http = http
48
+ self.cache_dir = cache_dir if cache_dir is not None else default_cache_dir()
49
+ self.ttl = ttl_days * 86400
50
+ self.enabled = True
51
+ try:
52
+ self._init_db()
53
+ except (OSError, sqlite3.Error) as e:
54
+ self._disable(e)
55
+
56
+ def _disable(self, error: Exception) -> None:
57
+ if self.enabled:
58
+ print(f"claimcite: cache disabled ({error})", file=sys.stderr)
59
+ self.enabled = False
60
+
61
+ @property
62
+ def db_path(self) -> Path:
63
+ return self.cache_dir / DB_NAME
64
+
65
+ def _connect(self) -> sqlite3.Connection:
66
+ return sqlite3.connect(self.db_path, timeout=5.0)
67
+
68
+ def _init_db(self) -> None:
69
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
70
+ db = self._connect()
71
+ try:
72
+ db.execute(
73
+ "CREATE TABLE IF NOT EXISTS responses ("
74
+ "url TEXT PRIMARY KEY, body BLOB NOT NULL, fetched_at REAL NOT NULL)"
75
+ )
76
+ db.commit()
77
+ finally:
78
+ db.close()
79
+
80
+ def __call__(self, url: str) -> str:
81
+ if not self.enabled:
82
+ return self.http(url)
83
+ key = cache_key(url)
84
+ try:
85
+ db = self._connect()
86
+ try:
87
+ row = db.execute("SELECT body, fetched_at FROM responses WHERE url = ?", (key,)).fetchone()
88
+ finally:
89
+ db.close()
90
+ except sqlite3.Error as e:
91
+ self._disable(e)
92
+ return self.http(url)
93
+ if row is not None and time.time() - row[1] <= self.ttl:
94
+ body = row[0]
95
+ # Bodies are stored compressed; plain text rows are from before that change.
96
+ if not isinstance(body, bytes):
97
+ return body
98
+ try:
99
+ return zlib.decompress(body).decode("utf-8")
100
+ except (zlib.error, UnicodeDecodeError):
101
+ pass # damaged row: fetch again and overwrite it
102
+
103
+ # Exceptions propagate untouched: a failed lookup is never cached.
104
+ body = self.http(url)
105
+
106
+ try:
107
+ db = self._connect()
108
+ try:
109
+ db.execute(
110
+ "INSERT OR REPLACE INTO responses (url, body, fetched_at) VALUES (?, ?, ?)",
111
+ (key, zlib.compress(body.encode("utf-8")), time.time()),
112
+ )
113
+ db.commit()
114
+ finally:
115
+ db.close()
116
+ except sqlite3.Error as e:
117
+ self._disable(e)
118
+ return body
claimcite/check.py ADDED
@@ -0,0 +1,82 @@
1
+ """Compare what the document says about each citation with what the registry says."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from difflib import SequenceMatcher
7
+
8
+ from .evidence import UNKNOWN, evidence_level
9
+ from .extract import extract_references
10
+ from .models import Finding, Reference
11
+ from .sources import Lookup, LookupFailed
12
+ from .text import CJK as _CJK
13
+ from .text import quote_found
14
+ from .text import words as _words
15
+ from .web import check_page
16
+
17
+
18
+ def compare_titles(stated: str | None, actual: str | None) -> str:
19
+ if not stated:
20
+ return "not_stated"
21
+ if not actual:
22
+ return "unknown"
23
+ if _CJK.search(stated) and not _CJK.search(actual):
24
+ return "not_comparable"
25
+ a, b = _words(stated), _words(actual)
26
+ ratio = SequenceMatcher(None, a, b).ratio()
27
+ if ratio >= 0.9 or (len(a) > 20 and (a in b or b in a)):
28
+ return "match"
29
+ if ratio >= 0.6:
30
+ return "partial"
31
+ return "mismatch"
32
+
33
+
34
+ def check_reference(ref: Reference, lookup: Lookup, web: bool = True) -> Finding:
35
+ if ref.kind == "url" and not web:
36
+ return Finding(ref, status="unchecked")
37
+ if ref.kind == "url":
38
+ result = check_page(ref, lookup.http)
39
+ status = {"alive": "ok", "dead": "warning", "blocked": "unchecked", "failed": "unchecked"}[
40
+ result.state
41
+ ]
42
+ if result.state == "alive" and any(ok is False for _, ok in result.quotes):
43
+ status = "warning"
44
+ return Finding(ref, status=status, web=result)
45
+
46
+ try:
47
+ record = lookup.doi(ref.id) if ref.kind == "doi" else lookup.pmid(ref.id)
48
+ except LookupFailed as e:
49
+ return Finding(ref, status="unchecked", error=str(e))
50
+ if record is None:
51
+ return Finding(ref, status="error", exists=False)
52
+
53
+ f = Finding(ref, status="ok", exists=True, record=record)
54
+ f.title_match = compare_titles(ref.title, record.title)
55
+ years = record.years or ([record.year] if record.year else [])
56
+ if ref.year and years:
57
+ # Papers have online, print and issue years; any of them, give or take one, is fine.
58
+ f.year_match = any(abs(ref.year - y) <= 1 for y in years)
59
+ text = " ".join(filter(None, [record.title, record.abstract]))
60
+ f.quotes = [(q, quote_found(q, record.abstract and text)) for q in ref.quotes]
61
+ f.evidence = (
62
+ evidence_level(record.pub_types, record.humans, record.animals)
63
+ if record.source == "pubmed"
64
+ else UNKNOWN
65
+ )
66
+
67
+ if f.title_match == "mismatch":
68
+ f.status = "error"
69
+ elif f.title_match == "partial" or f.year_match is False or any(ok is False for _, ok in f.quotes):
70
+ f.status = "warning"
71
+ return f
72
+
73
+
74
+ def check_text(text: str, lookup: Lookup | None = None, web: bool = True) -> list[Finding]:
75
+ lookup = lookup or Lookup()
76
+ refs = extract_references(text)
77
+ lookup.prefetch(
78
+ dois=[r.id for r in refs if r.kind == "doi"], pmids=[r.id for r in refs if r.kind == "pmid"]
79
+ )
80
+ # Registry lookups are already batched above; this mainly runs web-page fetches side by side.
81
+ with ThreadPoolExecutor(max_workers=lookup.workers) as pool:
82
+ return list(pool.map(lambda ref: check_reference(ref, lookup, web), refs))
claimcite/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .cache import CachedHttp, default_cache_dir
10
+ from .check import check_text
11
+ from .net import http_get
12
+ from .report import to_json, to_markdown
13
+ from .sources import Lookup
14
+
15
+ FAIL_ON = {"error": {"error"}, "warning": {"error", "warning"}, "never": set()}
16
+
17
+
18
+ def make_lookup(no_cache: bool = False) -> Lookup:
19
+ http = http_get if no_cache else CachedHttp(http_get, default_cache_dir())
20
+ return Lookup(http=http)
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ p = argparse.ArgumentParser(prog="claimcite", description="Check the citations in a document.")
25
+ p.add_argument("--version", action="version", version=f"claimcite {__version__}")
26
+ sub = p.add_subparsers(dest="command", required=True)
27
+ c = sub.add_parser("check", help="check Markdown or text files ('-' reads stdin)")
28
+ c.add_argument("paths", nargs="+", metavar="path")
29
+ c.add_argument("--format", choices=["md", "json"], default="md")
30
+ c.add_argument("--lang", choices=["en", "zh"], default="en")
31
+ c.add_argument(
32
+ "--fail-on",
33
+ choices=list(FAIL_ON),
34
+ default="error",
35
+ help="exit with 1 when any finding has this status (default: error)",
36
+ )
37
+ c.add_argument("--no-cache", action="store_true", help="bypass the on-disk lookup cache for this run")
38
+ c.add_argument("--no-web", action="store_true", help="do not fetch web pages; check DOIs and PMIDs only")
39
+ args = p.parse_args(argv)
40
+
41
+ lookup = make_lookup(no_cache=args.no_cache)
42
+ several = len(args.paths) > 1
43
+ failed, reports = False, []
44
+ for path in args.paths:
45
+ text = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
46
+ findings = check_text(text, lookup, web=not args.no_web)
47
+ failed |= any(f.status in FAIL_ON[args.fail_on] for f in findings)
48
+ if args.format == "json":
49
+ reports.append({"path": path, **json.loads(to_json(findings))})
50
+ else:
51
+ print(f"## {path}\n" if several else "", to_markdown(findings, args.lang), sep="")
52
+ if args.format == "json":
53
+ if several:
54
+ total = {k: sum(r["summary"][k] for r in reports) for k in reports[0]["summary"]}
55
+ print(json.dumps({"summary": total, "files": reports}, ensure_ascii=False, indent=2))
56
+ else:
57
+ reports[0].pop("path")
58
+ print(json.dumps(reports[0], ensure_ascii=False, indent=2))
59
+ return 1 if failed else 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main())
claimcite/evidence.py ADDED
@@ -0,0 +1,47 @@
1
+ """Study design from PubMed publication types. A rough guide, not a quality appraisal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import Evidence
6
+
7
+ META = Evidence("meta_analysis", 1, "Systematic review / meta-analysis", "系统综述 / 荟萃分析")
8
+ GUIDELINE = Evidence("guideline", 1, "Clinical guideline", "临床指南")
9
+ RCT = Evidence("rct", 2, "Randomized controlled trial", "随机对照试验")
10
+ TRIAL = Evidence("clinical_trial", 3, "Clinical trial (not randomized)", "临床试验(非随机)")
11
+ OBSERVATIONAL = Evidence("observational", 4, "Observational study", "观察性研究")
12
+ CASE = Evidence("case_report", 5, "Case report", "病例报告")
13
+ REVIEW = Evidence("narrative_review", 5, "Narrative review", "叙述性综述")
14
+ OPINION = Evidence("opinion", 6, "Editorial / comment / letter", "社论 / 评论 / 通信")
15
+ ANIMAL = Evidence("animal", 6, "Animal study", "动物研究")
16
+ UNKNOWN = Evidence("unknown", 9, "Not classified", "未分类")
17
+
18
+ _BY_TYPE = [
19
+ (META, {"Meta-Analysis", "Systematic Review"}),
20
+ (GUIDELINE, {"Practice Guideline", "Guideline", "Consensus Development Conference"}),
21
+ (RCT, {"Randomized Controlled Trial", "Pragmatic Clinical Trial", "Equivalence Trial"}),
22
+ (
23
+ TRIAL,
24
+ {
25
+ "Clinical Trial",
26
+ "Controlled Clinical Trial",
27
+ "Clinical Trial, Phase I",
28
+ "Clinical Trial, Phase II",
29
+ "Clinical Trial, Phase III",
30
+ "Clinical Trial, Phase IV",
31
+ },
32
+ ),
33
+ (OBSERVATIONAL, {"Observational Study", "Twin Study"}),
34
+ (CASE, {"Case Reports"}),
35
+ (REVIEW, {"Review"}),
36
+ (OPINION, {"Editorial", "Comment", "Letter"}),
37
+ ]
38
+
39
+
40
+ def evidence_level(pub_types: list[str], humans: bool, animals: bool) -> Evidence:
41
+ types = set(pub_types)
42
+ if animals and not humans:
43
+ return ANIMAL
44
+ for evidence, names in _BY_TYPE:
45
+ if types & names:
46
+ return evidence
47
+ return UNKNOWN
claimcite/extract.py ADDED
@@ -0,0 +1,150 @@
1
+ """Find citations in plain text / Markdown and the metadata the author wrote next to them.
2
+
3
+ Supported citation forms:
4
+ - DOI: https://doi.org/10.x/y, doi:10.x/y, or a bare 10.x/y
5
+ - PMID: "PMID: 123" or https://pubmed.ncbi.nlm.nih.gov/123
6
+ - any other http(s) link (fetched and checked for availability and quoted text)
7
+
8
+ The text between two citations on the same line is treated as the description of the
9
+ second one, e.g. ``Author (2020). Title. Journal:「quoted words」. <https://doi.org/...>``
10
+
11
+ Numbered citations work too: a quote in the body followed by ``[1]``, ``[^note]``, ``[1, 2]`` or ``[1-3]``
12
+ is checked against the reference-list entry with that label (``[1] …``, ``1. …`` or ``[^note]: …``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+
19
+ from .models import Reference
20
+
21
+ _ASCII_TOKEN = r"[^\s<>\"'\u0080-\uffff]+"
22
+ # No re.I here: case-folding would make the non-ASCII exclusion also drop "s" and "k" (ſ, K).
23
+ DOI_RE = re.compile(r"(?i:https?://(?:dx\.)?doi\.org/|\bdoi:\s*)?\b(10\.\d{4,9}/" + _ASCII_TOKEN + ")")
24
+ PMID_RE = re.compile(
25
+ r"\bPMID:?\s*(\d{1,9})\b|https?://pubmed\.ncbi\.nlm\.nih\.gov/(\d{1,9})/?", re.IGNORECASE
26
+ )
27
+ URL_RE = re.compile(r"https?://" + _ASCII_TOKEN)
28
+
29
+ YEAR_RE = re.compile(r"\((\d{4})(?:-\d{1,2}(?:-\d{1,2})?)?[a-z]?\)")
30
+ TITLE_RE = re.compile(r"\(\d{4}[^)]*\)\.\s*(.+?)(?:\.\s|\.?\s*$|[::]\s*[「“\"])")
31
+ # "Journal of X, 41(2), 181-202": the author gave the journal, not the title.
32
+ JOURNAL_ONLY = re.compile(r"^[^.?!]{3,150},\s*\d+\s*(?:\(\d+\))?\s*(?:,\s*[eE]?\d+(?:[-–]\d+)?)?$")
33
+ # "Is it true? Psychological Science, 26": title ends at the question mark.
34
+ TITLE_THEN_JOURNAL = re.compile(r"^(.+?[?!])\s+[A-Z][^?!]*,\s*\d+.*$")
35
+ QUOTE_RES = [re.compile(r"「(.+?)」"), re.compile(r"“(.+?)”"), re.compile(r"\"([^\"]{4,}?)\"")]
36
+ LEADING_NOISE = re.compile(
37
+ r"^(?:\s|[;;,,、]|([^)]*)|\([^)]*\)(?=\s*[;;])|[-*]\s*|来源[::]|Sources?:)+", re.IGNORECASE
38
+ )
39
+
40
+ # A reference-list entry: "[1] …", "[^note]: …", "1. …" or "1) …" at the start of a line.
41
+ DEF_RE = re.compile(r"^\s*(?:\[(\^?[\w-]+)\]:?|(\d{1,3})[.)])\s+")
42
+ # A marker in the body: [1], [^note], [1, 2], [1-3]. Not a Markdown link "[x](…)", "[x][y]" or "[x]:".
43
+ MARK_RE = re.compile(r"\[(\^[\w-]+|\d{1,3}(?:\s*[-–,]\s*\d{1,3})*)\](?![(:\[])")
44
+ FENCE_RE = re.compile(r"^\s*(```|~~~)")
45
+ INLINE_CODE_RE = re.compile(r"`[^`]*`")
46
+ SENTENCE_END = re.compile(r"[。!?!?]|\.(?=\s)")
47
+
48
+ _TRAILING = ".,;:!?"
49
+
50
+
51
+ def _clean_id(raw: str) -> str:
52
+ raw = raw.rstrip(_TRAILING)
53
+ while raw.endswith(")") and raw.count(")") > raw.count("("):
54
+ raw = raw[:-1].rstrip(_TRAILING)
55
+ return raw
56
+
57
+
58
+ def _describe(segment: str) -> tuple[int | None, str | None, list[str], str]:
59
+ seg = LEADING_NOISE.sub("", segment).rstrip(" <[(")
60
+ year_m = YEAR_RE.search(seg)
61
+ title_m = TITLE_RE.search(seg)
62
+ title = title_m.group(1).strip().rstrip(".") if title_m else None
63
+ if title and JOURNAL_ONLY.match(title):
64
+ title = None
65
+ elif title and (m := TITLE_THEN_JOURNAL.match(title)):
66
+ title = m.group(1)
67
+ quotes = []
68
+ for rx in QUOTE_RES:
69
+ quotes += [q.strip() for q in rx.findall(seg)]
70
+ if title and any(title == q or q in title for q in quotes):
71
+ title = None
72
+ return (int(year_m.group(1)) if year_m else None), title or None, quotes, seg.strip()
73
+
74
+
75
+ def extract_references(text: str) -> list[Reference]:
76
+ refs: list[Reference] = []
77
+ for lineno, line in enumerate(text.splitlines(), start=1):
78
+ hits: list[tuple[int, int, str, str]] = [] # start, end, kind, id
79
+ for m in DOI_RE.finditer(line):
80
+ hits.append((m.start(), m.end(), "doi", _clean_id(m.group(1)).lower()))
81
+ for m in PMID_RE.finditer(line):
82
+ if not any(s <= m.start() < e for s, e, *_ in hits):
83
+ hits.append((m.start(), m.end(), "pmid", m.group(1) or m.group(2)))
84
+ for m in URL_RE.finditer(line):
85
+ if not any(s <= m.start() < e or m.start() <= s < m.end() for s, e, *_ in hits):
86
+ hits.append((m.start(), m.end(), "url", _clean_id(m.group(0))))
87
+ hits.sort()
88
+ prev_end = 0
89
+ for start, end, kind, ident in hits:
90
+ year, title, quotes, ctx = _describe(line[prev_end:start])
91
+ refs.append(Reference(kind, ident, lineno, year, title, quotes, ctx))
92
+ prev_end = end
93
+ _attach_numbered_quotes(text, refs)
94
+ return refs
95
+
96
+
97
+ def _labels(raw: str) -> list[str]:
98
+ """``1-3`` -> 1, 2, 3; ``1, 4`` -> 1, 4; ``^note`` -> ^note."""
99
+ if raw.startswith("^"):
100
+ return [raw]
101
+ out: list[str] = []
102
+ for part in re.split(r"\s*,\s*", raw):
103
+ m = re.fullmatch(r"(\d+)\s*[-–]\s*(\d+)", part)
104
+ if m and 0 < int(m[2]) - int(m[1]) < 50:
105
+ out += [str(i) for i in range(int(m[1]), int(m[2]) + 1)]
106
+ else:
107
+ out.append(part.strip())
108
+ return out
109
+
110
+
111
+ def _quotes_before(line: str, start: int, end: int) -> list[str]:
112
+ """Quotes in the sentence that the marker at ``end`` closes, not earlier than ``start``."""
113
+ spans = sorted(
114
+ (m.start(), m.end(), m.group(1).strip()) for rx in QUOTE_RES for m in rx.finditer(line, start, end)
115
+ )
116
+ for m in SENTENCE_END.finditer(line, start, end):
117
+ inside = any(s <= m.start() < e for s, e, _ in spans)
118
+ if not inside and line[m.end() : end].strip():
119
+ start = m.end() # a sentence ended before the marker; its quotes belong to something else
120
+ return [q for s, _, q in spans if s >= start]
121
+
122
+
123
+ def _attach_numbered_quotes(text: str, refs: list[Reference]) -> None:
124
+ lines = text.splitlines()
125
+ by_label: dict[str, Reference] = {}
126
+ defs: set[int] = set()
127
+ for lineno, line in enumerate(lines, start=1):
128
+ m = DEF_RE.match(line)
129
+ first = next((r for r in refs if r.line == lineno), None)
130
+ if m and first:
131
+ by_label.setdefault(m.group(1) or m.group(2), first)
132
+ defs.add(lineno)
133
+ if not by_label:
134
+ return
135
+ fenced = False
136
+ for lineno, line in enumerate(lines, start=1):
137
+ if FENCE_RE.match(line):
138
+ fenced = not fenced
139
+ continue
140
+ if fenced or lineno in defs:
141
+ continue
142
+ line = INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), line) # `x[1]` is code, not a citation
143
+ prev = 0
144
+ for m in MARK_RE.finditer(line):
145
+ quotes = _quotes_before(line, prev, m.start())
146
+ prev = m.end()
147
+ for label in _labels(m.group(1)):
148
+ ref = by_label.get(label)
149
+ if ref:
150
+ ref.quotes += [q for q in quotes if q not in ref.quotes]
claimcite/models.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class Reference:
8
+ """One citation as written in the document."""
9
+
10
+ kind: str # "doi" | "pmid" | "url"
11
+ id: str # normalised DOI (lower case), PMID digits, or the URL
12
+ line: int
13
+ year: int | None = None
14
+ title: str | None = None # title as the author wrote it
15
+ quotes: list[str] = field(default_factory=list) # text the author says comes from the source
16
+ context: str = "" # the citation text this was parsed from
17
+
18
+
19
+ @dataclass
20
+ class Record:
21
+ """What a registry (PubMed, Crossref) says the identifier points to."""
22
+
23
+ source: str
24
+ title: str | None = None
25
+ year: int | None = None
26
+ years: list[int] = field(default_factory=list) # every known publication year (online, print, issue)
27
+ doi: str | None = None
28
+ pmid: str | None = None
29
+ abstract: str | None = None
30
+ pub_types: list[str] = field(default_factory=list)
31
+ humans: bool = False
32
+ animals: bool = False
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Evidence:
37
+ key: str
38
+ rank: int # lower = stronger study design
39
+ label_en: str
40
+ label_zh: str
41
+
42
+
43
+ @dataclass
44
+ class WebResult:
45
+ """What a web-page lookup found (issue #1)."""
46
+
47
+ state: str # "alive" | "dead" | "blocked" | "failed"
48
+ status_code: int | None = None
49
+ detail: str | None = None
50
+ quotes: list[tuple[str, bool | None]] = field(default_factory=list)
51
+
52
+
53
+ @dataclass
54
+ class Finding:
55
+ ref: Reference
56
+ status: str # "ok" | "warning" | "error" | "unchecked"
57
+ exists: bool | None = None
58
+ record: Record | None = None
59
+ title_match: str = "not_stated" # match | partial | mismatch | not_stated | not_comparable | unknown
60
+ year_match: bool | None = None
61
+ quotes: list[tuple[str, bool | None]] = field(default_factory=list)
62
+ evidence: Evidence | None = None
63
+ error: str | None = None # set when a registry could not be reached
64
+ web: WebResult | None = None
claimcite/net.py ADDED
@@ -0,0 +1,61 @@
1
+ """The only place that touches the network. Swap `http_get` out in tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from urllib.parse import urlparse
10
+
11
+ from . import __version__
12
+
13
+ USER_AGENT = f"claimcite/{__version__} (+https://github.com/wasabihu/claimcite)"
14
+
15
+ # NCBI allows 3 requests/second without an API key; be polite to everyone.
16
+ _MIN_INTERVAL = {"eutils.ncbi.nlm.nih.gov": 0.4}
17
+ _next_slot: dict[str, float] = {}
18
+ _lock = threading.Lock()
19
+ # Web pages are fetched from many sites that did not sign up for this; one request a second each.
20
+ WEB_INTERVAL = 1.0
21
+ # Registry answers are small; anything bigger is a download we do not need in full.
22
+ MAX_BYTES = 10 * 1024 * 1024
23
+
24
+
25
+ def polite(host: str, interval: float = WEB_INTERVAL) -> None:
26
+ """Rate-limit a host that has no explicit limit yet."""
27
+ with _lock:
28
+ _MIN_INTERVAL.setdefault(host, interval)
29
+
30
+
31
+ class NotFound(Exception):
32
+ """The server answered 404."""
33
+
34
+
35
+ def http_get(url: str, timeout: float = 20.0, retries: int = 3) -> str:
36
+ host = urlparse(url).netloc
37
+ for attempt in range(retries + 1):
38
+ with _lock: # reserve the next free slot for this host, safe across threads
39
+ now = time.monotonic()
40
+ slot = max(now, _next_slot.get(host, 0))
41
+ _next_slot[host] = slot + _MIN_INTERVAL.get(host, 0)
42
+ if slot > now:
43
+ time.sleep(slot - now)
44
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
45
+ try:
46
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
47
+ return resp.read(MAX_BYTES).decode("utf-8", errors="replace")
48
+ except urllib.error.HTTPError as e:
49
+ if e.code == 404:
50
+ raise NotFound(url) from e
51
+ if e.code in (429, 500, 502, 503, 504) and attempt < retries:
52
+ retry_after = e.headers.get("Retry-After", "") if e.headers else ""
53
+ time.sleep(max(2**attempt, int(retry_after) if retry_after.isdigit() else 0))
54
+ continue
55
+ raise
56
+ except urllib.error.URLError:
57
+ if attempt < retries:
58
+ time.sleep(2**attempt)
59
+ continue
60
+ raise
61
+ raise RuntimeError("unreachable")
claimcite/report.py ADDED
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections import Counter
5
+
6
+ from .models import Finding
7
+
8
+ STATUSES = ("ok", "warning", "error", "unchecked")
9
+
10
+ LABELS = {
11
+ "en": {
12
+ "title": "Citation check",
13
+ "summary": "{ok} ok · {warning} warnings · {error} errors · {unchecked} not checked",
14
+ "ok": "OK",
15
+ "warning": "Warning",
16
+ "error": "Error",
17
+ "unchecked": "Not checked",
18
+ "line": "line",
19
+ "missing": "Identifier does not exist",
20
+ "title_mismatch": "Title does not match this identifier. Registry title: {t}",
21
+ "title_partial": "Title only partly matches. Registry title: {t}",
22
+ "year": "Year differs: document {a}, registry {b}",
23
+ "quote_missing": "Quoted text not found in the abstract (it may be in the full text): “{q}”",
24
+ "quote_unknown": "No abstract available to check the quote: “{q}”",
25
+ "web": "Web page: not verified yet",
26
+ "web_alive": "Web page is reachable",
27
+ "web_dead": "Web page could not be opened (HTTP {code})",
28
+ "web_blocked": "Skipped: robots.txt disallows automated fetching",
29
+ "web_failed": "Web page lookup failed ({e}); not checked",
30
+ "web_quote_missing": "Quoted text not found on the page: “{q}”",
31
+ "web_quote_unknown": "Could not read the page text (e.g. a PDF); quoted text not checked: “{q}”",
32
+ "failed": "Lookup failed, try again later ({e})",
33
+ "evidence": "Study type (as tagged by PubMed)",
34
+ "disclaimer": "Automated check. It confirms that sources exist and match; it does not judge "
35
+ "whether a claim is true and is not medical advice.",
36
+ },
37
+ "zh": {
38
+ "title": "引用核查",
39
+ "summary": "通过 {ok} · 警告 {warning} · 错误 {error} · 未核查 {unchecked}",
40
+ "ok": "通过",
41
+ "warning": "警告",
42
+ "error": "错误",
43
+ "unchecked": "未核查",
44
+ "line": "第 {n} 行",
45
+ "missing": "编号不存在",
46
+ "title_mismatch": "标题与该编号对应的文献不符。登记的标题:{t}",
47
+ "title_partial": "标题只部分吻合。登记的标题:{t}",
48
+ "year": "年份不一致:文中 {a},登记 {b}",
49
+ "quote_missing": "引文在摘要中没有找到(可能在全文里):「{q}」",
50
+ "quote_unknown": "没有摘要,无法核对引文:「{q}」",
51
+ "web": "网页链接:暂不核查",
52
+ "web_alive": "网页可访问",
53
+ "web_dead": "网页打不开(HTTP {code})",
54
+ "web_blocked": "已跳过:robots.txt 不允许自动抓取",
55
+ "web_failed": "网页查询失败({e}),未核查",
56
+ "web_quote_missing": "页面正文中未找到引文:「{q}」",
57
+ "web_quote_unknown": "读不到页面文字(比如 PDF),引文未核对:「{q}」",
58
+ "failed": "查询失败,请稍后重试({e})",
59
+ "evidence": "研究类型(PubMed 标注)",
60
+ "disclaimer": "自动核查只确认出处存在且对得上,不判断说法本身是否正确,也不构成医疗建议。",
61
+ },
62
+ }
63
+
64
+
65
+ def summary(findings: list[Finding]) -> dict[str, int]:
66
+ c = Counter(f.status for f in findings)
67
+ return {s: c.get(s, 0) for s in STATUSES}
68
+
69
+
70
+ def _problems(f: Finding, L: dict[str, str]) -> list[str]:
71
+ if f.ref.kind == "url":
72
+ w = f.web
73
+ if w is None:
74
+ return [L["web"]]
75
+ if w.state == "blocked":
76
+ return [L["web_blocked"]]
77
+ if w.state == "failed":
78
+ return [L["web_failed"].format(e=w.detail or "error")]
79
+ if w.state == "dead":
80
+ return [L["web_dead"].format(code=w.status_code or "?")]
81
+ out = [L["web_alive"]]
82
+ out += [L["web_quote_missing"].format(q=q) for q, ok in w.quotes if ok is False]
83
+ out += [L["web_quote_unknown"].format(q=q) for q, ok in w.quotes if ok is None]
84
+ return out
85
+ if f.error:
86
+ return [L["failed"].format(e=f.error)]
87
+ if f.exists is False:
88
+ return [L["missing"]]
89
+ out = []
90
+ title = f.record.title if f.record else None
91
+ if f.title_match == "mismatch":
92
+ out.append(L["title_mismatch"].format(t=title))
93
+ elif f.title_match == "partial":
94
+ out.append(L["title_partial"].format(t=title))
95
+ if f.year_match is False:
96
+ out.append(L["year"].format(a=f.ref.year, b=f.record.year))
97
+ for q, ok in f.quotes:
98
+ if ok is False:
99
+ out.append(L["quote_missing"].format(q=q))
100
+ elif ok is None:
101
+ out.append(L["quote_unknown"].format(q=q))
102
+ return out
103
+
104
+
105
+ def to_markdown(findings: list[Finding], lang: str = "en") -> str:
106
+ L = LABELS[lang]
107
+ lines = [f"# {L['title']}", "", L["summary"].format(**summary(findings)), ""]
108
+ for f in findings:
109
+ where = L["line"].format(n=f.ref.line) if lang == "zh" else f"{L['line']} {f.ref.line}"
110
+ lines.append(f"- **{L[f.status]}** · {where} · `{f.ref.kind}:{f.ref.id}`")
111
+ if f.record and f.record.title:
112
+ lines.append(f" - {f.record.title} ({f.record.year or '?'})")
113
+ if f.evidence and f.evidence.key != "unknown":
114
+ label = f.evidence.label_zh if lang == "zh" else f.evidence.label_en
115
+ lines.append(f" - {L['evidence']}{':' if lang == 'zh' else ': '}{label}")
116
+ lines += [f" - {p}" for p in _problems(f, L)]
117
+ lines += ["", f"_{L['disclaimer']}_", ""]
118
+ return "\n".join(lines)
119
+
120
+
121
+ def to_json(findings: list[Finding]) -> str:
122
+ def one(f: Finding) -> dict:
123
+ r = f.record
124
+ return {
125
+ "kind": f.ref.kind,
126
+ "id": f.ref.id,
127
+ "line": f.ref.line,
128
+ "status": f.status,
129
+ "exists": f.exists,
130
+ "stated": {"title": f.ref.title, "year": f.ref.year, "quotes": f.ref.quotes},
131
+ "registry": None
132
+ if r is None
133
+ else {
134
+ "source": r.source,
135
+ "title": r.title,
136
+ "year": r.year,
137
+ "doi": r.doi,
138
+ "pmid": r.pmid,
139
+ "publication_types": r.pub_types,
140
+ },
141
+ "title_match": f.title_match,
142
+ "year_match": f.year_match,
143
+ "quotes": [{"text": q, "found_in_abstract": ok} for q, ok in f.quotes],
144
+ "evidence": f.evidence.key if f.evidence else None,
145
+ "error": f.error,
146
+ "web": None
147
+ if f.web is None
148
+ else {
149
+ "state": f.web.state,
150
+ "status_code": f.web.status_code,
151
+ "quotes": [{"text": q, "found": ok} for q, ok in f.web.quotes],
152
+ },
153
+ }
154
+
155
+ return json.dumps(
156
+ {"summary": summary(findings), "findings": [one(f) for f in findings]}, ensure_ascii=False, indent=2
157
+ )
claimcite/sources.py ADDED
@@ -0,0 +1,209 @@
1
+ """Look identifiers up in public registries.
2
+
3
+ - doi.org handle API: does the DOI exist at all (any registrar)?
4
+ - PubMed E-utilities: title, year, abstract, publication types (biomedical papers)
5
+ - Crossref: title and year for DOIs that are not in PubMed
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import json
12
+ import os
13
+ import re
14
+ import xml.etree.ElementTree as ET
15
+ from collections.abc import Callable
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from urllib.parse import quote
18
+
19
+ from .models import Record
20
+ from .net import NotFound, http_get
21
+
22
+ EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
23
+ _TAG = re.compile(r"<[^>]+>")
24
+
25
+
26
+ def _text(el: ET.Element | None) -> str | None:
27
+ if el is None:
28
+ return None
29
+ return re.sub(r"\s+", " ", "".join(el.itertext())).strip() or None
30
+
31
+
32
+ def parse_pubmed(xml: str) -> dict[str, Record]:
33
+ out: dict[str, Record] = {}
34
+ for art in ET.fromstring(xml).iter("PubmedArticle"):
35
+ pmid = art.findtext("MedlineCitation/PMID")
36
+ if not pmid:
37
+ continue
38
+ year = art.findtext(".//Article/Journal/JournalIssue/PubDate/Year") or art.findtext(
39
+ ".//Article/ArticleDate/Year"
40
+ )
41
+ if not year:
42
+ medline = art.findtext(".//PubDate/MedlineDate") or ""
43
+ year = medline[:4] if medline[:4].isdigit() else None
44
+ years = []
45
+ for y in (year, art.findtext(".//Article/ArticleDate/Year")):
46
+ if y and int(y) not in years:
47
+ years.append(int(y))
48
+ doi = next(
49
+ (i.text for i in art.iterfind("PubmedData/ArticleIdList/ArticleId") if i.get("IdType") == "doi"),
50
+ None,
51
+ ) or next((e.text for e in art.iter("ELocationID") if e.get("EIdType") == "doi"), None)
52
+ abstract = " ".join(filter(None, (_text(a) for a in art.iter("AbstractText")))) or None
53
+ mesh = {d.text for d in art.iter("DescriptorName")}
54
+ out[pmid] = Record(
55
+ source="pubmed",
56
+ title=_text(art.find(".//Article/ArticleTitle")),
57
+ year=int(year) if year else None,
58
+ years=years,
59
+ doi=doi.lower() if doi else None,
60
+ pmid=pmid,
61
+ abstract=abstract,
62
+ pub_types=[p.text for p in art.iter("PublicationType") if p.text],
63
+ humans="Humans" in mesh,
64
+ animals="Animals" in mesh,
65
+ )
66
+ return out
67
+
68
+
69
+ def clean_markup(s: str | None) -> str | None:
70
+ """Crossref titles and abstracts carry JATS/HTML markup, sometimes escaped twice."""
71
+ if not s:
72
+ return None
73
+ s = html.unescape(html.unescape(s))
74
+ s = re.sub(r"<xref\b[^>]*>.*?</xref>", "", s, flags=re.DOTALL) # footnote markers
75
+ s = re.sub(r"\s*(<su[bp]\b)", r"\1", s) # "D <sub>3</sub>" -> "D3"
76
+ s = re.sub(r"<(?:jats:)?(?:p|title|sec)\b[^>]*>", " ", s) # block tags separate words
77
+ return re.sub(r"\s+", " ", _TAG.sub("", s)).strip() or None
78
+
79
+
80
+ def parse_crossref(payload: str) -> Record:
81
+ msg = json.loads(payload)["message"]
82
+ years = []
83
+ for key in ("issued", "published-print", "published-online", "published"):
84
+ y = ((msg.get(key) or {}).get("date-parts") or [[None]])[0][0]
85
+ if y and y not in years:
86
+ years.append(y)
87
+ return Record(
88
+ source="crossref",
89
+ title=clean_markup((msg.get("title") or [None])[0]),
90
+ year=years[0] if years else None,
91
+ years=years,
92
+ doi=(msg.get("DOI") or "").lower() or None,
93
+ abstract=clean_markup(msg.get("abstract")),
94
+ )
95
+
96
+
97
+ class LookupFailed(Exception):
98
+ """A registry could not be reached; the citation is neither confirmed nor refuted."""
99
+
100
+
101
+ def _chunks(items: list[str], size: int):
102
+ for i in range(0, len(items), size):
103
+ yield items[i : i + size]
104
+
105
+
106
+ def _safe(fn):
107
+ def run(arg):
108
+ try:
109
+ return fn(arg)
110
+ except Exception as e: # noqa: BLE001 - one bad lookup must not sink the whole report
111
+ return LookupFailed(f"{type(e).__name__}: {e}")
112
+
113
+ return run
114
+
115
+
116
+ class Lookup:
117
+ """Resolves DOIs and PMIDs to Records, with an in-memory cache.
118
+
119
+ Call `prefetch` with everything in a document first: it batches PubMed requests
120
+ (NCBI allows 3 requests/second) and runs the other registries in parallel.
121
+ """
122
+
123
+ def __init__(self, http: Callable[[str], str] = http_get, workers: int = 8):
124
+ self.http = http
125
+ self.workers = workers
126
+ self._cache: dict[tuple[str, str], Record | LookupFailed | None] = {}
127
+ key = os.environ.get("NCBI_API_KEY")
128
+ self._ncbi_key = f"&api_key={key}" if key else ""
129
+
130
+ def doi(self, doi: str) -> Record | None:
131
+ return self._get("doi", doi)
132
+
133
+ def pmid(self, pmid: str) -> Record | None:
134
+ return self._get("pmid", pmid)
135
+
136
+ def _get(self, kind: str, ident: str) -> Record | None:
137
+ if (kind, ident) not in self._cache:
138
+ self.prefetch(**{f"{kind}s": [ident]})
139
+ value = self._cache[(kind, ident)]
140
+ if isinstance(value, LookupFailed):
141
+ raise value
142
+ return value
143
+
144
+ def prefetch(self, dois: list[str] = (), pmids: list[str] = ()) -> None:
145
+ dois = [d for d in dict.fromkeys(dois) if ("doi", d) not in self._cache]
146
+ pmids = [p for p in dict.fromkeys(pmids) if ("pmid", p) not in self._cache]
147
+ if not dois and not pmids:
148
+ return
149
+
150
+ # 1. Does each DOI exist at all? doi.org knows every registrar.
151
+ with ThreadPoolExecutor(self.workers) as pool:
152
+ exists = dict(zip(dois, pool.map(_safe(self._doi_exists), dois)))
153
+ live = []
154
+ for d, e in exists.items():
155
+ if e is True:
156
+ live.append(d)
157
+ else:
158
+ self._cache[("doi", d)] = e if isinstance(e, LookupFailed) else None
159
+
160
+ # 2. PubMed, in batches: DOIs -> PMIDs, then one efetch for all PMIDs.
161
+ records: dict[str, Record] = {}
162
+ pubmed_error = None
163
+ try:
164
+ ids = list(pmids)
165
+ for chunk in _chunks(live, 40):
166
+ term = quote(" OR ".join(f"{d}[doi]" for d in chunk), safe="")
167
+ found = json.loads(
168
+ self.http(
169
+ f"{EUTILS}/esearch.fcgi?db=pubmed&term={term}&retmode=json&retmax=200{self._ncbi_key}"
170
+ )
171
+ )
172
+ ids += found.get("esearchresult", {}).get("idlist", [])
173
+ for chunk in _chunks(list(dict.fromkeys(ids)), 150):
174
+ records.update(
175
+ parse_pubmed(
176
+ self.http(
177
+ f"{EUTILS}/efetch.fcgi?db=pubmed&id={','.join(chunk)}&retmode=xml{self._ncbi_key}"
178
+ )
179
+ )
180
+ )
181
+ except Exception as e: # noqa: BLE001
182
+ pubmed_error = LookupFailed(f"PubMed: {type(e).__name__}: {e}")
183
+
184
+ for p in pmids:
185
+ self._cache[("pmid", p)] = records.get(p) if pubmed_error is None else pubmed_error
186
+ by_doi = {r.doi: r for r in records.values() if r.doi}
187
+ rest = [d for d in live if d not in by_doi]
188
+ for d in live:
189
+ if d in by_doi:
190
+ self._cache[("doi", d)] = by_doi[d]
191
+
192
+ # 3. Everything else: Crossref, in parallel.
193
+ with ThreadPoolExecutor(self.workers) as pool:
194
+ for d, r in zip(rest, pool.map(_safe(self._crossref), rest)):
195
+ self._cache[("doi", d)] = r
196
+
197
+ def _doi_exists(self, doi: str) -> bool:
198
+ try:
199
+ handle = json.loads(self.http(f"https://doi.org/api/handles/{quote(doi, safe='/')}"))
200
+ except NotFound:
201
+ return False
202
+ return handle.get("responseCode") == 1
203
+
204
+ def _crossref(self, doi: str) -> Record:
205
+ try:
206
+ return parse_crossref(self.http(f"https://api.crossref.org/works/{quote(doi, safe='/')}"))
207
+ except NotFound:
208
+ # Registered (e.g. DataCite, CNKI) but no metadata we can read yet.
209
+ return Record(source="doi.org", doi=doi)
claimcite/text.py ADDED
@@ -0,0 +1,34 @@
1
+ """Shared text normalisation for both registry and web-page checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ CJK = re.compile(r"[\u3400-\u9fff]")
9
+ ELLIPSIS = re.compile(r"\s*(?:…+|\.\.\.+)\s*")
10
+
11
+
12
+ def norm(s: str) -> str:
13
+ s = unicodedata.normalize("NFKC", s).lower().replace("·", ".").replace("×", "x")
14
+ s = re.sub(r"[‐-―−]", "-", s)
15
+ return re.sub(r"\s+", " ", s).strip()
16
+
17
+
18
+ def words(s: str) -> str:
19
+ return re.sub(r"[^\w]+", " ", norm(s)).strip()
20
+
21
+
22
+ def letters(s: str) -> str:
23
+ """Only letters and digits: quotation-mark style, punctuation and spacing differ
24
+ between a page and a faithful quote of it (“ vs ", "26 个" vs "26个", "( NSAIDs )")."""
25
+ return re.sub(r"[\W_]+", "", norm(s))
26
+
27
+
28
+ def quote_found(quote: str, haystack: str | None) -> bool | None:
29
+ """True/False when we could look, None when there was nothing to look in."""
30
+ if not haystack:
31
+ return None
32
+ hay = letters(haystack)
33
+ parts = [letters(part) for part in ELLIPSIS.split(quote)]
34
+ return all(part in hay for part in parts if part)
claimcite/web.py ADDED
@@ -0,0 +1,93 @@
1
+ """Check web-page citations: is the page alive, and does it contain the quoted text?
2
+
3
+ Deliberately conservative: a dead link or a missing quote is a *warning*, never an
4
+ error. Pages change, some sites block bots, and we must not cry wolf.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from html.parser import HTMLParser
11
+ from typing import ClassVar
12
+ from urllib.parse import urlparse, urlunparse
13
+ from urllib.robotparser import RobotFileParser
14
+
15
+ from .models import Reference, WebResult
16
+ from .net import USER_AGENT, NotFound, polite
17
+ from .text import quote_found
18
+
19
+
20
+ class _TextExtractor(HTMLParser):
21
+ """Collect visible text, skipping script/style/noscript/template bodies."""
22
+
23
+ _SKIP: ClassVar[set[str]] = {"script", "style", "noscript", "template"}
24
+
25
+ def __init__(self) -> None:
26
+ super().__init__(convert_charrefs=True)
27
+ self.parts: list[str] = []
28
+ self._depth = 0
29
+
30
+ def handle_starttag(self, tag, attrs):
31
+ if tag.lower() in self._SKIP:
32
+ self._depth += 1
33
+
34
+ def handle_endtag(self, tag):
35
+ if tag.lower() in self._SKIP and self._depth:
36
+ self._depth -= 1
37
+
38
+ def handle_data(self, data):
39
+ if not self._depth:
40
+ self.parts.append(data)
41
+
42
+ def text(self) -> str:
43
+ return " ".join(" ".join(self.parts).split())
44
+
45
+
46
+ def _robots_url(url: str) -> str:
47
+ p = urlparse(url)
48
+ return urlunparse((p.scheme, p.netloc, "/robots.txt", "", "", ""))
49
+
50
+
51
+ def _robots_allows(url: str, http: Callable[[str], str]) -> bool:
52
+ """Only an explicit disallow blocks us; a missing robots.txt never does."""
53
+ try:
54
+ body = http(_robots_url(url))
55
+ except Exception: # noqa: BLE001 - an unreachable robots.txt must not block the check
56
+ return True
57
+ parser = RobotFileParser()
58
+ try:
59
+ parser.parse(body.splitlines())
60
+ except Exception: # noqa: BLE001 - malformed robots.txt is treated as absent
61
+ return True
62
+ return parser.can_fetch(USER_AGENT, url)
63
+
64
+
65
+ def _is_binary(body: str) -> bool:
66
+ """PDFs and other downloads: we cannot read their text yet, so do not search them."""
67
+ head = body.lstrip("\ufeff \t\r\n")[:1024]
68
+ return head.startswith("%PDF-") or "\x00" in head
69
+
70
+
71
+ def check_page(ref: Reference, http: Callable[[str], str]) -> WebResult:
72
+ unknown = [(q, None) for q in ref.quotes]
73
+ polite(urlparse(ref.id).netloc)
74
+ if not _robots_allows(ref.id, http):
75
+ return WebResult(state="blocked", quotes=unknown)
76
+ try:
77
+ body = http(ref.id)
78
+ except NotFound:
79
+ return WebResult(state="dead", status_code=404, quotes=unknown)
80
+ except Exception as e: # noqa: BLE001 - one bad page must not sink the whole report
81
+ return WebResult(state="failed", detail=type(e).__name__, quotes=unknown)
82
+
83
+ if _is_binary(body):
84
+ return WebResult(state="alive", detail="binary", quotes=unknown)
85
+
86
+ parser = _TextExtractor()
87
+ try:
88
+ parser.feed(body)
89
+ page_text = parser.text()
90
+ except Exception: # noqa: BLE001 - malformed HTML: fall back to the raw text
91
+ page_text = body
92
+ quotes = [(q, quote_found(q, page_text)) for q in ref.quotes]
93
+ return WebResult(state="alive", quotes=quotes)
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.5
2
+ Name: claimcite
3
+ Version: 0.1.0
4
+ Summary: Check that every citation in a document is real, matches what the text says it is, and supports the quoted words.
5
+ Project-URL: Homepage, https://github.com/wasabihu/claimcite
6
+ Project-URL: Issues, https://github.com/wasabihu/claimcite/issues
7
+ Project-URL: Changelog, https://github.com/wasabihu/claimcite/blob/main/CHANGELOG.md
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: citation,crossref,doi,evidence-based,fact-checking,hallucination,pubmed
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
20
+ Classifier: Topic :: Text Processing
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # claimcite
25
+
26
+ **English** · [中文](README.zh-CN.md)
27
+
28
+ Check that every citation in a document is real, points to the paper the text says it does, and actually contains the words quoted from it.
29
+
30
+ AI-written articles, health advice and even papers increasingly cite sources that don't exist, or attach a real DOI to the wrong paper. Readers can't check dozens of references by hand. claimcite does it in one command.
31
+
32
+ ```console
33
+ $ claimcite check examples/sample.md
34
+ # Citation check
35
+
36
+ 1 ok · 2 warnings · 2 errors · 0 not checked
37
+
38
+ - **Error** · line 7 · `doi:10.1016/s0140-6736(10)61388-8`
39
+ - Title does not match this identifier. Registry title: Worldwide burden of disease from
40
+ exposure to second-hand smoke: a retrospective analysis of data from 192 countries.
41
+ - Year differs: document 2021, registry 2011
42
+ - **Error** · line 9 · `doi:10.1001/jama.2022.99999`
43
+ - Identifier does not exist
44
+ - **Warning** · line 11 · `pmid:33237064`
45
+ - Study type (as tagged by PubMed): Systematic review / meta-analysis
46
+ - Quoted text not found in the abstract (it may be in the full text): “calcium and vitamin D
47
+ supplementation cured osteoporosis in all women”
48
+ ...
49
+ ```
50
+
51
+ ## What it checks today
52
+
53
+ | Check | How |
54
+ |---|---|
55
+ | The DOI / PMID exists | doi.org handle API (every registrar), PubMed |
56
+ | The title written in the text matches the identifier | PubMed, then Crossref; fuzzy match |
57
+ | The year matches (±1 for online-first vs print) | same |
58
+ | Text in quotes (`"…"`, `“…”`, `「…」`) appears in the abstract | PubMed / Crossref abstract |
59
+ | Study type: meta-analysis, RCT, observational, animal… | PubMed publication types and MeSH |
60
+ | A web link is alive and contains the quoted text | fetch the page (respecting `robots.txt`), strip HTML, normalise whitespace |
61
+
62
+ It reads Markdown or plain text and understands the common
63
+ `Author (Year). Title. Journal: "quote". https://doi.org/…` pattern, plus `doi:…`, bare DOIs,
64
+ `PMID: …` and PubMed links. Numbered citations work too: a quote in the text followed by `[1]`, `[^1]`,
65
+ `[1, 2]` or `[1-3]` is checked against the matching entry in the reference list (`[1] …`, `1. …` or
66
+ `[^1]: …`). Code blocks and inline code are skipped. Other web links are fetched (respecting `robots.txt`) and checked for
67
+ availability and for the quoted text. A dead link or a missing quote is a **warning**, never an error —
68
+ pages change and some sites block bots. When a page cannot be read the link stays *not checked*.
69
+
70
+ **What it does not do (yet):** decide whether a source *supports* a claim. That needs a language model and a
71
+ labelled test set. See the roadmap. claimcite only confirms that sources exist and match what the text says
72
+ about them. It is not medical advice.
73
+
74
+ ## Install and run
75
+
76
+ Requires Python 3.10+. No API keys needed.
77
+
78
+ ```bash
79
+ pipx install claimcite # or: uv tool install claimcite
80
+ claimcite check article.md # Markdown report
81
+ claimcite check article.md --lang zh # 中文报告
82
+ claimcite check article.md --format json # for scripts and CI
83
+ cat article.md | claimcite check -
84
+ ```
85
+
86
+ The exit code is 1 when any citation has an error, so it works as a CI gate (`--fail-on warning|never` to tune).
87
+ Set `NCBI_API_KEY` to raise PubMed's rate limit.
88
+
89
+ ### Lookup cache
90
+
91
+ Successful registry responses are cached on disk for **30 days** (default `~/.cache/claimcite/v1/`,
92
+ or `$XDG_CACHE_HOME/claimcite/v1/`), so re-running after a one-line edit does not repeat every
93
+ request. Lookup failures are never cached. Use `--no-cache` to bypass the cache for one run.
94
+
95
+ Web pages are fetched at most once a second per site, so the first run over a document with hundreds of
96
+ links takes minutes. Use `--no-web` to check only DOIs and PMIDs. Quotes in PDFs are not checked yet.
97
+
98
+ ### In CI: GitHub Action
99
+
100
+ ```yaml
101
+ # .github/workflows/citations.yml
102
+ on: [push, pull_request]
103
+ jobs:
104
+ claimcite:
105
+ runs-on: ubuntu-latest
106
+ steps:
107
+ - uses: actions/checkout@v4
108
+ - uses: wasabihu/claimcite@v0.1.0
109
+ with:
110
+ files: "docs/**/*.md" # default: every Markdown file
111
+ fail-on: error # or: warning, never
112
+ web: "true" # "false" checks DOIs and PMIDs only
113
+ ```
114
+
115
+ The report also appears in the job summary. Runs on Linux and macOS runners.
116
+
117
+ ### Before each commit: pre-commit
118
+
119
+ ```yaml
120
+ # .pre-commit-config.yaml
121
+ repos:
122
+ - repo: https://github.com/wasabihu/claimcite
123
+ rev: v0.1.0
124
+ hooks:
125
+ - id: claimcite
126
+ # args: [--no-web] # faster: skip ordinary web links
127
+ ```
128
+
129
+ Every changed Markdown file is checked; the commit stops when a citation has an error. You can also pass
130
+ several files on the command line: `claimcite check docs/*.md`.
131
+
132
+ ## Roadmap
133
+
134
+ Each item is a self-contained piece of work. Pick one and open an issue to say you're on it.
135
+
136
+ - [x] **Web links**: check that the page is alive and contains the quoted text
137
+ - [ ] **More registries**: OpenAlex, Semantic Scholar, arXiv, CNKI/Wanfang metadata, DataCite
138
+ - [ ] **Retractions**: flag retracted papers (Crossref / Retraction Watch data)
139
+ - [ ] **Full text**: look for quotes in open-access full text (PubMed Central, Europe PMC), not only abstracts
140
+ - [ ] **Support check**: does the source support the sentence? Pluggable LLM judge, local models first
141
+ - [ ] **Benchmark**: a public, labelled set of real and fabricated citations to measure every check
142
+ - [x] **Numbered citations**: `[1]`, `[1-3]` and footnotes `[^1]` linked to the reference list
143
+ - [ ] **More input formats**: BibTeX, Word / PDF input
144
+ - [x] **Integrations**: GitHub Action, pre-commit hook
145
+ - [ ] Browser extension
146
+ - [x] **Caching** between runs (30-day on-disk cache, `--no-cache` to bypass)
147
+ - [ ] Faster batch lookups
148
+
149
+ ## Contributing
150
+
151
+ Contributions of every size are welcome, including AI-assisted ones. See [CONTRIBUTING.md](CONTRIBUTING.md).
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1,18 @@
1
+ claimcite/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ claimcite/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ claimcite/cache.py,sha256=6wOECPIe4GOpRDHv8AuKqC_zjyr23j6u2RmwQ57xbps,3839
4
+ claimcite/check.py,sha256=uu00GYFIYy38wkncchpl27ouW-OaM-M42RMl9a12HeI,3228
5
+ claimcite/cli.py,sha256=WtJB_87PV4sVg89GLh9mIJXFmMEUOPXGIrHhonBFpS8,2578
6
+ claimcite/evidence.py,sha256=242-N6DfVdET1cCRgc0WKn9zSmd8Rmn0s6ybhsoBI9Q,1943
7
+ claimcite/extract.py,sha256=0cMuxGhddHppJ5Wly9PMn8R3YIgdIUsM9y13yreR-lQ,6618
8
+ claimcite/models.py,sha256=uGQM_2YIanPkF4-vfzzztCBC8YRAT40j4DVmnVLY7wo,1950
9
+ claimcite/net.py,sha256=8Kz2Y8BnCO1S278cQ3WUwdN2kZ7fuPv0zTdDHrauaW4,2281
10
+ claimcite/report.py,sha256=JzjXZxmzDs_dH-fQXvp_5VVcovGdMb2immC5N8MzuyI,6759
11
+ claimcite/sources.py,sha256=qVjQu9J5rN9Q78oi8rQJrluwvC1NZhP-MHdPQMsWTFo,7944
12
+ claimcite/text.py,sha256=ljAuav1rASJ0McRlNUv19i1g1oNExoI4dkhfWeWxjXk,1086
13
+ claimcite/web.py,sha256=72jndrWvQeHAEOrcE8hlWEoiA81LekINLL7CIGX92xs,3203
14
+ claimcite-0.1.0.dist-info/METADATA,sha256=ccy3BiXc8l7IWJoD9eA8_pjgzV88ckoaLsYf6BTxy5Q,6925
15
+ claimcite-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
16
+ claimcite-0.1.0.dist-info/entry_points.txt,sha256=UTZcBv29D6YtyLoY3LqUDXjej-Sww1bqRpQujANf_nA,49
17
+ claimcite-0.1.0.dist-info/licenses/LICENSE,sha256=kNZjGIeNgVNpjFFuYCgx3v7FlDy6g_f4861-K_zpEF8,1079
18
+ claimcite-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ claimcite = claimcite.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 claimcite contributors
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.