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 +1 -0
- claimcite/__main__.py +5 -0
- claimcite/cache.py +118 -0
- claimcite/check.py +82 -0
- claimcite/cli.py +63 -0
- claimcite/evidence.py +47 -0
- claimcite/extract.py +150 -0
- claimcite/models.py +64 -0
- claimcite/net.py +61 -0
- claimcite/report.py +157 -0
- claimcite/sources.py +209 -0
- claimcite/text.py +34 -0
- claimcite/web.py +93 -0
- claimcite-0.1.0.dist-info/METADATA +155 -0
- claimcite-0.1.0.dist-info/RECORD +18 -0
- claimcite-0.1.0.dist-info/WHEEL +4 -0
- claimcite-0.1.0.dist-info/entry_points.txt +2 -0
- claimcite-0.1.0.dist-info/licenses/LICENSE +21 -0
claimcite/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
claimcite/__main__.py
ADDED
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,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.
|