refaudit 0.1.0__tar.gz
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.
- refaudit-0.1.0/.github/CODEOWNERS +3 -0
- refaudit-0.1.0/.github/workflows/ci.yml +29 -0
- refaudit-0.1.0/.github/workflows/release.yml +39 -0
- refaudit-0.1.0/.gitignore +12 -0
- refaudit-0.1.0/CONTRIBUTING.md +50 -0
- refaudit-0.1.0/LICENSE +21 -0
- refaudit-0.1.0/PKG-INFO +223 -0
- refaudit-0.1.0/README.md +202 -0
- refaudit-0.1.0/pyproject.toml +48 -0
- refaudit-0.1.0/src/refaudit/__init__.py +39 -0
- refaudit-0.1.0/src/refaudit/__main__.py +3 -0
- refaudit-0.1.0/src/refaudit/bibtex.py +133 -0
- refaudit-0.1.0/src/refaudit/cache.py +86 -0
- refaudit-0.1.0/src/refaudit/checker.py +205 -0
- refaudit-0.1.0/src/refaudit/cli.py +167 -0
- refaudit-0.1.0/src/refaudit/http.py +229 -0
- refaudit-0.1.0/src/refaudit/models.py +136 -0
- refaudit-0.1.0/src/refaudit/normalize.py +136 -0
- refaudit-0.1.0/src/refaudit/ratelimit.py +103 -0
- refaudit-0.1.0/src/refaudit/resolvers/__init__.py +44 -0
- refaudit-0.1.0/src/refaudit/resolvers/arxiv.py +91 -0
- refaudit-0.1.0/src/refaudit/resolvers/base.py +81 -0
- refaudit-0.1.0/src/refaudit/resolvers/crossref.py +113 -0
- refaudit-0.1.0/src/refaudit/resolvers/openalex.py +112 -0
- refaudit-0.1.0/src/refaudit/xmlsafe.py +85 -0
- refaudit-0.1.0/tests/test_checker.py +165 -0
- refaudit-0.1.0/tests/test_infra.py +141 -0
- refaudit-0.1.0/tests/test_parsing.py +117 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
- run: pip install -e ".[dev]"
|
|
25
|
+
# The suite is offline by design: it uses fake resolvers and never makes a
|
|
26
|
+
# network call, so CI cannot be flaked by a rate-limited upstream.
|
|
27
|
+
- run: pytest -q
|
|
28
|
+
- run: ruff check .
|
|
29
|
+
- run: mypy src --ignore-missing-imports
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI via Trusted Publishing (OIDC). There is deliberately no API
|
|
4
|
+
# token stored in this repository: PyPI verifies the workflow's identity
|
|
5
|
+
# directly, so there is no long-lived credential to leak or rotate.
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
- run: pip install build
|
|
22
|
+
- run: python -m build
|
|
23
|
+
- uses: actions/upload-artifact@v4
|
|
24
|
+
with:
|
|
25
|
+
name: dist
|
|
26
|
+
path: dist/
|
|
27
|
+
|
|
28
|
+
publish:
|
|
29
|
+
needs: build
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
environment: pypi # add required reviewers here to gate releases
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write # required for Trusted Publishing
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/download-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: dist
|
|
38
|
+
path: dist/
|
|
39
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
`main` is protected. Nobody pushes to it directly — changes arrive by pull
|
|
4
|
+
request with a review from a code owner (see `.github/CODEOWNERS`).
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
git checkout -b your-change
|
|
8
|
+
# ...
|
|
9
|
+
git push -u origin your-change
|
|
10
|
+
gh pr create --fill
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
CI runs the test suite on Python 3.10–3.13 plus `ruff` and `mypy`, and must be
|
|
14
|
+
green before merge.
|
|
15
|
+
|
|
16
|
+
## Running the tests
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install -e ".[dev]"
|
|
20
|
+
pytest -q
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The suite is **offline by design**: it exercises the checker through fake
|
|
24
|
+
resolvers and never makes a network call. This is deliberate — the services this
|
|
25
|
+
tool talks to rate-limit aggressively, and a test suite that depended on them
|
|
26
|
+
would fail for reasons that have nothing to do with the change under review.
|
|
27
|
+
|
|
28
|
+
If you add a resolver, add it to `AVAILABLE` in `src/refaudit/resolvers/__init__.py`
|
|
29
|
+
and give it a `RateSpec` whose `rationale` cites the service's own documented
|
|
30
|
+
limit. Please do not guess a rate.
|
|
31
|
+
|
|
32
|
+
## The invariant to preserve
|
|
33
|
+
|
|
34
|
+
**"We could not check" and "this is wrong" must never collapse into each other.**
|
|
35
|
+
|
|
36
|
+
A resolver returns `Found`, `NotFound` or `Unavailable`, and only `Found` may
|
|
37
|
+
produce a negative verdict. `UNVERIFIED` results are reported separately from
|
|
38
|
+
findings and are never cached. If you are tempted to simplify this, read the
|
|
39
|
+
tests in `tests/test_checker.py` under "the central guarantee" first — they
|
|
40
|
+
exist because both failure modes happened during development.
|
|
41
|
+
|
|
42
|
+
## Releasing
|
|
43
|
+
|
|
44
|
+
1. Bump `version` in `pyproject.toml` and `__version__` in `src/refaudit/__init__.py`.
|
|
45
|
+
2. Merge to `main`.
|
|
46
|
+
3. Publish a GitHub release with a tag like `v0.1.1`.
|
|
47
|
+
|
|
48
|
+
The `release` workflow builds and publishes to PyPI using Trusted Publishing.
|
|
49
|
+
There is no API token in this repository; PyPI verifies the workflow's identity
|
|
50
|
+
over OIDC, so there is no long-lived credential to leak.
|
refaudit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SHARE Lab, University of Waterloo
|
|
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.
|
refaudit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: refaudit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify bibliography entries against Crossref, arXiv and OpenAlex before you submit.
|
|
5
|
+
Project-URL: Homepage, https://github.com/uw-share-lab/refaudit
|
|
6
|
+
Project-URL: Issues, https://github.com/uw-share-lab/refaudit/issues
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: arxiv,bibtex,citations,crossref,openalex,research-integrity
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Text Processing :: Markup :: LaTeX
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
18
|
+
Provides-Extra: xml
|
|
19
|
+
Requires-Dist: defusedxml>=0.7; extra == 'xml'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# refaudit
|
|
23
|
+
|
|
24
|
+
Verify the entries in a `.bib` against Crossref, arXiv and OpenAlex, and report
|
|
25
|
+
the ones a human needs to look at.
|
|
26
|
+
|
|
27
|
+
Written for venues that run automated checks for hallucinated or malformed
|
|
28
|
+
references. The failure it is built to catch is not a missing DOI — it is a DOI
|
|
29
|
+
that resolves to **a different paper than the entry claims**. That one is
|
|
30
|
+
invisible when you read the reference list, because the title and authors look
|
|
31
|
+
fine and only the identifier is wrong.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install git+https://github.com/uw-share-lab/refaudit.git
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Python 3.10+. No runtime dependencies.
|
|
40
|
+
|
|
41
|
+
> PyPI publishing is wired up (Trusted Publishing, see `.github/workflows/release.yml`)
|
|
42
|
+
> but no release has been cut yet, so `pip install refaudit` will not work until
|
|
43
|
+
> the first tagged release. Use the command above in the meantime.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# every entry in the file
|
|
49
|
+
refaudit refs.bib --email you@uwaterloo.ca
|
|
50
|
+
|
|
51
|
+
# only the entries actually cited in the paper, which is usually what you want
|
|
52
|
+
refaudit refs.bib --email you@uwaterloo.ca --tex paper/sections --only-cited
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--email` is required. Crossref and OpenAlex give identified callers a separate,
|
|
56
|
+
more reliable request pool, and it is the courtesy their documentation asks for.
|
|
57
|
+
Set it once instead of typing it each time:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export REFAUDIT_EMAIL=you@uwaterloo.ca
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Results are written to `refaudit-out/` as `reference_check.txt` (readable) and
|
|
64
|
+
`reference_check.csv` (sortable), and printed to stdout.
|
|
65
|
+
|
|
66
|
+
### Working from Overleaf
|
|
67
|
+
|
|
68
|
+
Download the `.bib` (Menu → Download → Source, or just the file), then point
|
|
69
|
+
`--tex` at the unzipped `sections/` directory so `--only-cited` can tell which
|
|
70
|
+
keys actually reach the PDF:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
refaudit sample-base.bib --email you@uwaterloo.ca --tex sections/ --only-cited
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## What it reports
|
|
77
|
+
|
|
78
|
+
| Verdict | Meaning |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `TITLE_MISMATCH` | an identifier resolved to a different paper — check first |
|
|
81
|
+
| `DEAD_DOI` | the DOI is not registered |
|
|
82
|
+
| `AUTHOR_MISMATCH` | titles agree, first author does not |
|
|
83
|
+
| `YEAR_MISMATCH` | titles agree, year is off by more than one |
|
|
84
|
+
| `NOT_FOUND` | no identifier, and no title match anywhere |
|
|
85
|
+
| `UNVERIFIED` | **a source was unreachable — this says nothing about the entry** |
|
|
86
|
+
| `SKIPPED` | `@misc`/`@online` with no identifier; nothing to check against |
|
|
87
|
+
| `OK` | resolved and consistent |
|
|
88
|
+
|
|
89
|
+
Exit status is `1` if there is at least one finding, `0` if not, `2` on a usage
|
|
90
|
+
error — so it drops into CI or a pre-submission script.
|
|
91
|
+
|
|
92
|
+
## Options
|
|
93
|
+
|
|
94
|
+
| Flag | Effect |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `--email` | contact address sent to the APIs (or `REFAUDIT_EMAIL`). Required. |
|
|
97
|
+
| `--tex PATH` | LaTeX file or directory, used to work out which keys are cited |
|
|
98
|
+
| `--only-cited` | check only cited keys; requires `--tex` |
|
|
99
|
+
| `--resolvers` | comma-separated subset of `crossref:doi`, `arxiv:id`, `openalex`, `crossref:title` |
|
|
100
|
+
| `--out DIR` | output directory (default `refaudit-out`) |
|
|
101
|
+
| `--cache PATH` / `--no-cache` | cache location, or disable it |
|
|
102
|
+
| `--ttl-days N` | how long cached results stay valid (default 90) |
|
|
103
|
+
| `--timeout N` | per-request timeout in seconds (default 20) |
|
|
104
|
+
| `--title-match N` | similarity at or above which two titles are the same work (default 0.75) |
|
|
105
|
+
| `--quiet` | suppress per-entry progress, print only the summary |
|
|
106
|
+
|
|
107
|
+
A run over a few hundred references takes minutes, because it is deliberately
|
|
108
|
+
paced. Successful lookups are cached, so it is safe to interrupt with Ctrl-C and
|
|
109
|
+
re-run — it picks up where it stopped.
|
|
110
|
+
|
|
111
|
+
## The one design rule
|
|
112
|
+
|
|
113
|
+
**"We could not check" and "this is wrong" are different answers and never
|
|
114
|
+
collapse into each other.**
|
|
115
|
+
|
|
116
|
+
This sounds obvious and is easy to get wrong. arXiv rate-limits whole networks;
|
|
117
|
+
when that happens, a naive checker either silently passes the entry (false
|
|
118
|
+
comfort, the worse failure) or falls back to a title search, finds something
|
|
119
|
+
loosely related, and reports a mismatch (false alarm, which trains you to ignore
|
|
120
|
+
it). Both are worse than saying "I could not check this one."
|
|
121
|
+
|
|
122
|
+
So every resolver returns exactly one of `Found`, `NotFound`, or `Unavailable`,
|
|
123
|
+
and only `Found` can produce a negative verdict. `UNVERIFIED` results are listed
|
|
124
|
+
separately from findings and are never cached, so a transient outage does not
|
|
125
|
+
get baked into later runs.
|
|
126
|
+
|
|
127
|
+
Relatedly, evidence is weighted by strength: a DOI that Crossref does not
|
|
128
|
+
recognise is a finding, but a title search returning something different is only
|
|
129
|
+
a finding when there was no identifier to go on. Otherwise every arXiv-only
|
|
130
|
+
workshop paper that Crossref does not index would be flagged.
|
|
131
|
+
|
|
132
|
+
## Rate limiting
|
|
133
|
+
|
|
134
|
+
Each source declares the limit its own documentation specifies, next to the code
|
|
135
|
+
that calls it:
|
|
136
|
+
|
|
137
|
+
| Source | Rate used | Why |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| Crossref | 2/s, then whatever the response headers say | Crossref publishes `X-Rate-Limit-Limit` / `-Interval`; the client reads and obeys them |
|
|
140
|
+
| arXiv | 1 per 3s | [arXiv's terms of use](https://info.arxiv.org/help/api/tou.html) specify exactly this |
|
|
141
|
+
| OpenAlex | 3/s | [documented](https://docs.openalex.org/how-to-use-the-api/rate-limits-and-authentication) ceiling is 10/s and 100k/day; we use a third of it |
|
|
142
|
+
|
|
143
|
+
A `429` is treated as instruction rather than noise: `Retry-After` is honoured,
|
|
144
|
+
the token bucket is permanently halved, and after repeated refusals a circuit
|
|
145
|
+
breaker stops asking that host so the rest of the run still finishes. Retries use
|
|
146
|
+
exponential backoff with full jitter.
|
|
147
|
+
|
|
148
|
+
`--email` is required because Crossref and OpenAlex give identified callers a
|
|
149
|
+
separate, more reliable pool, and it is the courtesy their docs ask for.
|
|
150
|
+
|
|
151
|
+
## Security
|
|
152
|
+
|
|
153
|
+
- **HTTPS only.** Plain-`http` URLs are refused, not silently upgraded.
|
|
154
|
+
- **Bounded redirects**, kept on https, so a redirect cannot downgrade transport
|
|
155
|
+
or forward the `mailto` identifier somewhere unexpected.
|
|
156
|
+
- **Response size cap and per-request timeouts** on every call.
|
|
157
|
+
- **XML is parsed with entity declarations refused**, blocking billion-laughs and
|
|
158
|
+
XXE. Uses `defusedxml` when installed, otherwise a hardened stdlib path; both
|
|
159
|
+
raise the same exception type so callers cannot miss one.
|
|
160
|
+
- **DOIs and arXiv ids are validated against a pattern before being interpolated
|
|
161
|
+
into a request path**, so a malformed field cannot steer the URL.
|
|
162
|
+
- **No credentials in code.** Optional API keys come from the environment and are
|
|
163
|
+
sent as headers, never query parameters, so they stay out of logs.
|
|
164
|
+
- **No runtime dependencies.** This gets installed in a hurry near a deadline,
|
|
165
|
+
often on a machine someone else administers; that is the wrong moment to widen
|
|
166
|
+
the supply chain.
|
|
167
|
+
|
|
168
|
+
## Troubleshooting
|
|
169
|
+
|
|
170
|
+
**Lots of `UNVERIFIED` results.** A source refused your network. arXiv in
|
|
171
|
+
particular rate-limits by IP and will 429 an entire institution or VPN
|
|
172
|
+
regardless of your own pace. These are not findings — the entries were simply
|
|
173
|
+
not checked. Try again later, from a different network, or lean on OpenAlex,
|
|
174
|
+
which indexes arXiv identifiers too:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
refaudit refs.bib --email you@uwaterloo.ca --resolvers crossref:doi,openalex
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**A correct entry is flagged `NOT_FOUND`.** Workshop papers, theses and
|
|
181
|
+
tech reports are often in no citation index. If the entry has no DOI and no
|
|
182
|
+
arXiv id there is nothing to verify it against; confirm it by hand and move on.
|
|
183
|
+
|
|
184
|
+
**A correct entry is flagged `TITLE_MISMATCH`.** This one is worth taking
|
|
185
|
+
seriously: it means the DOI or arXiv id in your `.bib` resolves to a different
|
|
186
|
+
paper. Usually the identifier was copied from the wrong row, or generated rather
|
|
187
|
+
than looked up. Check the `found` line in the report against what you meant to
|
|
188
|
+
cite.
|
|
189
|
+
|
|
190
|
+
**Everything is `SKIPPED`.** `@misc` and `@online` entries with no identifier
|
|
191
|
+
cannot be checked. That is expected for datasets, blog posts and software.
|
|
192
|
+
|
|
193
|
+
## Library use
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from refaudit import Checker, default_resolvers, parse_file
|
|
197
|
+
|
|
198
|
+
entries = parse_file("refs.bib")
|
|
199
|
+
checker = Checker(default_resolvers("you@university.edu"))
|
|
200
|
+
|
|
201
|
+
for result in checker.check_all(entries):
|
|
202
|
+
if result.verdict.is_finding:
|
|
203
|
+
print(result.key, result.verdict.value, result.note)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`Resolver` is a `Protocol`: implement `name`, `rate`, `can_handle` and `resolve`
|
|
207
|
+
to add a source, and pass it to `Checker` alongside the built-ins.
|
|
208
|
+
|
|
209
|
+
## Development
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
pip install -e ".[dev]"
|
|
213
|
+
pytest # offline: the suite uses fake resolvers and never hits a network
|
|
214
|
+
ruff check .
|
|
215
|
+
mypy src
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`main` is protected: changes go through a pull request with a code-owner review,
|
|
219
|
+
and CI must pass on Python 3.10-3.13. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
220
|
+
|
|
221
|
+
## Licence
|
|
222
|
+
|
|
223
|
+
MIT.
|
refaudit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# refaudit
|
|
2
|
+
|
|
3
|
+
Verify the entries in a `.bib` against Crossref, arXiv and OpenAlex, and report
|
|
4
|
+
the ones a human needs to look at.
|
|
5
|
+
|
|
6
|
+
Written for venues that run automated checks for hallucinated or malformed
|
|
7
|
+
references. The failure it is built to catch is not a missing DOI — it is a DOI
|
|
8
|
+
that resolves to **a different paper than the entry claims**. That one is
|
|
9
|
+
invisible when you read the reference list, because the title and authors look
|
|
10
|
+
fine and only the identifier is wrong.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install git+https://github.com/uw-share-lab/refaudit.git
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Python 3.10+. No runtime dependencies.
|
|
19
|
+
|
|
20
|
+
> PyPI publishing is wired up (Trusted Publishing, see `.github/workflows/release.yml`)
|
|
21
|
+
> but no release has been cut yet, so `pip install refaudit` will not work until
|
|
22
|
+
> the first tagged release. Use the command above in the meantime.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# every entry in the file
|
|
28
|
+
refaudit refs.bib --email you@uwaterloo.ca
|
|
29
|
+
|
|
30
|
+
# only the entries actually cited in the paper, which is usually what you want
|
|
31
|
+
refaudit refs.bib --email you@uwaterloo.ca --tex paper/sections --only-cited
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`--email` is required. Crossref and OpenAlex give identified callers a separate,
|
|
35
|
+
more reliable request pool, and it is the courtesy their documentation asks for.
|
|
36
|
+
Set it once instead of typing it each time:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
export REFAUDIT_EMAIL=you@uwaterloo.ca
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Results are written to `refaudit-out/` as `reference_check.txt` (readable) and
|
|
43
|
+
`reference_check.csv` (sortable), and printed to stdout.
|
|
44
|
+
|
|
45
|
+
### Working from Overleaf
|
|
46
|
+
|
|
47
|
+
Download the `.bib` (Menu → Download → Source, or just the file), then point
|
|
48
|
+
`--tex` at the unzipped `sections/` directory so `--only-cited` can tell which
|
|
49
|
+
keys actually reach the PDF:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
refaudit sample-base.bib --email you@uwaterloo.ca --tex sections/ --only-cited
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## What it reports
|
|
56
|
+
|
|
57
|
+
| Verdict | Meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `TITLE_MISMATCH` | an identifier resolved to a different paper — check first |
|
|
60
|
+
| `DEAD_DOI` | the DOI is not registered |
|
|
61
|
+
| `AUTHOR_MISMATCH` | titles agree, first author does not |
|
|
62
|
+
| `YEAR_MISMATCH` | titles agree, year is off by more than one |
|
|
63
|
+
| `NOT_FOUND` | no identifier, and no title match anywhere |
|
|
64
|
+
| `UNVERIFIED` | **a source was unreachable — this says nothing about the entry** |
|
|
65
|
+
| `SKIPPED` | `@misc`/`@online` with no identifier; nothing to check against |
|
|
66
|
+
| `OK` | resolved and consistent |
|
|
67
|
+
|
|
68
|
+
Exit status is `1` if there is at least one finding, `0` if not, `2` on a usage
|
|
69
|
+
error — so it drops into CI or a pre-submission script.
|
|
70
|
+
|
|
71
|
+
## Options
|
|
72
|
+
|
|
73
|
+
| Flag | Effect |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `--email` | contact address sent to the APIs (or `REFAUDIT_EMAIL`). Required. |
|
|
76
|
+
| `--tex PATH` | LaTeX file or directory, used to work out which keys are cited |
|
|
77
|
+
| `--only-cited` | check only cited keys; requires `--tex` |
|
|
78
|
+
| `--resolvers` | comma-separated subset of `crossref:doi`, `arxiv:id`, `openalex`, `crossref:title` |
|
|
79
|
+
| `--out DIR` | output directory (default `refaudit-out`) |
|
|
80
|
+
| `--cache PATH` / `--no-cache` | cache location, or disable it |
|
|
81
|
+
| `--ttl-days N` | how long cached results stay valid (default 90) |
|
|
82
|
+
| `--timeout N` | per-request timeout in seconds (default 20) |
|
|
83
|
+
| `--title-match N` | similarity at or above which two titles are the same work (default 0.75) |
|
|
84
|
+
| `--quiet` | suppress per-entry progress, print only the summary |
|
|
85
|
+
|
|
86
|
+
A run over a few hundred references takes minutes, because it is deliberately
|
|
87
|
+
paced. Successful lookups are cached, so it is safe to interrupt with Ctrl-C and
|
|
88
|
+
re-run — it picks up where it stopped.
|
|
89
|
+
|
|
90
|
+
## The one design rule
|
|
91
|
+
|
|
92
|
+
**"We could not check" and "this is wrong" are different answers and never
|
|
93
|
+
collapse into each other.**
|
|
94
|
+
|
|
95
|
+
This sounds obvious and is easy to get wrong. arXiv rate-limits whole networks;
|
|
96
|
+
when that happens, a naive checker either silently passes the entry (false
|
|
97
|
+
comfort, the worse failure) or falls back to a title search, finds something
|
|
98
|
+
loosely related, and reports a mismatch (false alarm, which trains you to ignore
|
|
99
|
+
it). Both are worse than saying "I could not check this one."
|
|
100
|
+
|
|
101
|
+
So every resolver returns exactly one of `Found`, `NotFound`, or `Unavailable`,
|
|
102
|
+
and only `Found` can produce a negative verdict. `UNVERIFIED` results are listed
|
|
103
|
+
separately from findings and are never cached, so a transient outage does not
|
|
104
|
+
get baked into later runs.
|
|
105
|
+
|
|
106
|
+
Relatedly, evidence is weighted by strength: a DOI that Crossref does not
|
|
107
|
+
recognise is a finding, but a title search returning something different is only
|
|
108
|
+
a finding when there was no identifier to go on. Otherwise every arXiv-only
|
|
109
|
+
workshop paper that Crossref does not index would be flagged.
|
|
110
|
+
|
|
111
|
+
## Rate limiting
|
|
112
|
+
|
|
113
|
+
Each source declares the limit its own documentation specifies, next to the code
|
|
114
|
+
that calls it:
|
|
115
|
+
|
|
116
|
+
| Source | Rate used | Why |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| Crossref | 2/s, then whatever the response headers say | Crossref publishes `X-Rate-Limit-Limit` / `-Interval`; the client reads and obeys them |
|
|
119
|
+
| arXiv | 1 per 3s | [arXiv's terms of use](https://info.arxiv.org/help/api/tou.html) specify exactly this |
|
|
120
|
+
| OpenAlex | 3/s | [documented](https://docs.openalex.org/how-to-use-the-api/rate-limits-and-authentication) ceiling is 10/s and 100k/day; we use a third of it |
|
|
121
|
+
|
|
122
|
+
A `429` is treated as instruction rather than noise: `Retry-After` is honoured,
|
|
123
|
+
the token bucket is permanently halved, and after repeated refusals a circuit
|
|
124
|
+
breaker stops asking that host so the rest of the run still finishes. Retries use
|
|
125
|
+
exponential backoff with full jitter.
|
|
126
|
+
|
|
127
|
+
`--email` is required because Crossref and OpenAlex give identified callers a
|
|
128
|
+
separate, more reliable pool, and it is the courtesy their docs ask for.
|
|
129
|
+
|
|
130
|
+
## Security
|
|
131
|
+
|
|
132
|
+
- **HTTPS only.** Plain-`http` URLs are refused, not silently upgraded.
|
|
133
|
+
- **Bounded redirects**, kept on https, so a redirect cannot downgrade transport
|
|
134
|
+
or forward the `mailto` identifier somewhere unexpected.
|
|
135
|
+
- **Response size cap and per-request timeouts** on every call.
|
|
136
|
+
- **XML is parsed with entity declarations refused**, blocking billion-laughs and
|
|
137
|
+
XXE. Uses `defusedxml` when installed, otherwise a hardened stdlib path; both
|
|
138
|
+
raise the same exception type so callers cannot miss one.
|
|
139
|
+
- **DOIs and arXiv ids are validated against a pattern before being interpolated
|
|
140
|
+
into a request path**, so a malformed field cannot steer the URL.
|
|
141
|
+
- **No credentials in code.** Optional API keys come from the environment and are
|
|
142
|
+
sent as headers, never query parameters, so they stay out of logs.
|
|
143
|
+
- **No runtime dependencies.** This gets installed in a hurry near a deadline,
|
|
144
|
+
often on a machine someone else administers; that is the wrong moment to widen
|
|
145
|
+
the supply chain.
|
|
146
|
+
|
|
147
|
+
## Troubleshooting
|
|
148
|
+
|
|
149
|
+
**Lots of `UNVERIFIED` results.** A source refused your network. arXiv in
|
|
150
|
+
particular rate-limits by IP and will 429 an entire institution or VPN
|
|
151
|
+
regardless of your own pace. These are not findings — the entries were simply
|
|
152
|
+
not checked. Try again later, from a different network, or lean on OpenAlex,
|
|
153
|
+
which indexes arXiv identifiers too:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
refaudit refs.bib --email you@uwaterloo.ca --resolvers crossref:doi,openalex
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**A correct entry is flagged `NOT_FOUND`.** Workshop papers, theses and
|
|
160
|
+
tech reports are often in no citation index. If the entry has no DOI and no
|
|
161
|
+
arXiv id there is nothing to verify it against; confirm it by hand and move on.
|
|
162
|
+
|
|
163
|
+
**A correct entry is flagged `TITLE_MISMATCH`.** This one is worth taking
|
|
164
|
+
seriously: it means the DOI or arXiv id in your `.bib` resolves to a different
|
|
165
|
+
paper. Usually the identifier was copied from the wrong row, or generated rather
|
|
166
|
+
than looked up. Check the `found` line in the report against what you meant to
|
|
167
|
+
cite.
|
|
168
|
+
|
|
169
|
+
**Everything is `SKIPPED`.** `@misc` and `@online` entries with no identifier
|
|
170
|
+
cannot be checked. That is expected for datasets, blog posts and software.
|
|
171
|
+
|
|
172
|
+
## Library use
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from refaudit import Checker, default_resolvers, parse_file
|
|
176
|
+
|
|
177
|
+
entries = parse_file("refs.bib")
|
|
178
|
+
checker = Checker(default_resolvers("you@university.edu"))
|
|
179
|
+
|
|
180
|
+
for result in checker.check_all(entries):
|
|
181
|
+
if result.verdict.is_finding:
|
|
182
|
+
print(result.key, result.verdict.value, result.note)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`Resolver` is a `Protocol`: implement `name`, `rate`, `can_handle` and `resolve`
|
|
186
|
+
to add a source, and pass it to `Checker` alongside the built-ins.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
pip install -e ".[dev]"
|
|
192
|
+
pytest # offline: the suite uses fake resolvers and never hits a network
|
|
193
|
+
ruff check .
|
|
194
|
+
mypy src
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`main` is protected: changes go through a pull request with a code-owner review,
|
|
198
|
+
and CI must pass on Python 3.10-3.13. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
199
|
+
|
|
200
|
+
## Licence
|
|
201
|
+
|
|
202
|
+
MIT.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.20"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "refaudit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Verify bibliography entries against Crossref, arXiv and OpenAlex before you submit."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["bibtex", "citations", "crossref", "openalex", "arxiv", "research-integrity"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Intended Audience :: Science/Research",
|
|
16
|
+
"Topic :: Text Processing :: Markup :: LaTeX",
|
|
17
|
+
]
|
|
18
|
+
# No runtime dependencies on purpose: this gets installed in a hurry, close to a
|
|
19
|
+
# deadline, often on a machine someone else administers. defusedxml is used if
|
|
20
|
+
# present but a hardened stdlib parser is built in.
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
xml = ["defusedxml>=0.7"]
|
|
25
|
+
dev = ["pytest>=7", "mypy>=1.5", "ruff>=0.4"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
refaudit = "refaudit.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/uw-share-lab/refaudit"
|
|
32
|
+
Issues = "https://github.com/uw-share-lab/refaudit/issues"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/refaudit"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
addopts = "-q"
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
|
|
45
|
+
[tool.mypy]
|
|
46
|
+
python_version = "3.10"
|
|
47
|
+
warn_unused_ignores = true
|
|
48
|
+
disallow_untyped_defs = false
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""refaudit -- verify bibliography entries against external indexes.
|
|
2
|
+
|
|
3
|
+
Built for the case where a venue runs an automated check for hallucinated or
|
|
4
|
+
malformed references and a false entry costs you a desk reject.
|
|
5
|
+
|
|
6
|
+
from refaudit import Checker, parse_file, default_resolvers
|
|
7
|
+
|
|
8
|
+
entries = parse_file("refs.bib")
|
|
9
|
+
checker = Checker(default_resolvers("you@example.org"))
|
|
10
|
+
for result in checker.check_all(entries):
|
|
11
|
+
print(result.key, result.verdict.value)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .bibtex import cited_keys, find_tex, parse_file, parse_string
|
|
15
|
+
from .cache import Cache
|
|
16
|
+
from .checker import Checker, Thresholds
|
|
17
|
+
from .models import CheckResult, Entry, Found, NotFound, Record, Unavailable, Verdict
|
|
18
|
+
from .resolvers import default_resolvers
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Cache",
|
|
24
|
+
"CheckResult",
|
|
25
|
+
"Checker",
|
|
26
|
+
"Entry",
|
|
27
|
+
"Found",
|
|
28
|
+
"NotFound",
|
|
29
|
+
"Record",
|
|
30
|
+
"Thresholds",
|
|
31
|
+
"Unavailable",
|
|
32
|
+
"Verdict",
|
|
33
|
+
"__version__",
|
|
34
|
+
"cited_keys",
|
|
35
|
+
"default_resolvers",
|
|
36
|
+
"find_tex",
|
|
37
|
+
"parse_file",
|
|
38
|
+
"parse_string",
|
|
39
|
+
]
|