touchneedle 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.
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: touchneedle
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify that every citation and reference in a document is real, accurately described, and consistently used.
|
|
5
|
+
Author: ncoleman
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nicoleman0/touchneedle
|
|
8
|
+
Project-URL: Source, https://github.com/nicoleman0/touchneedle
|
|
9
|
+
Project-URL: Changelog, https://github.com/nicoleman0/touchneedle/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/nicoleman0/touchneedle/issues
|
|
11
|
+
Keywords: citations,references,bibliography,academic,fact-checking
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# touchneedle
|
|
29
|
+
|
|
30
|
+
Citiation tool that verifies the citations in a document are real,
|
|
31
|
+
accurately described, and consistently used.
|
|
32
|
+
|
|
33
|
+
Useful for both students and examiners who wish to corroborate citations.
|
|
34
|
+
|
|
35
|
+
It works standalone, or as a coding agent skill.
|
|
36
|
+
|
|
37
|
+
Most commercial citation checkers want a `.bib` file and check it against academic
|
|
38
|
+
databases. That covers journal articles but misses standards,
|
|
39
|
+
specifications, vendor documentation, and blog posts. In a lot of real
|
|
40
|
+
bibliographies, this is half the list.
|
|
41
|
+
|
|
42
|
+
So this tool parses a **prose reference list** (Harvard/author-date)
|
|
43
|
+
straight out of Markdown or `.docx`, and routes each entry to whichever
|
|
44
|
+
authority can actually confirm it.
|
|
45
|
+
|
|
46
|
+
## What it checks
|
|
47
|
+
|
|
48
|
+
**Existence and metadata** — scripted and deterministic:
|
|
49
|
+
|
|
50
|
+
| Entry carries | Checked against |
|
|
51
|
+
|---|---|
|
|
52
|
+
| arXiv id | arXiv API |
|
|
53
|
+
| DOI | Crossref |
|
|
54
|
+
| RFC number | IETF datatracker, falling back to rfc-editor |
|
|
55
|
+
| `draft-*` name | IETF datatracker, **including whether the cited revision is still current** |
|
|
56
|
+
| Quoted title in an academic venue | Crossref, then OpenAlex, by title |
|
|
57
|
+
| A URL and nothing else | Fetched live; page title compared with the cited title |
|
|
58
|
+
|
|
59
|
+
Entries with both an identifier and a URL get both, so a real paper behind a dead
|
|
60
|
+
link is still reported. Detects the fabricated-citation signature — a real title
|
|
61
|
+
carrying the wrong authors — as `MISMATCH`.
|
|
62
|
+
|
|
63
|
+
**Internal consistency** — every in-text citation resolves to a list entry, every
|
|
64
|
+
list entry is cited somewhere, and `2025a`/`2025b` suffixes are used unambiguously.
|
|
65
|
+
|
|
66
|
+
**Claim support** — the pass that needs reading rather than fetching. `claims`
|
|
67
|
+
emits a worklist pairing each in-text citation with the sentence making the claim
|
|
68
|
+
and a locator for the source; the model then reads each source and rules
|
|
69
|
+
SUPPORTED / PARTIAL / UNSUPPORTED / INACCESSIBLE. This catches the failure the
|
|
70
|
+
database checks cannot: a genuine source attached to a claim it does not make.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
As a command-line tool:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install touchneedle
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
As a Claude Code skill:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://github.com/nicoleman0/touchneedle ~/.claude/skills/touchneedle
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or as a Claude Code plugin:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
/plugin marketplace add nicoleman0/touchneedle
|
|
90
|
+
/plugin install touchneedle
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
There are no dependencies beyond Python 3.11+. `pandoc` is needed only for `.docx` input.
|
|
94
|
+
|
|
95
|
+
Then, in Claude Code: *"check the citations in thesis.docx"*.
|
|
96
|
+
|
|
97
|
+
## Use directly
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
touchneedle check thesis.docx --out report.md --json data.json
|
|
101
|
+
touchneedle claims thesis.docx --out claims.md
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
From a clone, without installing, that is `python3 scripts/touchneedle.py …` —
|
|
105
|
+
the same file either way.
|
|
106
|
+
|
|
107
|
+
Options: `--offline` (parse and cross-check only, no network), `--cache DIR`
|
|
108
|
+
(HTTP cache, 7-day TTL, so re-runs are nearly free), `--timeout N`, and
|
|
109
|
+
`--mailto you@example.com` for Crossref and OpenAlex's polite rate-limit pool.
|
|
110
|
+
`--mailto` is off by default and never inferred — it sends an address to third
|
|
111
|
+
parties.
|
|
112
|
+
|
|
113
|
+
`check` exits 2 when something needs attention, 0 when clean, so it drops into CI.
|
|
114
|
+
|
|
115
|
+
## Statuses
|
|
116
|
+
|
|
117
|
+
`MISMATCH` and `NOT_FOUND` are the ones that damage a submission. `LINK_DEAD` and
|
|
118
|
+
`STALE` need a fix but not a retraction. `PARTIAL`, `LINK_MOVED` and
|
|
119
|
+
`UNVERIFIABLE` are for a glance — notably, PDFs and JS-rendered pages land in
|
|
120
|
+
`PARTIAL` routinely, because no `<title>` can be read from them. A `PARTIAL` is a
|
|
121
|
+
limit of the check, not evidence against the citation.
|
|
122
|
+
|
|
123
|
+
## Limits
|
|
124
|
+
|
|
125
|
+
Author-date reference lists only — numeric styles (Vancouver, IEEE) are not
|
|
126
|
+
parsed. Page numbers, edition and publisher details are not checked.
|
|
127
|
+
|
|
128
|
+
Sources behind paywalls cannot be verified beyond their metadata record.
|
|
129
|
+
|
|
130
|
+
The list of in-text citations with no matching entry has expected false positives,
|
|
131
|
+
because a regex cannot distinguish `(Smith, 2024)` from `(ICLR 2023)`.
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
python3 -m unittest discover -s tests -t tests
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
|
|
140
|
+
|
|
141
|
+
The short version: standard library only, tests stay offline, and never let a coverage gap
|
|
142
|
+
report itself as a finding.
|
|
143
|
+
|
|
144
|
+
## Licence
|
|
145
|
+
|
|
146
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
touchneedle.py,sha256=jHTBhqSA--fFsGoUg6VMq04u7vQ1j57V2ybncvtRIdw,37593
|
|
2
|
+
touchneedle-0.1.0.dist-info/licenses/LICENSE,sha256=KF0EZ7NOibsbO2Qw9TMOX9qJrs3_WRQgSgoRpIBVUA0,1065
|
|
3
|
+
touchneedle-0.1.0.dist-info/METADATA,sha256=ZthC6oc4EQxuqnvljB1RAliyDeA0vq8FQLbsfYospiU,5347
|
|
4
|
+
touchneedle-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
touchneedle-0.1.0.dist-info/entry_points.txt,sha256=artVA-3Ladu8OAjBzWSw52TEppLMyAG2O85HcOkscRs,49
|
|
6
|
+
touchneedle-0.1.0.dist-info/top_level.txt,sha256=68kjUp0j8L2_Hn0q9hAjbPhmSBeEc5ScFnf3Qncwlw4,12
|
|
7
|
+
touchneedle-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ncoleman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
touchneedle
|
touchneedle.py
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify that the citations in a document are real, correctly described, and
|
|
3
|
+
consistently used.
|
|
4
|
+
|
|
5
|
+
Reads a prose reference list (Harvard/author-date) out of a Markdown or .docx
|
|
6
|
+
document, routes each entry to whichever authority can actually confirm it --
|
|
7
|
+
arXiv, Crossref, OpenAlex, the IETF datatracker, or the live web -- and reports
|
|
8
|
+
what does not line up. Also cross-checks in-text citations against the list in
|
|
9
|
+
both directions.
|
|
10
|
+
|
|
11
|
+
Standard library only. Python 3.11+.
|
|
12
|
+
|
|
13
|
+
touchneedle.py check DOC [--out report.md] [--json data.json]
|
|
14
|
+
touchneedle.py claims DOC [--out claims.md]
|
|
15
|
+
|
|
16
|
+
Exit status is 2 when the run found problems worth a human look, 0 when clean.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import dataclasses
|
|
21
|
+
import difflib
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import unicodedata
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
import xml.etree.ElementTree as ET
|
|
35
|
+
from collections.abc import Iterable
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
# Goes out in the User-Agent below, so it has to point somewhere a rate-limited
|
|
40
|
+
# API operator can actually reach a human. One token, repo-wide -- see RELEASING.md.
|
|
41
|
+
REPO_URL = "https://github.com/nicoleman0/touchneedle"
|
|
42
|
+
UA = f"touchneedle/{__version__} (+{REPO_URL}; academic reference verification)"
|
|
43
|
+
CACHE_TTL = 7 * 24 * 3600
|
|
44
|
+
|
|
45
|
+
# Verification outcomes, worst first -- the report sorts by this order.
|
|
46
|
+
SEVERITY = {
|
|
47
|
+
"MISMATCH": 0,
|
|
48
|
+
"NOT_FOUND": 1,
|
|
49
|
+
"LINK_DEAD": 2,
|
|
50
|
+
"STALE": 3,
|
|
51
|
+
"PARTIAL": 4,
|
|
52
|
+
"LINK_MOVED": 5,
|
|
53
|
+
"UNVERIFIABLE": 6,
|
|
54
|
+
"VERIFIED": 7,
|
|
55
|
+
}
|
|
56
|
+
PROBLEM_STATUSES = {"MISMATCH", "NOT_FOUND", "LINK_DEAD", "STALE"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --------------------------------------------------------------------------
|
|
60
|
+
# text loading and cleanup
|
|
61
|
+
# --------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def load_text(path: str) -> str:
|
|
64
|
+
"""Return document text as Markdown, converting .docx through pandoc."""
|
|
65
|
+
if path.lower().endswith((".docx", ".odt", ".rtf")):
|
|
66
|
+
if not shutil.which("pandoc"):
|
|
67
|
+
sys.exit(f"error: {path} needs pandoc to convert, and pandoc is not on PATH")
|
|
68
|
+
out = subprocess.run(
|
|
69
|
+
["pandoc", path, "-t", "markdown", "--wrap=none"],
|
|
70
|
+
capture_output=True, text=True,
|
|
71
|
+
)
|
|
72
|
+
if out.returncode != 0:
|
|
73
|
+
sys.exit(f"error: pandoc failed on {path}:\n{out.stderr}")
|
|
74
|
+
return out.stdout
|
|
75
|
+
with open(path, encoding="utf-8") as fh:
|
|
76
|
+
return fh.read()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
LATEX_NOISE = re.compile(r"\\(?:allowbreak|linebreak|newline|,|;|!)\{?\}?")
|
|
80
|
+
MARKBOTH = re.compile(r"\\markboth\{[^}]*\}\{[^}]*\}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clean(s: str) -> str:
|
|
84
|
+
"""Strip the LaTeX/pandoc debris that survives a docx -> markdown pass."""
|
|
85
|
+
s = LATEX_NOISE.sub("", s)
|
|
86
|
+
s = MARKBOTH.sub("", s)
|
|
87
|
+
s = re.sub(r"\\([\'\"`^~$&%#_{}])", r"\1", s) # pandoc escapes
|
|
88
|
+
s = s.replace("\u00a0", " ").replace("\u2011", "-")
|
|
89
|
+
s = re.sub(r"[ \t]*\n[ \t]*", " ", s)
|
|
90
|
+
return re.sub(r"\s{2,}", " ", s).strip()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
TITLES = r"references|bibliography|works cited|reference list"
|
|
94
|
+
HEADING = re.compile(r"^(#{1,4})\s*(?:\d+[.)]?\s*)?(?:" + TITLES + r")\b.*$", re.I | re.M)
|
|
95
|
+
# A .docx whose Word style never mapped to a heading level leaves the word
|
|
96
|
+
# sitting on a line of its own, sometimes bold or underlined.
|
|
97
|
+
BARE_HEADING = re.compile(r"^[ \t]*[*_]{0,2}(?:" + TITLES + r")[*_:]{0,2}[ \t]*$", re.I | re.M)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def split_document(text: str) -> tuple[str, str]:
|
|
101
|
+
"""Split into (body, reference block).
|
|
102
|
+
|
|
103
|
+
Later candidates win -- the word appears in running prose long before the
|
|
104
|
+
list itself -- but a candidate only wins if entries can actually be parsed
|
|
105
|
+
below it, so a stray mention does not swallow the real list.
|
|
106
|
+
"""
|
|
107
|
+
candidates = list(HEADING.finditer(text)) or list(BARE_HEADING.finditer(text))
|
|
108
|
+
if not candidates:
|
|
109
|
+
sys.exit("error: no 'References' / 'Bibliography' heading found in the document")
|
|
110
|
+
|
|
111
|
+
for match in reversed(candidates):
|
|
112
|
+
level = len(match.group(1)) if match.re is HEADING else 1
|
|
113
|
+
rest = text[match.end():]
|
|
114
|
+
nxt = re.search(r"^#{1," + str(level) + r"}\s+\S", rest, re.M)
|
|
115
|
+
block = rest[: nxt.start()] if nxt else rest
|
|
116
|
+
if len(split_entries(block)) >= 3:
|
|
117
|
+
return text[: match.start()], block
|
|
118
|
+
sys.exit("error: found a References heading but could not parse any entries under it")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
SKIP_ENTRY = re.compile(r"^(\\markboth|\[\^|:::|<!--|!\[|\||\s*$)")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def split_entries(block: str) -> list[str]:
|
|
125
|
+
"""Blank-line-separated entries, with a fallback for one-per-line lists."""
|
|
126
|
+
chunks = [c.strip() for c in re.split(r"\n[ \t]*\n", block)]
|
|
127
|
+
entries = [clean(c) for c in chunks if c and not SKIP_ENTRY.match(c)]
|
|
128
|
+
entries = [e for e in entries if len(e) > 25 and re.search(r"\((?:19|20)\d\d[a-z]?\)", e)]
|
|
129
|
+
if len(entries) <= 1 and block.count("\n") > 3:
|
|
130
|
+
# Single-spaced list: start a new entry at each line that opens with a
|
|
131
|
+
# capitalised author or organisation and carries a year.
|
|
132
|
+
entries, current = [], None
|
|
133
|
+
for line in block.splitlines():
|
|
134
|
+
if re.match(r"^[A-Z\u00c0-\u00dd][^\n]*\((?:19|20)\d\d[a-z]?\)", line.strip()):
|
|
135
|
+
if current is not None:
|
|
136
|
+
entries.append(clean(current))
|
|
137
|
+
current = line
|
|
138
|
+
elif current is not None:
|
|
139
|
+
# Anything before the first author-year line is preamble --
|
|
140
|
+
# a table row, a caption, a stray heading -- not an entry.
|
|
141
|
+
current += " " + line
|
|
142
|
+
if current is not None:
|
|
143
|
+
entries.append(clean(current))
|
|
144
|
+
entries = [e for e in entries
|
|
145
|
+
if len(e) > 25 and not SKIP_ENTRY.match(e)]
|
|
146
|
+
return entries
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --------------------------------------------------------------------------
|
|
150
|
+
# reference parsing
|
|
151
|
+
# --------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
@dataclasses.dataclass
|
|
154
|
+
class Reference:
|
|
155
|
+
raw: str
|
|
156
|
+
key: str = ""
|
|
157
|
+
name: str = "" # first-author surname, or organisation name
|
|
158
|
+
is_org: bool = False
|
|
159
|
+
year: str = ""
|
|
160
|
+
suffix: str = "" # the 'a' / 'b' in 2025a
|
|
161
|
+
title: str = ""
|
|
162
|
+
container: str = ""
|
|
163
|
+
url: str = ""
|
|
164
|
+
doi: str = ""
|
|
165
|
+
arxiv: str = ""
|
|
166
|
+
rfc: str = ""
|
|
167
|
+
draft: str = ""
|
|
168
|
+
draft_rev: str = ""
|
|
169
|
+
accessed: str = ""
|
|
170
|
+
kind: str = "unknown"
|
|
171
|
+
status: str = "UNVERIFIABLE"
|
|
172
|
+
notes: list[str] = dataclasses.field(default_factory=list)
|
|
173
|
+
evidence: list[str] = dataclasses.field(default_factory=list)
|
|
174
|
+
cited_by: list[str] = dataclasses.field(default_factory=list)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
YEAR = re.compile(r"\((19|20)(\d\d)([a-z]?)\)")
|
|
178
|
+
# A quoted title may contain an apostrophe ("what you've signed up for"), so the
|
|
179
|
+
# closing quote is only the one followed by punctuation or end of entry.
|
|
180
|
+
QUOTED = re.compile(
|
|
181
|
+
r"(?:^|[\s(])[\u2018'\"\u201c](.{8,300}?)[\u2019'\"\u201d](?=\s*[,.;]|\s*$)")
|
|
182
|
+
URL_RE = re.compile(r"<?(https?://[^\s>)\]]+)>?")
|
|
183
|
+
DOI_RE = re.compile(r"\b(10\.\d{4,9}/[^\s,;>\)\]]+)")
|
|
184
|
+
ARXIV_RE = re.compile(r"arXiv[:\s]\s*(\d{4}\.\d{4,5})(v\d+)?", re.I)
|
|
185
|
+
RFC_RE = re.compile(r"\bRFC\s*(\d{3,5})\b", re.I)
|
|
186
|
+
DRAFT_RE = re.compile(r"\b(draft-[a-z0-9][a-z0-9\-]*[a-z0-9])\b", re.I)
|
|
187
|
+
ACCESSED_RE = re.compile(r"\(Accessed:?\s*([^)]+)\)", re.I)
|
|
188
|
+
PERSON_RE = re.compile(r"^[A-Z\u00c0-\u00dd][\w\u00c0-\u017e'\u2019\-]+,\s*[A-Z]\.")
|
|
189
|
+
|
|
190
|
+
ACADEMIC = re.compile(
|
|
191
|
+
r"\b(proceedings|conference|symposium|workshop|journal|transactions|advances in|"
|
|
192
|
+
r"findings of|arxiv|preprint|acm|ieee|usenix|neurips|iclr|icml)\b", re.I)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_entry(raw: str) -> Reference:
|
|
196
|
+
ref = Reference(raw=raw)
|
|
197
|
+
|
|
198
|
+
ym = YEAR.search(raw)
|
|
199
|
+
if ym:
|
|
200
|
+
ref.year = ym.group(1) + ym.group(2)
|
|
201
|
+
ref.suffix = ym.group(3)
|
|
202
|
+
authors = raw[: ym.start()].strip().rstrip(",")
|
|
203
|
+
tail = raw[ym.end():].strip()
|
|
204
|
+
else:
|
|
205
|
+
authors, tail = "", raw
|
|
206
|
+
|
|
207
|
+
ref.is_org = not PERSON_RE.match(authors)
|
|
208
|
+
if ref.is_org:
|
|
209
|
+
ref.name = authors.strip(" .,")
|
|
210
|
+
else:
|
|
211
|
+
ref.name = authors.split(",")[0].strip()
|
|
212
|
+
|
|
213
|
+
ref.key = f"{normalise(ref.name)}|{ref.year}{ref.suffix}"
|
|
214
|
+
|
|
215
|
+
qm = QUOTED.search(tail)
|
|
216
|
+
if qm:
|
|
217
|
+
ref.title = qm.group(1).strip()
|
|
218
|
+
ref.container = tail[qm.end():].lstrip(" ,.").strip()
|
|
219
|
+
else:
|
|
220
|
+
# Unquoted title: everything up to the first sentence break that is not
|
|
221
|
+
# part of an initial or a URL.
|
|
222
|
+
head = re.split(r"\.\s+(?=[A-Z])|\.\s*Available at", tail, maxsplit=1)
|
|
223
|
+
ref.title = head[0].strip(" .,")
|
|
224
|
+
ref.container = (head[1] if len(head) > 1 else "").strip()
|
|
225
|
+
|
|
226
|
+
if m := URL_RE.search(raw):
|
|
227
|
+
ref.url = m.group(1).rstrip(".,;")
|
|
228
|
+
if m := DOI_RE.search(raw):
|
|
229
|
+
ref.doi = m.group(1).rstrip(".")
|
|
230
|
+
if m := ARXIV_RE.search(raw):
|
|
231
|
+
ref.arxiv = m.group(1)
|
|
232
|
+
if m := RFC_RE.search(raw):
|
|
233
|
+
ref.rfc = m.group(1)
|
|
234
|
+
if m := DRAFT_RE.search(raw):
|
|
235
|
+
# Take the whole token greedily, then split a trailing -NN revision off
|
|
236
|
+
# it -- 'draft-ietf-oauth-v2-1-15' is v2-1 at revision 15.
|
|
237
|
+
full = m.group(1)
|
|
238
|
+
rm = re.match(r"^(.*?)-(\d{2})$", full)
|
|
239
|
+
ref.draft, ref.draft_rev = (rm.group(1), rm.group(2)) if rm else (full, "")
|
|
240
|
+
if m := ACCESSED_RE.search(raw):
|
|
241
|
+
ref.accessed = m.group(1).strip()
|
|
242
|
+
ref.title = re.sub(r"\s*\(Accessed:?[^)]*\)", "", ref.title).strip()
|
|
243
|
+
ref.title = re.sub(r"\s*Available at:?.*$", "", ref.title, flags=re.I)
|
|
244
|
+
# An unquoted title often absorbs the series identifier that follows it;
|
|
245
|
+
# trim it so title matching compares like with like.
|
|
246
|
+
ref.title = re.sub(
|
|
247
|
+
r",?\s*(RFC\s*\d+|BCP\s*\d+|Internet-Draft\s+draft-\S+|IETF)\b", "",
|
|
248
|
+
ref.title, flags=re.I).strip(" .,")
|
|
249
|
+
|
|
250
|
+
if ref.arxiv:
|
|
251
|
+
ref.kind = "arxiv"
|
|
252
|
+
elif ref.doi:
|
|
253
|
+
ref.kind = "doi"
|
|
254
|
+
elif ref.rfc:
|
|
255
|
+
ref.kind = "rfc"
|
|
256
|
+
elif ref.draft:
|
|
257
|
+
ref.kind = "ietf-draft"
|
|
258
|
+
elif qm and ACADEMIC.search(ref.container):
|
|
259
|
+
ref.kind = "paper"
|
|
260
|
+
elif ref.url:
|
|
261
|
+
ref.kind = "web"
|
|
262
|
+
return ref
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# --------------------------------------------------------------------------
|
|
266
|
+
# in-text citations
|
|
267
|
+
# --------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
PAREN = re.compile(r"\(([^()]{3,120}?(?:19|20)\d\d[a-z]?[^()]{0,40}?)\)")
|
|
270
|
+
NARRATIVE = re.compile(
|
|
271
|
+
r"\b([A-Z\u00c0-\u00dd][\w\u00c0-\u017e'\u2019\-]*"
|
|
272
|
+
r"(?:\s+(?:and|&)\s+[A-Z\u00c0-\u00dd][\w\u00c0-\u017e'\u2019\-]*"
|
|
273
|
+
r"|\s+et\s+al\.?"
|
|
274
|
+
r"|\s+[A-Z\u00c0-\u00dd][\w\u00c0-\u017e'\u2019\-]*){0,3})"
|
|
275
|
+
r"\s+\(((?:19|20)\d\d)([a-z]?)\)")
|
|
276
|
+
NOT_A_CITATION = re.compile(
|
|
277
|
+
r"^(accessed|figure|table|chapter|section|appendix|see|eq|equation|n\.?d\.?)\b", re.I)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclasses.dataclass
|
|
281
|
+
class Citation:
|
|
282
|
+
name: str
|
|
283
|
+
year: str
|
|
284
|
+
suffix: str
|
|
285
|
+
form: str # 'parenthetical' or 'narrative'
|
|
286
|
+
context: str
|
|
287
|
+
key: str = ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def find_citations(body: str) -> list[Citation]:
|
|
291
|
+
out: list[Citation] = []
|
|
292
|
+
for m in PAREN.finditer(body):
|
|
293
|
+
inner = m.group(1)
|
|
294
|
+
if re.search(r"accessed", inner, re.I):
|
|
295
|
+
continue
|
|
296
|
+
for part in re.split(r";", inner):
|
|
297
|
+
part = part.strip()
|
|
298
|
+
cm = re.match(
|
|
299
|
+
r"^(.{2,80}?)[,\s]+((?:19|20)\d\d)([a-z]?)\s*$", part.replace("et al.", "et al"))
|
|
300
|
+
if not cm:
|
|
301
|
+
continue
|
|
302
|
+
name = cm.group(1).strip(" ,")
|
|
303
|
+
if NOT_A_CITATION.match(name) or not re.match(r"^[A-Z\u00c0-\u00dd]", name):
|
|
304
|
+
continue
|
|
305
|
+
out.append(Citation(name, cm.group(2), cm.group(3), "parenthetical",
|
|
306
|
+
context(body, m.start())))
|
|
307
|
+
for m in NARRATIVE.finditer(body):
|
|
308
|
+
name = m.group(1).strip()
|
|
309
|
+
if NOT_A_CITATION.match(name):
|
|
310
|
+
continue
|
|
311
|
+
out.append(Citation(name, m.group(2), m.group(3), "narrative",
|
|
312
|
+
context(body, m.start())))
|
|
313
|
+
return out
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
SENT_END = re.compile(r"(?<![A-Z])(?<!\bet al)(?<!\bvol)(?<!\bpp)[.!?](?:\s|$)")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def context(body: str, pos: int, width: int = 420) -> str:
|
|
320
|
+
"""Approximate sentence around a position -- good enough for a worklist."""
|
|
321
|
+
start = max(0, pos - width)
|
|
322
|
+
end = min(len(body), pos + width)
|
|
323
|
+
left = body[start:pos]
|
|
324
|
+
right = body[pos:end]
|
|
325
|
+
headings = list(re.finditer(r"(?m)^#{1,6}[^\n]*$", left))
|
|
326
|
+
if headings: # don't drag the section title into the quote
|
|
327
|
+
left = left[headings[-1].end():]
|
|
328
|
+
bounds = list(SENT_END.finditer(left))
|
|
329
|
+
if bounds:
|
|
330
|
+
left = left[bounds[-1].end():]
|
|
331
|
+
fwd = SENT_END.search(right)
|
|
332
|
+
if fwd:
|
|
333
|
+
right = right[: fwd.end()]
|
|
334
|
+
return clean(left + right)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def normalise(s: str) -> str:
|
|
338
|
+
s = unicodedata.normalize("NFKD", s)
|
|
339
|
+
s = "".join(c for c in s if not unicodedata.combining(c))
|
|
340
|
+
s = re.sub(r"\bet\s+al\.?", "", s, flags=re.I)
|
|
341
|
+
s = re.sub(r"[^a-z0-9 ]", " ", s.lower())
|
|
342
|
+
return re.sub(r"\s+", " ", s).strip()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def citation_key(name: str, year: str, suffix: str) -> str:
|
|
346
|
+
# Split the ampersand off before normalise(), which strips punctuation and
|
|
347
|
+
# would otherwise leave 'Smith & Jones' as the single token 'smith jones'.
|
|
348
|
+
n = normalise(name.split("&")[0])
|
|
349
|
+
n = re.split(r"\band\b", n)[0].strip()
|
|
350
|
+
return f"{n}|{year}{suffix}"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def match_citations(refs: list[Reference], cites: list[Citation]) -> list[Citation]:
|
|
354
|
+
"""Attach each in-text citation to a reference where one can be found."""
|
|
355
|
+
by_key = {r.key: r for r in refs}
|
|
356
|
+
by_surname: dict[str, list[Reference]] = {}
|
|
357
|
+
for r in refs:
|
|
358
|
+
first = normalise(r.name).split(" ")[0] if not r.is_org else normalise(r.name)
|
|
359
|
+
by_surname.setdefault(first, []).append(r)
|
|
360
|
+
|
|
361
|
+
for c in cites:
|
|
362
|
+
key = citation_key(c.name, c.year, c.suffix)
|
|
363
|
+
if key in by_key:
|
|
364
|
+
c.key = key
|
|
365
|
+
by_key[key].cited_by.append(c.form)
|
|
366
|
+
continue
|
|
367
|
+
# organisation names cite in full; person names cite by surname only
|
|
368
|
+
stem = key.split("|")[0]
|
|
369
|
+
head = stem.split(" ")[0]
|
|
370
|
+
for candidate_stem in (stem, head):
|
|
371
|
+
for r in by_surname.get(candidate_stem, []):
|
|
372
|
+
if r.year == c.year and (not c.suffix or c.suffix == r.suffix):
|
|
373
|
+
c.key = r.key
|
|
374
|
+
r.cited_by.append(c.form)
|
|
375
|
+
break
|
|
376
|
+
if c.key:
|
|
377
|
+
break
|
|
378
|
+
return cites
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# --------------------------------------------------------------------------
|
|
382
|
+
# HTTP with an on-disk cache
|
|
383
|
+
# --------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
class Fetcher:
|
|
386
|
+
def __init__(self, cache_dir: str, timeout: int = 25, offline: bool = False,
|
|
387
|
+
mailto: str | None = None, delay: float = 0.4):
|
|
388
|
+
self.cache_dir = cache_dir
|
|
389
|
+
self.timeout = timeout
|
|
390
|
+
self.offline = offline
|
|
391
|
+
self.mailto = mailto
|
|
392
|
+
self.delay = delay
|
|
393
|
+
self.last = 0.0
|
|
394
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
395
|
+
|
|
396
|
+
def get(self, url: str, accept: str | None = None,
|
|
397
|
+
max_bytes: int = 400_000) -> dict[str, Any]:
|
|
398
|
+
path = os.path.join(self.cache_dir, hashlib.sha256(
|
|
399
|
+
(url + (accept or "")).encode()).hexdigest() + ".json")
|
|
400
|
+
if os.path.exists(path) and time.time() - os.path.getmtime(path) < CACHE_TTL:
|
|
401
|
+
with open(path, encoding="utf-8") as fh:
|
|
402
|
+
cached: dict[str, Any] = json.load(fh)
|
|
403
|
+
return cached
|
|
404
|
+
if self.offline:
|
|
405
|
+
return {"ok": False, "status": None, "error": "offline", "body": "", "final_url": url}
|
|
406
|
+
|
|
407
|
+
gap = self.delay - (time.time() - self.last)
|
|
408
|
+
if gap > 0:
|
|
409
|
+
time.sleep(gap)
|
|
410
|
+
self.last = time.time()
|
|
411
|
+
|
|
412
|
+
ua = UA if not self.mailto else f"{UA} mailto:{self.mailto}"
|
|
413
|
+
req = urllib.request.Request(url, headers={
|
|
414
|
+
"User-Agent": ua,
|
|
415
|
+
"Accept": accept or "text/html,application/json;q=0.9,*/*;q=0.8",
|
|
416
|
+
})
|
|
417
|
+
rec: dict[str, Any]
|
|
418
|
+
try:
|
|
419
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
420
|
+
raw = resp.read(max_bytes)
|
|
421
|
+
charset = resp.headers.get_content_charset() or "utf-8"
|
|
422
|
+
rec = {"ok": True, "status": resp.status,
|
|
423
|
+
"body": raw.decode(charset, "replace"),
|
|
424
|
+
"final_url": resp.geturl(), "error": None}
|
|
425
|
+
except urllib.error.HTTPError as e:
|
|
426
|
+
rec = {"ok": False, "status": e.code, "body": "", "final_url": url,
|
|
427
|
+
"error": f"HTTP {e.code}"}
|
|
428
|
+
except Exception as e: # timeouts, DNS, TLS, redirect loops
|
|
429
|
+
rec = {"ok": False, "status": None, "body": "", "final_url": url,
|
|
430
|
+
"error": f"{type(e).__name__}: {e}"}
|
|
431
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
432
|
+
json.dump(rec, fh)
|
|
433
|
+
return rec
|
|
434
|
+
|
|
435
|
+
def json(self, url: str) -> Any | None:
|
|
436
|
+
rec = self.get(url, accept="application/json")
|
|
437
|
+
if not rec["ok"]:
|
|
438
|
+
return None
|
|
439
|
+
try:
|
|
440
|
+
return json.loads(rec["body"])
|
|
441
|
+
except json.JSONDecodeError:
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# --------------------------------------------------------------------------
|
|
446
|
+
# similarity
|
|
447
|
+
# --------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def title_score(a: str, b: str) -> float:
|
|
450
|
+
na, nb = normalise(a), normalise(b)
|
|
451
|
+
if not na or not nb:
|
|
452
|
+
return 0.0
|
|
453
|
+
seq = difflib.SequenceMatcher(None, na, nb).ratio()
|
|
454
|
+
ta, tb = set(na.split()), set(nb.split())
|
|
455
|
+
jac = len(ta & tb) / len(ta | tb) if ta | tb else 0.0
|
|
456
|
+
contain = 1.0 if (na in nb or nb in na) and min(len(na), len(nb)) > 20 else 0.0
|
|
457
|
+
return max(seq, jac, contain)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def author_present(surname: str, authors: Iterable[str]) -> bool:
|
|
461
|
+
s = normalise(surname).split(" ")[-1]
|
|
462
|
+
return any(s and s in normalise(a).split() for a in authors)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# --------------------------------------------------------------------------
|
|
466
|
+
# per-source verification
|
|
467
|
+
# --------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
def check_metadata(ref: Reference, found_title: str, found_authors: list[str],
|
|
470
|
+
found_year: str | None, source: str) -> None:
|
|
471
|
+
"""Compare a retrieved record against the reference and set status/notes."""
|
|
472
|
+
score = title_score(ref.title, found_title)
|
|
473
|
+
ref.evidence.append(f"{source}: \u201c{found_title[:140]}\u201d"
|
|
474
|
+
+ (f" ({found_year})" if found_year else ""))
|
|
475
|
+
problems = []
|
|
476
|
+
if score < 0.60:
|
|
477
|
+
problems.append(f"title differs from {source} record (similarity {score:.2f})")
|
|
478
|
+
elif score < 0.85:
|
|
479
|
+
ref.notes.append(f"title only partly matches {source} (similarity {score:.2f})")
|
|
480
|
+
if found_authors and not ref.is_org and not author_present(ref.name, found_authors):
|
|
481
|
+
problems.append(f"first author '{ref.name}' not among {source} authors "
|
|
482
|
+
f"({', '.join(found_authors[:4])})")
|
|
483
|
+
if found_year and ref.year and abs(int(found_year) - int(ref.year)) > 1:
|
|
484
|
+
problems.append(f"year {ref.year} vs {found_year} in {source}")
|
|
485
|
+
|
|
486
|
+
if problems:
|
|
487
|
+
ref.status = "MISMATCH"
|
|
488
|
+
ref.notes.extend(problems)
|
|
489
|
+
elif score >= 0.85:
|
|
490
|
+
ref.status = "VERIFIED"
|
|
491
|
+
else:
|
|
492
|
+
ref.status = "PARTIAL"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def verify_arxiv(ref: Reference, f: Fetcher) -> bool:
|
|
496
|
+
url = f"http://export.arxiv.org/api/query?id_list={ref.arxiv}&max_results=1"
|
|
497
|
+
rec = f.get(url, accept="application/atom+xml")
|
|
498
|
+
if not rec["ok"]:
|
|
499
|
+
ref.notes.append(f"arXiv API unreachable ({rec['error']})")
|
|
500
|
+
return False
|
|
501
|
+
try:
|
|
502
|
+
root = ET.fromstring(rec["body"])
|
|
503
|
+
except ET.ParseError:
|
|
504
|
+
return False
|
|
505
|
+
ns = {"a": "http://www.w3.org/2005/Atom"}
|
|
506
|
+
entry = root.find("a:entry", ns)
|
|
507
|
+
if entry is None or entry.findtext("a:title", "", ns).strip() in ("", "Error"):
|
|
508
|
+
ref.status = "NOT_FOUND"
|
|
509
|
+
ref.notes.append(f"arXiv has no paper with id {ref.arxiv}")
|
|
510
|
+
return True
|
|
511
|
+
title = " ".join(entry.findtext("a:title", "", ns).split())
|
|
512
|
+
authors = [a.findtext("a:name", "", ns) for a in entry.findall("a:author", ns)]
|
|
513
|
+
published = entry.findtext("a:published", "", ns)[:4] or None
|
|
514
|
+
check_metadata(ref, title, authors, published, f"arXiv:{ref.arxiv}")
|
|
515
|
+
return True
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def crossref_record(item: dict[str, Any]) -> tuple[str, list[str], str | None]:
|
|
519
|
+
title = (item.get("title") or [""])[0]
|
|
520
|
+
authors = [" ".join(filter(None, [a.get("given"), a.get("family")]))
|
|
521
|
+
for a in item.get("author", [])]
|
|
522
|
+
parts = (item.get("issued") or {}).get("date-parts") or [[None]]
|
|
523
|
+
year = str(parts[0][0]) if parts and parts[0] and parts[0][0] else None
|
|
524
|
+
return title, authors, year
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def verify_doi(ref: Reference, f: Fetcher) -> bool:
|
|
528
|
+
data = f.json(f"https://api.crossref.org/works/{urllib.parse.quote(ref.doi)}")
|
|
529
|
+
if not data or "message" not in data:
|
|
530
|
+
ref.status = "NOT_FOUND"
|
|
531
|
+
ref.notes.append(f"Crossref has no record for DOI {ref.doi}")
|
|
532
|
+
return True
|
|
533
|
+
title, authors, year = crossref_record(data["message"])
|
|
534
|
+
check_metadata(ref, title, authors, year, f"Crossref {ref.doi}")
|
|
535
|
+
return True
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def verify_by_title(ref: Reference, f: Fetcher) -> bool:
|
|
539
|
+
"""No identifier: search Crossref then OpenAlex by bibliographic title."""
|
|
540
|
+
if not ref.title:
|
|
541
|
+
return False
|
|
542
|
+
q = urllib.parse.quote(ref.title[:250])
|
|
543
|
+
best: tuple[float, str, list[str], str | None, str] = (0.0, "", [], None, "")
|
|
544
|
+
|
|
545
|
+
data = f.json(f"https://api.crossref.org/works?query.bibliographic={q}&rows=5"
|
|
546
|
+
+ (f"&mailto={urllib.parse.quote(f.mailto)}" if f.mailto else ""))
|
|
547
|
+
for item in ((data or {}).get("message", {}) or {}).get("items", []):
|
|
548
|
+
title, authors, year = crossref_record(item)
|
|
549
|
+
s = title_score(ref.title, title)
|
|
550
|
+
if s > best[0]:
|
|
551
|
+
best = (s, title, authors, year, f"Crossref ({item.get('DOI', 'no DOI')})")
|
|
552
|
+
|
|
553
|
+
if best[0] < 0.85:
|
|
554
|
+
data = f.json(f"https://api.openalex.org/works?per-page=5&filter=title.search:{q}"
|
|
555
|
+
+ (f"&mailto={urllib.parse.quote(f.mailto)}" if f.mailto else ""))
|
|
556
|
+
for item in (data or {}).get("results", []):
|
|
557
|
+
title = item.get("display_name") or ""
|
|
558
|
+
authors = [a.get("author", {}).get("display_name", "")
|
|
559
|
+
for a in item.get("authorships", [])]
|
|
560
|
+
year = str(item.get("publication_year")) if item.get("publication_year") else None
|
|
561
|
+
s = title_score(ref.title, title)
|
|
562
|
+
if s > best[0]:
|
|
563
|
+
best = (s, title, authors, year, "OpenAlex")
|
|
564
|
+
|
|
565
|
+
if best[0] < 0.55:
|
|
566
|
+
ref.status = "NOT_FOUND"
|
|
567
|
+
ref.notes.append(
|
|
568
|
+
"no close title match in Crossref or OpenAlex"
|
|
569
|
+
+ (f" (best {best[0]:.2f}: \u201c{best[1][:90]}\u201d)" if best[1] else ""))
|
|
570
|
+
return True
|
|
571
|
+
check_metadata(ref, best[1], best[2], best[3], best[4])
|
|
572
|
+
return True
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def verify_rfc(ref: Reference, f: Fetcher) -> bool:
|
|
576
|
+
data = f.json(f"https://datatracker.ietf.org/api/v1/doc/document/rfc{ref.rfc}/?format=json")
|
|
577
|
+
if data and data.get("title"):
|
|
578
|
+
check_metadata(ref, data["title"], [], None, f"IETF datatracker RFC {ref.rfc}")
|
|
579
|
+
if data.get("std_level"):
|
|
580
|
+
ref.evidence.append(f"status: {data['std_level']}")
|
|
581
|
+
return True
|
|
582
|
+
rec = f.get(f"https://www.rfc-editor.org/rfc/rfc{ref.rfc}.txt", accept="text/plain")
|
|
583
|
+
if not rec["ok"]:
|
|
584
|
+
ref.status = "NOT_FOUND"
|
|
585
|
+
ref.notes.append(f"RFC {ref.rfc} not retrievable from datatracker or rfc-editor")
|
|
586
|
+
return True
|
|
587
|
+
head = rec["body"][:4000]
|
|
588
|
+
if (ref.title and title_score(ref.title, head) < 0.10
|
|
589
|
+
and normalise(ref.title)[:40] not in normalise(head)):
|
|
590
|
+
ref.status = "PARTIAL"
|
|
591
|
+
ref.notes.append(f"RFC {ref.rfc} exists but its text does not obviously "
|
|
592
|
+
"contain the cited title")
|
|
593
|
+
else:
|
|
594
|
+
ref.status = "VERIFIED"
|
|
595
|
+
ref.evidence.append(f"rfc-editor: rfc{ref.rfc}.txt retrieved")
|
|
596
|
+
return True
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def verify_draft(ref: Reference, f: Fetcher) -> bool:
|
|
600
|
+
data = f.json(
|
|
601
|
+
f"https://datatracker.ietf.org/api/v1/doc/document/{ref.draft}/?format=json")
|
|
602
|
+
if not data or not data.get("title"):
|
|
603
|
+
ref.status = "NOT_FOUND"
|
|
604
|
+
ref.notes.append(f"IETF datatracker has no draft named {ref.draft}")
|
|
605
|
+
return True
|
|
606
|
+
check_metadata(ref, data["title"], [], None, f"IETF datatracker {ref.draft}")
|
|
607
|
+
current = str(data.get("rev") or "")
|
|
608
|
+
if current:
|
|
609
|
+
ref.evidence.append(f"current revision: -{current}")
|
|
610
|
+
if ref.draft_rev and ref.draft_rev != current:
|
|
611
|
+
ref.status = "STALE"
|
|
612
|
+
ref.notes.append(
|
|
613
|
+
f"cited as -{ref.draft_rev} but the current revision is -{current}; "
|
|
614
|
+
"an Internet-Draft is a moving target, so confirm the cited text survived")
|
|
615
|
+
if str(data.get("state") or "").lower() in {"expired", "dead", "replaced"}:
|
|
616
|
+
ref.notes.append(f"datatracker state: {data['state']}")
|
|
617
|
+
return True
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
TITLE_TAG = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
|
|
621
|
+
OG_TITLE = re.compile(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\'](.*?)["\']', re.I)
|
|
622
|
+
SOFT_404 = re.compile(r"\b(404|not found|page (?:not|no longer) (?:found|available)|"
|
|
623
|
+
r"deleted|does not exist)\b", re.I)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def verify_web(ref: Reference, f: Fetcher) -> bool:
|
|
627
|
+
if not ref.url:
|
|
628
|
+
return False
|
|
629
|
+
rec = f.get(ref.url)
|
|
630
|
+
if not rec["ok"]:
|
|
631
|
+
# Our own offline switch is not evidence about the link.
|
|
632
|
+
ref.status = "UNVERIFIABLE" if rec["error"] == "offline" else "LINK_DEAD"
|
|
633
|
+
ref.notes.append(f"{ref.url} -> {rec['error']}")
|
|
634
|
+
return True
|
|
635
|
+
|
|
636
|
+
page_title = ""
|
|
637
|
+
if m := OG_TITLE.search(rec["body"]):
|
|
638
|
+
page_title = m.group(1)
|
|
639
|
+
elif m := TITLE_TAG.search(rec["body"]):
|
|
640
|
+
page_title = re.sub(r"<[^>]+>", " ", m.group(1))
|
|
641
|
+
page_title = clean(page_title)[:200]
|
|
642
|
+
ref.evidence.append(f"page title: \u201c{page_title}\u201d" if page_title
|
|
643
|
+
else f"HTTP {rec['status']}, no <title>")
|
|
644
|
+
|
|
645
|
+
final = rec["final_url"]
|
|
646
|
+
if final.rstrip("/") != ref.url.rstrip("/"):
|
|
647
|
+
ref.notes.append(f"redirects to {final}")
|
|
648
|
+
|
|
649
|
+
if page_title and SOFT_404.search(page_title):
|
|
650
|
+
ref.status = "LINK_DEAD"
|
|
651
|
+
ref.notes.append(f"page resolves but looks like an error page: \u201c{page_title}\u201d")
|
|
652
|
+
return True
|
|
653
|
+
|
|
654
|
+
if not page_title:
|
|
655
|
+
ref.status = "PARTIAL"
|
|
656
|
+
ref.notes.append("URL resolves but no title could be read (PDF or JS-rendered page); "
|
|
657
|
+
"confirm by eye")
|
|
658
|
+
return True
|
|
659
|
+
|
|
660
|
+
score = max(title_score(ref.title, page_title),
|
|
661
|
+
title_score(ref.title, page_title + " " + ref.container))
|
|
662
|
+
if score >= 0.55:
|
|
663
|
+
ref.status = "VERIFIED"
|
|
664
|
+
elif normalise(ref.name) and normalise(ref.name).split(" ")[0] in normalise(page_title):
|
|
665
|
+
ref.status = "PARTIAL"
|
|
666
|
+
ref.notes.append(f"page title \u201c{page_title}\u201d does not match the cited title, "
|
|
667
|
+
"though the publisher matches")
|
|
668
|
+
else:
|
|
669
|
+
ref.status = "MISMATCH"
|
|
670
|
+
ref.notes.append(f"cited title vs page title mismatch (similarity {score:.2f}): "
|
|
671
|
+
f"\u201c{page_title}\u201d")
|
|
672
|
+
if final.rstrip("/") != ref.url.rstrip("/") and ref.status == "VERIFIED":
|
|
673
|
+
ref.status = "LINK_MOVED"
|
|
674
|
+
return True
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def verify(ref: Reference, f: Fetcher) -> None:
|
|
678
|
+
if f.offline:
|
|
679
|
+
# Absence of a lookup is not a finding about the citation.
|
|
680
|
+
ref.status = "UNVERIFIABLE"
|
|
681
|
+
ref.notes.append("offline mode: no source was contacted")
|
|
682
|
+
return
|
|
683
|
+
done = False
|
|
684
|
+
if ref.arxiv:
|
|
685
|
+
done = verify_arxiv(ref, f)
|
|
686
|
+
if not done and ref.doi:
|
|
687
|
+
done = verify_doi(ref, f)
|
|
688
|
+
if not done and ref.rfc:
|
|
689
|
+
done = verify_rfc(ref, f)
|
|
690
|
+
if not done and ref.draft:
|
|
691
|
+
done = verify_draft(ref, f)
|
|
692
|
+
if not done and ref.kind == "paper":
|
|
693
|
+
done = verify_by_title(ref, f)
|
|
694
|
+
if not done and ref.url:
|
|
695
|
+
done = verify_web(ref, f)
|
|
696
|
+
elif done and ref.url and ref.status in {"VERIFIED", "PARTIAL"} and ref.kind != "web":
|
|
697
|
+
# Identifier checked out; still make sure the link the reader follows works.
|
|
698
|
+
probe = f.get(ref.url)
|
|
699
|
+
if not probe["ok"]:
|
|
700
|
+
ref.notes.append(f"companion link {ref.url} -> {probe['error']}")
|
|
701
|
+
ref.status = "LINK_DEAD" if ref.status == "VERIFIED" else ref.status
|
|
702
|
+
if not done and not ref.url:
|
|
703
|
+
ref.status = "UNVERIFIABLE"
|
|
704
|
+
ref.notes.append("no DOI, arXiv id, RFC number or URL to check against")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# --------------------------------------------------------------------------
|
|
708
|
+
# reporting
|
|
709
|
+
# --------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
def build_report(refs: list[Reference], cites: list[Citation], doc: str,
|
|
712
|
+
offline: bool) -> str:
|
|
713
|
+
counts: dict[str, int] = {}
|
|
714
|
+
for r in refs:
|
|
715
|
+
counts[r.status] = counts.get(r.status, 0) + 1
|
|
716
|
+
|
|
717
|
+
uncited = [r for r in refs if not r.cited_by]
|
|
718
|
+
unresolved = [c for c in cites if not c.key]
|
|
719
|
+
ambiguous = ambiguous_suffixes(refs, cites)
|
|
720
|
+
|
|
721
|
+
L = [f"# Citation check \u2014 {os.path.basename(doc)}", "",
|
|
722
|
+
f"Run {time.strftime('%Y-%m-%d %H:%M')}"
|
|
723
|
+
+ (" \u2014 **offline mode, nothing was verified against a live source**"
|
|
724
|
+
if offline else ""),
|
|
725
|
+
"", f"{len(refs)} reference entries, {len(cites)} in-text citation instances.", "",
|
|
726
|
+
"## Summary", "", "| Status | Count | Meaning |", "|---|---:|---|"]
|
|
727
|
+
meaning = {
|
|
728
|
+
"VERIFIED": "matched an authoritative record",
|
|
729
|
+
"PARTIAL": "found, but the match is loose \u2014 eyeball it",
|
|
730
|
+
"MISMATCH": "**found something that disagrees with what you wrote**",
|
|
731
|
+
"NOT_FOUND": "**searched and could not find it at all**",
|
|
732
|
+
"LINK_DEAD": "**URL does not resolve**",
|
|
733
|
+
"LINK_MOVED": "URL redirects elsewhere",
|
|
734
|
+
"STALE": "**cited revision superseded**",
|
|
735
|
+
"UNVERIFIABLE": "nothing checkable in the entry",
|
|
736
|
+
}
|
|
737
|
+
for status in sorted(counts, key=lambda s: SEVERITY[s]):
|
|
738
|
+
L.append(f"| {status} | {counts[status]} | {meaning[status]} |")
|
|
739
|
+
|
|
740
|
+
problems = [r for r in refs if r.status in PROBLEM_STATUSES]
|
|
741
|
+
L += ["", f"**{len(problems)} entries need attention.**" if problems
|
|
742
|
+
else "**No entry failed verification.**", ""]
|
|
743
|
+
|
|
744
|
+
L += ["## Entries needing attention", ""]
|
|
745
|
+
if not problems:
|
|
746
|
+
L.append("_None._")
|
|
747
|
+
for r in sorted(problems, key=lambda r: SEVERITY[r.status]):
|
|
748
|
+
L += [f"### {r.status} \u2014 {r.name} ({r.year}{r.suffix})", "",
|
|
749
|
+
f"> {r.raw}", ""]
|
|
750
|
+
for n in r.notes:
|
|
751
|
+
L.append(f"- {n}")
|
|
752
|
+
for e in r.evidence:
|
|
753
|
+
L.append(f"- _{e}_")
|
|
754
|
+
L.append("")
|
|
755
|
+
|
|
756
|
+
soft = [r for r in refs if r.status in {"PARTIAL", "LINK_MOVED", "UNVERIFIABLE"}]
|
|
757
|
+
L += ["## Worth a glance", ""]
|
|
758
|
+
if not soft:
|
|
759
|
+
L.append("_None._")
|
|
760
|
+
for r in sorted(soft, key=lambda r: SEVERITY[r.status]):
|
|
761
|
+
note = r.notes[0] if r.notes else (r.evidence[0] if r.evidence else "")
|
|
762
|
+
L.append(f"- **{r.status}** \u2014 {r.name} ({r.year}{r.suffix}): {note}")
|
|
763
|
+
L.append("")
|
|
764
|
+
|
|
765
|
+
L += ["## Verified", ""]
|
|
766
|
+
ok = [r for r in refs if r.status == "VERIFIED"]
|
|
767
|
+
for r in ok:
|
|
768
|
+
L.append(f"- {r.name} ({r.year}{r.suffix}) \u2014 {r.title[:80]}"
|
|
769
|
+
+ (f" \u2014 _{r.evidence[0][:100]}_" if r.evidence else ""))
|
|
770
|
+
if not ok:
|
|
771
|
+
L.append("_None._")
|
|
772
|
+
|
|
773
|
+
L += ["", "## Cross-reference consistency", "",
|
|
774
|
+
"### Reference-list entries never cited in the text", ""]
|
|
775
|
+
L += [f"- {r.name} ({r.year}{r.suffix}) \u2014 {r.title[:90]}" for r in uncited] or ["_None._"]
|
|
776
|
+
|
|
777
|
+
L += ["", "### In-text citations with no matching reference entry", "",
|
|
778
|
+
"_Expect false positives here: parenthetical years such as `(ICLR 2023)` "
|
|
779
|
+
"look like citations to a regex._", ""]
|
|
780
|
+
seen = set()
|
|
781
|
+
rows = []
|
|
782
|
+
for c in unresolved:
|
|
783
|
+
k = (c.name, c.year, c.suffix)
|
|
784
|
+
if k in seen:
|
|
785
|
+
continue
|
|
786
|
+
seen.add(k)
|
|
787
|
+
rows.append(f"- `{c.name} ({c.year}{c.suffix})` \u2014 \u2026{c.context[:150]}\u2026")
|
|
788
|
+
L += rows or ["_None._"]
|
|
789
|
+
|
|
790
|
+
L += ["", "### Ambiguous year suffixes", ""]
|
|
791
|
+
L += ambiguous or ["_None._"]
|
|
792
|
+
|
|
793
|
+
L += ["", "## What this run did not check", "",
|
|
794
|
+
"- Whether each source **supports the claim it is attached to**. That needs "
|
|
795
|
+
"reading, not fetching \u2014 run `touchneedle.py claims` and work the list.",
|
|
796
|
+
"- Page numbers, edition, and publisher details.",
|
|
797
|
+
"- Anything behind a paywall or a JS-rendered page, which comes back PARTIAL.", ""]
|
|
798
|
+
return "\n".join(L)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def ambiguous_suffixes(refs: list[Reference], cites: list[Citation]) -> list[str]:
|
|
802
|
+
"""Same author+year split across a/b in the list must be cited with a suffix."""
|
|
803
|
+
groups: dict[str, list[Reference]] = {}
|
|
804
|
+
for r in refs:
|
|
805
|
+
groups.setdefault(f"{normalise(r.name)}|{r.year}", []).append(r)
|
|
806
|
+
out = []
|
|
807
|
+
for stem, group in groups.items():
|
|
808
|
+
if len(group) < 2:
|
|
809
|
+
continue
|
|
810
|
+
name, year = group[0].name, group[0].year
|
|
811
|
+
bare = [c for c in cites
|
|
812
|
+
if citation_key(c.name, c.year, "") == stem and not c.suffix]
|
|
813
|
+
if bare:
|
|
814
|
+
out.append(f"- `{name} ({year})` is cited without a suffix "
|
|
815
|
+
f"{len(bare)}\u00d7, but the list has "
|
|
816
|
+
f"{', '.join(year + r.suffix for r in group)}")
|
|
817
|
+
if any(not r.suffix for r in group):
|
|
818
|
+
out.append(f"- reference list has {len(group)} entries for {name} ({year}) "
|
|
819
|
+
"but not all carry an a/b suffix")
|
|
820
|
+
return out
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def build_claims(refs: list[Reference], cites: list[Citation], doc: str) -> str:
|
|
824
|
+
by_key = {r.key: r for r in refs}
|
|
825
|
+
L = [f"# Claim-support worklist \u2014 {os.path.basename(doc)}", "",
|
|
826
|
+
"One row per in-text citation. For each, read the source and decide whether it "
|
|
827
|
+
"supports the sentence: **SUPPORTED / PARTIAL / UNSUPPORTED / INACCESSIBLE**. "
|
|
828
|
+
"Do not guess \u2014 if the source cannot be read, say INACCESSIBLE.", ""]
|
|
829
|
+
ordered = sorted(cites, key=lambda c: (c.key or "zzz", c.year))
|
|
830
|
+
for n, c in enumerate(ordered, 1):
|
|
831
|
+
r = by_key.get(c.key)
|
|
832
|
+
L += [f"## {n}. {c.name} ({c.year}{c.suffix}) \u2014 {c.form}", ""]
|
|
833
|
+
if r:
|
|
834
|
+
src = r.url or (f"arXiv:{r.arxiv}" if r.arxiv else "") or (
|
|
835
|
+
f"doi:{r.doi}" if r.doi else "") or (f"RFC {r.rfc}" if r.rfc else "")
|
|
836
|
+
L += [f"- **Source**: {r.title or r.raw[:100]}",
|
|
837
|
+
f"- **Locate at**: {src or '_no locator in the reference entry_'}"]
|
|
838
|
+
else:
|
|
839
|
+
L.append("- **Source**: _no matching reference-list entry_")
|
|
840
|
+
L += ["- **Claim in the text**:", f" > \u2026{c.context}\u2026", "",
|
|
841
|
+
"- **Verdict**: ", ""]
|
|
842
|
+
return "\n".join(L)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# --------------------------------------------------------------------------
|
|
846
|
+
# entry point
|
|
847
|
+
# --------------------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
def collect(doc: str) -> tuple[list[Reference], list[Citation]]:
|
|
850
|
+
text = load_text(doc)
|
|
851
|
+
body, block = split_document(text)
|
|
852
|
+
refs = [parse_entry(e) for e in split_entries(block)]
|
|
853
|
+
if not refs:
|
|
854
|
+
sys.exit("error: found a References heading but could not parse any entries under it")
|
|
855
|
+
cites = match_citations(refs, find_citations(body))
|
|
856
|
+
return refs, cites
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def main() -> int:
|
|
860
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
861
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
862
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
863
|
+
|
|
864
|
+
for name in ("check", "claims"):
|
|
865
|
+
p = sub.add_parser(name)
|
|
866
|
+
p.add_argument("doc", help="Markdown or .docx document")
|
|
867
|
+
p.add_argument("--out", help="write the report here instead of stdout")
|
|
868
|
+
chk = sub.choices["check"]
|
|
869
|
+
chk.add_argument("--json", dest="json_out", help="also write machine-readable results")
|
|
870
|
+
chk.add_argument("--offline", action="store_true",
|
|
871
|
+
help="parse and cross-check only; make no network calls")
|
|
872
|
+
chk.add_argument("--mailto", default=os.environ.get("CITATION_CHECK_MAILTO"),
|
|
873
|
+
help="contact address sent to Crossref/OpenAlex for their polite "
|
|
874
|
+
"rate-limit pool (optional; also read from CITATION_CHECK_MAILTO)")
|
|
875
|
+
chk.add_argument("--cache", default=".touchneedle-cache", help="HTTP cache directory")
|
|
876
|
+
chk.add_argument("--timeout", type=int, default=25)
|
|
877
|
+
|
|
878
|
+
args = ap.parse_args()
|
|
879
|
+
refs, cites = collect(args.doc)
|
|
880
|
+
|
|
881
|
+
if args.cmd == "claims":
|
|
882
|
+
out = build_claims(refs, cites, args.doc)
|
|
883
|
+
else:
|
|
884
|
+
f = Fetcher(args.cache, timeout=args.timeout, offline=args.offline,
|
|
885
|
+
mailto=args.mailto)
|
|
886
|
+
for i, r in enumerate(refs, 1):
|
|
887
|
+
print(f"[{i}/{len(refs)}] {r.name} ({r.year}{r.suffix}) \u2026",
|
|
888
|
+
file=sys.stderr, flush=True)
|
|
889
|
+
verify(r, f)
|
|
890
|
+
print(f" {r.status}", file=sys.stderr, flush=True)
|
|
891
|
+
out = build_report(refs, cites, args.doc, args.offline)
|
|
892
|
+
if args.json_out:
|
|
893
|
+
with open(args.json_out, "w", encoding="utf-8") as fh:
|
|
894
|
+
json.dump({"references": [dataclasses.asdict(r) for r in refs],
|
|
895
|
+
"citations": [dataclasses.asdict(c) for c in cites]},
|
|
896
|
+
fh, indent=2, ensure_ascii=False)
|
|
897
|
+
|
|
898
|
+
if args.out:
|
|
899
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
900
|
+
fh.write(out + "\n")
|
|
901
|
+
print(f"wrote {args.out}", file=sys.stderr)
|
|
902
|
+
else:
|
|
903
|
+
print(out)
|
|
904
|
+
|
|
905
|
+
if args.cmd == "check":
|
|
906
|
+
return 2 if any(r.status in PROBLEM_STATUSES for r in refs) else 0
|
|
907
|
+
return 0
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
if __name__ == "__main__":
|
|
911
|
+
sys.exit(main())
|