reference-audit 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.
- reference_audit/__init__.py +8 -0
- reference_audit/alignmentcheck.py +136 -0
- reference_audit/bookcheck.py +98 -0
- reference_audit/cache/__init__.py +1 -0
- reference_audit/cache/db.py +108 -0
- reference_audit/cache/store.py +128 -0
- reference_audit/cli.py +190 -0
- reference_audit/config.py +179 -0
- reference_audit/fieldcheck.py +599 -0
- reference_audit/inputs.py +96 -0
- reference_audit/llm/__init__.py +6 -0
- reference_audit/llm/client.py +85 -0
- reference_audit/llm/prompts.py +206 -0
- reference_audit/llm/schemas.py +38 -0
- reference_audit/matching/__init__.py +6 -0
- reference_audit/matching/adjudicate.py +63 -0
- reference_audit/matching/features.py +185 -0
- reference_audit/matching/names.py +238 -0
- reference_audit/matching/pool.py +344 -0
- reference_audit/matching/sameobject.py +150 -0
- reference_audit/matching/scoring.py +75 -0
- reference_audit/matching/verdict.py +56 -0
- reference_audit/matching/webcheck.py +189 -0
- reference_audit/models.py +412 -0
- reference_audit/parsing/__init__.py +1 -0
- reference_audit/parsing/bib.py +392 -0
- reference_audit/parsing/context.py +55 -0
- reference_audit/parsing/entities.py +22 -0
- reference_audit/parsing/identifiers.py +165 -0
- reference_audit/parsing/latex.py +83 -0
- reference_audit/parsing/tex.py +159 -0
- reference_audit/pdf/__init__.py +16 -0
- reference_audit/pdf/embedded.py +117 -0
- reference_audit/pdf/grobid.py +234 -0
- reference_audit/pdf/tei.py +470 -0
- reference_audit/pipeline.py +1692 -0
- reference_audit/report.py +330 -0
- reference_audit/sources/__init__.py +6 -0
- reference_audit/sources/arxiv.py +81 -0
- reference_audit/sources/base.py +54 -0
- reference_audit/sources/clickhouse.py +575 -0
- reference_audit/sources/crossref.py +90 -0
- reference_audit/sources/dblp.py +140 -0
- reference_audit/sources/google_books.py +101 -0
- reference_audit/sources/http.py +260 -0
- reference_audit/sources/normalize.py +464 -0
- reference_audit/sources/openalex.py +93 -0
- reference_audit/sources/openlibrary.py +158 -0
- reference_audit/sources/publisher.py +186 -0
- reference_audit/sources/registry.py +117 -0
- reference_audit/sources/render.py +115 -0
- reference_audit/sources/semantic_scholar.py +68 -0
- reference_audit/sources/titlewords.py +31 -0
- reference_audit/sources/web.py +256 -0
- reference_audit/versioning.py +125 -0
- reference_audit-0.1.0.dist-info/METADATA +715 -0
- reference_audit-0.1.0.dist-info/RECORD +60 -0
- reference_audit-0.1.0.dist-info/WHEEL +4 -0
- reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
- reference_audit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
r"""BibTeX parsing → BibEntry.
|
|
2
|
+
|
|
3
|
+
bibtexparser 2 only splits the text into blocks (`parse_string` with an empty parse stack). This
|
|
4
|
+
module reads the field values itself, because the library's default middlewares would get them wrong
|
|
5
|
+
in ways nothing reports: they leave a `#` concatenation (`venue # " 2020"`) and a month macro
|
|
6
|
+
(`month = jul`) as raw text, and their LaTeX decoder truncates a URL at its first `%`
|
|
7
|
+
(`parsing.latex`). `read_bib_records` resolves `@string` references, `#` concatenation and BibTeX's
|
|
8
|
+
month macros, then decodes LaTeX (`decode_latex`: {\'e}→é) for clean matching. It never guesses: a
|
|
9
|
+
reference to an undefined `@string`, a duplicate field, or a value pylatexenc cannot read makes the
|
|
10
|
+
entry unparsed, with the reason. Three further non-standard behaviors:
|
|
11
|
+
|
|
12
|
+
1. **Commented-twin detection.** The pilot has a `%@misc{bagrov2024visual, ...}` block whose
|
|
13
|
+
only-commented header is the arXiv preprint of `kravchenko2026`. bibtexparser reads a `%` line as
|
|
14
|
+
a comment only up to the next `@`, so it may or may not parse such a block, and a fully commented
|
|
15
|
+
one parses into fields named `% title`. Commentedness is therefore decided from the *raw source
|
|
16
|
+
line*, and every such entry is read from the uncommented text and routed to `twins`
|
|
17
|
+
(informational), never the audited list. T1 is solved from the DBs, not from this block.
|
|
18
|
+
2. **Dropped-entry detection.** bibtexparser reports a block it cannot split as a failed block with
|
|
19
|
+
no key. Every live `@type{key` header in the raw text that produced no entry is returned as an
|
|
20
|
+
`UnparsedEntry` with the reason, so the report can say it was not checked.
|
|
21
|
+
3. **Duplicate keys.** bibtexparser sets a repeated entry key aside as a failed block. Both entries
|
|
22
|
+
are audited, as written.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import NamedTuple
|
|
30
|
+
|
|
31
|
+
import bibtexparser
|
|
32
|
+
from bibtexparser.exceptions import BlockAbortedException
|
|
33
|
+
from bibtexparser.library import Library
|
|
34
|
+
from bibtexparser.model import (
|
|
35
|
+
DuplicateBlockKeyBlock,
|
|
36
|
+
DuplicateFieldKeyBlock,
|
|
37
|
+
Entry,
|
|
38
|
+
ParsingFailedBlock,
|
|
39
|
+
String,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from reference_audit.models import BibEntry, Identifiers, UnparsedEntry, entry_type_from_bib
|
|
43
|
+
from reference_audit.parsing.entities import decode_html_entities
|
|
44
|
+
from reference_audit.parsing.identifiers import (
|
|
45
|
+
extract_arxiv_id,
|
|
46
|
+
normalize_doi,
|
|
47
|
+
normalize_google_books_id,
|
|
48
|
+
normalize_isbn13,
|
|
49
|
+
normalize_openalex_id,
|
|
50
|
+
normalize_url,
|
|
51
|
+
)
|
|
52
|
+
from reference_audit.parsing.latex import LatexDecodeError, decode_latex
|
|
53
|
+
|
|
54
|
+
# An @type{key occurrence; we inspect whether a '%' precedes the '@' on the same line.
|
|
55
|
+
_ENTRY_LINE_RE = re.compile(r"(?m)^(?P<pre>[^\n@]*)@(?P<type>\w+)\s*\{\s*(?P<key>[^,\s}]+)")
|
|
56
|
+
# `@string`, `@preamble` and `@comment` blocks are not entries.
|
|
57
|
+
_NON_ENTRY_TYPES = {"string", "preamble", "comment"}
|
|
58
|
+
# BibTeX's predefined month macros (`month = jul`), expanded to the full name as bibtex does.
|
|
59
|
+
_MONTH_MACROS = {
|
|
60
|
+
name[:3].lower(): name
|
|
61
|
+
for name in (
|
|
62
|
+
"January", "February", "March", "April", "May", "June",
|
|
63
|
+
"July", "August", "September", "October", "November", "December",
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
# A bare operand of a field value: a `@string` name (or a number, checked first).
|
|
67
|
+
_STRING_NAME_RE = re.compile(r"[^\s\"#%'(),={}]+")
|
|
68
|
+
_NOT_CHECKED = "the entry was not read, so it was not checked"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _classify_keys(raw: str) -> set[str]:
|
|
72
|
+
"""Return keys whose @type{key header appears ONLY in a commented (`%`-prefixed) line."""
|
|
73
|
+
commented: set[str] = set()
|
|
74
|
+
live: set[str] = set()
|
|
75
|
+
for m in _ENTRY_LINE_RE.finditer(raw):
|
|
76
|
+
key = m.group("key")
|
|
77
|
+
pre = m.group("pre")
|
|
78
|
+
# A '%' anywhere before '@' on the line (not escaped) ⇒ commented occurrence.
|
|
79
|
+
if re.search(r"(?<!\\)%", pre):
|
|
80
|
+
commented.add(key)
|
|
81
|
+
else:
|
|
82
|
+
live.add(key)
|
|
83
|
+
return commented - live
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _clean(s: str) -> str:
|
|
87
|
+
# HTML references (`d'Amore`) come from web-scraped metadata; see `parsing.entities`.
|
|
88
|
+
text = decode_html_entities(s.replace("{", "").replace("}", ""))
|
|
89
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _split_authors(author_field: str) -> list[str]:
|
|
93
|
+
if not author_field:
|
|
94
|
+
return []
|
|
95
|
+
parts = re.split(r"\s+and\s+", author_field.strip())
|
|
96
|
+
return [_clean(p) for p in parts if _clean(p)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _year(value: str | None) -> int | None:
|
|
100
|
+
if not value:
|
|
101
|
+
return None
|
|
102
|
+
m = re.search(r"\d{4}", value)
|
|
103
|
+
return int(m.group(0)) if m else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _identifiers_from_fields(f: dict[str, str]) -> Identifiers:
|
|
107
|
+
doi = normalize_doi(f.get("doi")) or normalize_doi(f.get("url"))
|
|
108
|
+
arxiv = extract_arxiv_id(
|
|
109
|
+
f.get("eprint"),
|
|
110
|
+
f.get("archiveprefix"),
|
|
111
|
+
fallback_text=f.get("doi") or f.get("url"),
|
|
112
|
+
)
|
|
113
|
+
isbn13 = normalize_isbn13(f.get("isbn"))
|
|
114
|
+
# An openalex.org `url` is a resolvable Work id, not a generic landing page: extract it as a
|
|
115
|
+
# first-class identifier and drop it from `url` so it isn't mistaken for a web @misc page.
|
|
116
|
+
openalex = normalize_openalex_id(f.get("url"))
|
|
117
|
+
# A books.google `url` carries a resolvable volume id — extract it as a first-class identifier
|
|
118
|
+
# (the authoritative key for that exact volume). The URL itself is kept (it is a real landing
|
|
119
|
+
# page, unlike an openalex.org Work URL) so a dead-link check can still see it.
|
|
120
|
+
google_books = normalize_google_books_id(f.get("url"))
|
|
121
|
+
url = None if openalex else normalize_url(f.get("url"))
|
|
122
|
+
pmid = (f.get("pmid") or "").strip() or None
|
|
123
|
+
return Identifiers(
|
|
124
|
+
doi=doi, arxiv_id=arxiv, isbn13=isbn13, url=url, pmid=pmid,
|
|
125
|
+
openalex=openalex, google_books=google_books,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def entry_from_fields(
|
|
130
|
+
key: str,
|
|
131
|
+
bib_type: str,
|
|
132
|
+
f: dict[str, str],
|
|
133
|
+
*,
|
|
134
|
+
commented: bool = False,
|
|
135
|
+
authors: list[str] | None = None,
|
|
136
|
+
) -> BibEntry:
|
|
137
|
+
"""Build a `BibEntry` from a flat `{field: str}` dict — the single construction seam.
|
|
138
|
+
|
|
139
|
+
Any front end that produces BibTeX-shaped fields goes through here (the `.bib` parser below, and
|
|
140
|
+
`pdf.tei` for GROBID's TEI), so identifier normalization, venue selection, cleaning and type
|
|
141
|
+
mapping are defined exactly once.
|
|
142
|
+
|
|
143
|
+
`authors` bypasses the `" and "` split for callers that already have a *list* of names. TEI gives
|
|
144
|
+
us structured `<persName>` elements, and re-joining them into `"A and B"` only to re-split would
|
|
145
|
+
corrupt an organizational author such as "Smith and Sons".
|
|
146
|
+
"""
|
|
147
|
+
venue = f.get("journal") or f.get("booktitle") or f.get("howpublished") or ""
|
|
148
|
+
return BibEntry(
|
|
149
|
+
key=key,
|
|
150
|
+
entry_type=entry_type_from_bib(bib_type),
|
|
151
|
+
title=_clean(f.get("title", "")),
|
|
152
|
+
authors=authors if authors is not None else _split_authors(f.get("author", "")),
|
|
153
|
+
year=_year(f.get("year")),
|
|
154
|
+
venue=_clean(venue),
|
|
155
|
+
publisher=_clean(f.get("publisher", "")),
|
|
156
|
+
pages=_clean(f.get("pages", "")),
|
|
157
|
+
ids=_identifiers_from_fields(f),
|
|
158
|
+
raw_fields={k: v for k, v in f.items() if isinstance(v, str)},
|
|
159
|
+
is_commented=commented,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _FieldError(ValueError):
|
|
164
|
+
"""A field value that cannot be read without guessing."""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _operands(raw: str) -> list[str]:
|
|
168
|
+
"""`a # "b" # {c}` → its operands. A `#` inside braces or quotes is text."""
|
|
169
|
+
parts: list[str] = []
|
|
170
|
+
depth, quoted, start = 0, False, 0
|
|
171
|
+
for i, ch in enumerate(raw):
|
|
172
|
+
if ch == "{":
|
|
173
|
+
depth += 1
|
|
174
|
+
elif ch == "}":
|
|
175
|
+
depth -= 1
|
|
176
|
+
elif ch == '"' and depth == 0:
|
|
177
|
+
quoted = not quoted
|
|
178
|
+
elif ch == "#" and depth == 0 and not quoted:
|
|
179
|
+
parts.append(raw[start:i])
|
|
180
|
+
start = i + 1
|
|
181
|
+
parts.append(raw[start:])
|
|
182
|
+
return [p.strip() for p in parts]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _resolve_value(raw: str, strings: dict[str, str], string_errors: dict[str, str]) -> str:
|
|
186
|
+
"""A raw BibTeX value (`{X}`, `"X"`, `2020`, `jul`, `venue # " 2020"`) → its text."""
|
|
187
|
+
out: list[str] = []
|
|
188
|
+
for part in _operands(raw):
|
|
189
|
+
if len(part) >= 2 and (part[0], part[-1]) in (("{", "}"), ('"', '"')):
|
|
190
|
+
out.append(part[1:-1])
|
|
191
|
+
elif part.isdigit():
|
|
192
|
+
out.append(part)
|
|
193
|
+
elif not _STRING_NAME_RE.fullmatch(part):
|
|
194
|
+
raise _FieldError(f"cannot read the value {raw!r}")
|
|
195
|
+
elif part.lower() in string_errors:
|
|
196
|
+
raise _FieldError(f"@string `{part}` could not be read: {string_errors[part.lower()]}")
|
|
197
|
+
elif part.lower() in strings:
|
|
198
|
+
out.append(strings[part.lower()])
|
|
199
|
+
else:
|
|
200
|
+
raise _FieldError(f"undefined @string `{part}`")
|
|
201
|
+
return "".join(out)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _read_strings(library: Library) -> tuple[dict[str, str], dict[str, str]]:
|
|
205
|
+
"""`@string` name (lower-cased; BibTeX names are case-insensitive) → value, and → failure reason.
|
|
206
|
+
|
|
207
|
+
Strings are resolved in file order against the month macros and the strings before them; a
|
|
208
|
+
redefinition wins over the earlier one.
|
|
209
|
+
"""
|
|
210
|
+
strings = dict(_MONTH_MACROS)
|
|
211
|
+
errors: dict[str, str] = {}
|
|
212
|
+
for block in library.blocks:
|
|
213
|
+
if isinstance(block, DuplicateBlockKeyBlock):
|
|
214
|
+
block = block.ignore_error_block
|
|
215
|
+
if not isinstance(block, String):
|
|
216
|
+
continue
|
|
217
|
+
name = block.key.lower()
|
|
218
|
+
try:
|
|
219
|
+
strings[name] = _resolve_value(block.value, strings, errors)
|
|
220
|
+
errors.pop(name, None)
|
|
221
|
+
except _FieldError as e:
|
|
222
|
+
strings.pop(name, None)
|
|
223
|
+
errors[name] = str(e)
|
|
224
|
+
return strings, errors
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _read_fields(
|
|
228
|
+
entry: Entry, strings: dict[str, str], string_errors: dict[str, str]
|
|
229
|
+
) -> dict[str, str]:
|
|
230
|
+
"""An entry's fields as `{lower-cased key: decoded text}`; raises `_FieldError` naming the field."""
|
|
231
|
+
fields: dict[str, str] = {}
|
|
232
|
+
for field in entry.fields:
|
|
233
|
+
key = field.key.lower()
|
|
234
|
+
if key in fields: # bibtexparser flags exact repeats; `Title` and `title` are one field too
|
|
235
|
+
raise _FieldError(f"duplicate field `{key}`")
|
|
236
|
+
try:
|
|
237
|
+
fields[key] = decode_latex(_resolve_value(field.value, strings, string_errors))
|
|
238
|
+
except (_FieldError, LatexDecodeError) as e:
|
|
239
|
+
raise _FieldError(f"field `{field.key}`: {e}") from e
|
|
240
|
+
return fields
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class BibRecord(NamedTuple):
|
|
244
|
+
"""One `.bib` entry as read: values resolved and LaTeX-decoded, field keys lower-cased."""
|
|
245
|
+
|
|
246
|
+
key: str
|
|
247
|
+
entry_type: str # lower-cased, e.g. "article"
|
|
248
|
+
line: int # 1-based line of the `@type{key` header
|
|
249
|
+
fields: dict[str, str]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class BibRead(NamedTuple):
|
|
253
|
+
records: list[BibRecord]
|
|
254
|
+
failed: list[UnparsedEntry] # entries bibtexparser split whose fields cannot be read
|
|
255
|
+
parse_errors: dict[int, str] # 1-based line → why bibtexparser could not split the block there
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _failed(entry: Entry, reason: str) -> UnparsedEntry:
|
|
259
|
+
return UnparsedEntry(
|
|
260
|
+
key=entry.key, entry_type=entry.entry_type.lower(), line=(entry.start_line or 0) + 1,
|
|
261
|
+
reason=f"{reason}; {_NOT_CHECKED}",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def read_bib_records(raw: str) -> BibRead:
|
|
266
|
+
"""Read every entry of `.bib` text. An entry that cannot be read is in `failed`, never guessed."""
|
|
267
|
+
library = bibtexparser.parse_string(raw, parse_stack=[])
|
|
268
|
+
strings, string_errors = _read_strings(library)
|
|
269
|
+
records: list[BibRecord] = []
|
|
270
|
+
failed: list[UnparsedEntry] = []
|
|
271
|
+
parse_errors: dict[int, str] = {}
|
|
272
|
+
for block in library.blocks:
|
|
273
|
+
if isinstance(block, DuplicateFieldKeyBlock):
|
|
274
|
+
dupes = ", ".join(f"`{k}`" for k in sorted(block.duplicate_keys))
|
|
275
|
+
failed.append(_failed(block.ignore_error_block, f"duplicate field {dupes}"))
|
|
276
|
+
continue
|
|
277
|
+
# A repeated entry key: bibtexparser sets the later entry aside; it is read like any other.
|
|
278
|
+
entry = block.ignore_error_block if isinstance(block, DuplicateBlockKeyBlock) else block
|
|
279
|
+
if isinstance(entry, Entry):
|
|
280
|
+
# `@article{key}`, or LaTeX such as `\@ifundefined{X}` in a mis-passed `.tex`.
|
|
281
|
+
if not entry.fields:
|
|
282
|
+
failed.append(_failed(entry, "the entry has no fields"))
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
fields = _read_fields(entry, strings, string_errors)
|
|
286
|
+
except _FieldError as e:
|
|
287
|
+
failed.append(_failed(entry, str(e)))
|
|
288
|
+
continue
|
|
289
|
+
records.append(
|
|
290
|
+
BibRecord(entry.key, entry.entry_type.lower(), (entry.start_line or 0) + 1, fields)
|
|
291
|
+
)
|
|
292
|
+
elif isinstance(block, ParsingFailedBlock) and not isinstance(block, DuplicateBlockKeyBlock):
|
|
293
|
+
err = block.error
|
|
294
|
+
reason = err.abort_reason if isinstance(err, BlockAbortedException) else str(err)
|
|
295
|
+
parse_errors[(block.start_line or 0) + 1] = reason
|
|
296
|
+
return BibRead(records, failed, parse_errors)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _twin_from_raw(raw: str, key: str) -> BibEntry | None:
|
|
300
|
+
"""The commented-only entry `key`, read from the text with its lines' `%` markers removed."""
|
|
301
|
+
m = re.search(r"(?m)^[^\n@]*@\w+\s*\{\s*" + re.escape(key) + r"(?=[,\s}])", raw)
|
|
302
|
+
if not m:
|
|
303
|
+
return None
|
|
304
|
+
# The block ends, at the latest, where the next live entry starts.
|
|
305
|
+
end = next(
|
|
306
|
+
(h.start() for h in _ENTRY_LINE_RE.finditer(raw, m.end())
|
|
307
|
+
if not re.search(r"(?<!\\)%", h.group("pre"))),
|
|
308
|
+
len(raw),
|
|
309
|
+
)
|
|
310
|
+
uncommented = re.sub(r"(?m)^([ \t]*)%+", r"\1", raw[m.start():end])
|
|
311
|
+
read = read_bib_records(uncommented)
|
|
312
|
+
rec = next((r for r in read.records if r.key == key), None)
|
|
313
|
+
if rec is None:
|
|
314
|
+
return None
|
|
315
|
+
return entry_from_fields(key, rec.entry_type, rec.fields, commented=True)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class BibParse(NamedTuple):
|
|
319
|
+
entries: list[BibEntry] # audited
|
|
320
|
+
twins: list[BibEntry] # commented-out entries (informational)
|
|
321
|
+
unparsed: list[UnparsedEntry] # live entries that could not be read (never audited)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _brace_balance(text: str) -> int:
|
|
325
|
+
"""Net `{` minus `}` in `text`, ignoring escaped braces (`\\{`, `\\}`)."""
|
|
326
|
+
unescaped = re.sub(r"\\[{}]", "", text)
|
|
327
|
+
return unescaped.count("{") - unescaped.count("}")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _unparsed_entries(
|
|
331
|
+
raw: str, read_keys: set[str], commented_only: set[str], parse_errors: dict[int, str]
|
|
332
|
+
) -> list[UnparsedEntry]:
|
|
333
|
+
"""Live entry headers in `raw` that produced no entry, each with the likeliest reason.
|
|
334
|
+
|
|
335
|
+
The block of an entry runs from its header to the next header. A block whose net brace count does
|
|
336
|
+
not balance is reported as such; otherwise the reason is bibtexparser's own for the block at that
|
|
337
|
+
line, or the generic one, never a guess.
|
|
338
|
+
"""
|
|
339
|
+
headers = [
|
|
340
|
+
m for m in _ENTRY_LINE_RE.finditer(raw)
|
|
341
|
+
if m.group("type").lower() not in _NON_ENTRY_TYPES
|
|
342
|
+
and not re.search(r"(?<!\\)%", m.group("pre"))
|
|
343
|
+
]
|
|
344
|
+
out: list[UnparsedEntry] = []
|
|
345
|
+
reported: set[str] = set()
|
|
346
|
+
for i, m in enumerate(headers):
|
|
347
|
+
key = m.group("key")
|
|
348
|
+
if key in read_keys or key in commented_only or key in reported:
|
|
349
|
+
continue
|
|
350
|
+
end = headers[i + 1].start() if i + 1 < len(headers) else len(raw)
|
|
351
|
+
balance = _brace_balance(raw[m.start("type") - 1:end])
|
|
352
|
+
line = raw.count("\n", 0, m.start()) + 1
|
|
353
|
+
if balance > 0:
|
|
354
|
+
reason = f"unbalanced braces ({balance} more '{{' than '}}')"
|
|
355
|
+
elif balance < 0:
|
|
356
|
+
reason = f"unbalanced braces ({-balance} more '}}' than '{{')"
|
|
357
|
+
elif line in parse_errors:
|
|
358
|
+
reason = f"bibtexparser could not parse this entry: {parse_errors[line]}"
|
|
359
|
+
else:
|
|
360
|
+
reason = "bibtexparser could not parse this entry"
|
|
361
|
+
out.append(UnparsedEntry(
|
|
362
|
+
key=key, entry_type=m.group("type").lower(), line=line,
|
|
363
|
+
reason=f"{reason}; {_NOT_CHECKED}",
|
|
364
|
+
))
|
|
365
|
+
reported.add(key)
|
|
366
|
+
return out
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def parse_bib(bib_path: str | Path) -> BibParse:
|
|
370
|
+
"""Parse a .bib file. Returns (audited_entries, commented_twins, unparsed_entries)."""
|
|
371
|
+
return parse_bib_text(Path(bib_path).read_text(encoding="utf-8", errors="replace"))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def parse_bib_text(raw: str) -> BibParse:
|
|
375
|
+
"""Parse .bib text — a file's, or the records a PDF embeds (`pdf.embedded`)."""
|
|
376
|
+
commented_only = _classify_keys(raw)
|
|
377
|
+
read = read_bib_records(raw)
|
|
378
|
+
|
|
379
|
+
entries = [
|
|
380
|
+
entry_from_fields(r.key, r.entry_type, r.fields)
|
|
381
|
+
for r in read.records if r.key not in commented_only
|
|
382
|
+
]
|
|
383
|
+
twins: list[BibEntry] = []
|
|
384
|
+
for key in dict.fromkeys(m.group("key") for m in _ENTRY_LINE_RE.finditer(raw)):
|
|
385
|
+
if key in commented_only and (twin := _twin_from_raw(raw, key)) is not None:
|
|
386
|
+
twins.append(twin)
|
|
387
|
+
failed = [u for u in read.failed if u.key not in commented_only]
|
|
388
|
+
|
|
389
|
+
read_keys = {r.key for r in read.records} | {u.key for u in read.failed}
|
|
390
|
+
unparsed = failed + _unparsed_entries(raw, read_keys, commented_only, read.parse_errors)
|
|
391
|
+
unparsed.sort(key=lambda u: u.line)
|
|
392
|
+
return BibParse(entries, twins, unparsed)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Sentence-span extraction for citing contexts — shared by the `.tex` and the PDF/TEI front ends.
|
|
2
|
+
|
|
3
|
+
`parsing.tex.parse_citation_contexts` (LaTeX source) and `pdf.tei.parse_tei` (GROBID output) must
|
|
4
|
+
agree on what "the citing sentence" *is*, or a `CitationContext` would mean something different
|
|
5
|
+
depending on which format the manuscript arrived in — and the alignment check compares them against
|
|
6
|
+
the same abstracts. These three helpers are that single definition. Each front end adds only its own
|
|
7
|
+
noise stripping on top: `_clean_context` for LaTeX macros, nothing for TEI (whose text is already
|
|
8
|
+
plain prose).
|
|
9
|
+
|
|
10
|
+
This module is pure: no I/O, no network, no LaTeX and no XML knowledge.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
_SENT_END_RE = re.compile(r"[.!?]")
|
|
18
|
+
|
|
19
|
+
# A cleaned context shorter than this (bare macro shells, a lone trailing cite) is extended with its
|
|
20
|
+
# preceding sentence so there is an actual claim to judge.
|
|
21
|
+
MIN_CONTEXT_CHARS = 15
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def collapse(span: str) -> str:
|
|
25
|
+
"""Collapse whitespace runs and strip — the final step of every context extraction."""
|
|
26
|
+
return re.sub(r"\s+", " ", span).strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sentence_span(text: str, start: int, end: int) -> tuple[int, int]:
|
|
30
|
+
"""Bounds of the sentence (or paragraph-bounded fragment) containing text[start:end]. A sentence
|
|
31
|
+
boundary is a `.`/`!`/`?` followed by whitespace; a blank line also bounds it.
|
|
32
|
+
|
|
33
|
+
A zero-width span (`start == end`) is valid and returns the sentence surrounding that offset —
|
|
34
|
+
the TEI front end deletes each citation marker's surface text (`[13]`, `(Chan, 2019)`), which is
|
|
35
|
+
not part of the citing claim, and asks for the sentence around the position where it stood.
|
|
36
|
+
"""
|
|
37
|
+
left = 0
|
|
38
|
+
for m in _SENT_END_RE.finditer(text, 0, start):
|
|
39
|
+
e = m.end()
|
|
40
|
+
if e < len(text) and text[e].isspace():
|
|
41
|
+
left = e
|
|
42
|
+
para = text.rfind("\n\n", left, start)
|
|
43
|
+
if para != -1:
|
|
44
|
+
left = para + 2
|
|
45
|
+
|
|
46
|
+
right = len(text)
|
|
47
|
+
for m in _SENT_END_RE.finditer(text, end):
|
|
48
|
+
e = m.end()
|
|
49
|
+
if e >= len(text) or text[e].isspace():
|
|
50
|
+
right = e
|
|
51
|
+
break
|
|
52
|
+
para = text.find("\n\n", end, right)
|
|
53
|
+
if para != -1:
|
|
54
|
+
right = para
|
|
55
|
+
return left, right
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""HTML character references in bibliographic text.
|
|
2
|
+
|
|
3
|
+
Metadata scraped from the web reaches `.bib` files with its HTML escaping intact: `d'Amore`,
|
|
4
|
+
`Allocation & Regularization`. Left encoded, a cited author `d'Amore` fails the author check
|
|
5
|
+
against the source's `d'Amore`, and a title gains a phantom word "amp". Only complete, `;`-terminated
|
|
6
|
+
references are decoded. `html.unescape` alone would also expand legacy forms without the `;`
|
|
7
|
+
(`©` in "R&D ©" → "©"), which ordinary text can contain.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import html
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
_REFERENCE_RE = re.compile(r"&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def decode_html_entities(text: str) -> str:
|
|
19
|
+
"""`text` with every `;`-terminated HTML character reference decoded; anything else unchanged."""
|
|
20
|
+
if "&" not in text:
|
|
21
|
+
return text
|
|
22
|
+
return _REFERENCE_RE.sub(lambda m: html.unescape(m.group(0)), text)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Identifier extraction & normalization.
|
|
2
|
+
|
|
3
|
+
DOI/ISBN regexes lifted from `paper-search-mcp/paper_search_mcp/utils.py`; we add
|
|
4
|
+
URL-prefix stripping (fixes wolpert2007's `https://doi.org/...` DOI), ISBN-10→13 conversion,
|
|
5
|
+
and arXiv-id extraction from `eprint`/`archivePrefix`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# --- DOI ---
|
|
13
|
+
_DOI_CORE_RE = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.IGNORECASE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# @cpt-dod:cpt-referenceaudit-dod-parsing-identifiers:p1
|
|
17
|
+
def normalize_doi(text: str | None) -> str | None:
|
|
18
|
+
"""Return a bare, lowercased DOI ('10.xxxx/...') from any URL/prefixed form, or None.
|
|
19
|
+
|
|
20
|
+
DOIs are case-insensitive (ISO 26324); lowercasing makes opaque-token equality trivial.
|
|
21
|
+
"""
|
|
22
|
+
if not text:
|
|
23
|
+
return None
|
|
24
|
+
m = _DOI_CORE_RE.search(text)
|
|
25
|
+
if not m:
|
|
26
|
+
return None
|
|
27
|
+
doi = m.group(0).rstrip(".,;)")
|
|
28
|
+
return doi.lower()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- ISBN ---
|
|
32
|
+
def _compact_isbn(text: str) -> str:
|
|
33
|
+
return re.sub(r"[\s\-]", "", str(text or "")).upper()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _isbn10_to_13(isbn10: str) -> str | None:
|
|
37
|
+
core = "978" + isbn10[:9]
|
|
38
|
+
total = sum((1 if i % 2 == 0 else 3) * int(d) for i, d in enumerate(core))
|
|
39
|
+
check = (10 - (total % 10)) % 10
|
|
40
|
+
return core + str(check)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_isbn13(text: str | None) -> str | None:
|
|
44
|
+
"""Return a 13-digit ISBN (converting ISBN-10), or None if not a valid ISBN shape."""
|
|
45
|
+
if not text:
|
|
46
|
+
return None
|
|
47
|
+
compact = _compact_isbn(text)
|
|
48
|
+
if re.fullmatch(r"97[89]\d{10}", compact):
|
|
49
|
+
return compact
|
|
50
|
+
if re.fullmatch(r"\d{9}[\dX]", compact):
|
|
51
|
+
return _isbn10_to_13(compact)
|
|
52
|
+
# try to find an embedded ISBN
|
|
53
|
+
for pat in (r"97[89](?:[\s\-]?\d){10}", r"(?:\d[\s\-]?){9}[\dXx]"):
|
|
54
|
+
m = re.search(pat, text)
|
|
55
|
+
if m:
|
|
56
|
+
return normalize_isbn13(m.group(0))
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- arXiv ---
|
|
61
|
+
# Bare ids are only trusted from an `eprint` field or after an explicit arXiv marker —
|
|
62
|
+
# never scraped from an arbitrary DOI (e.g. 10.3389/frobt.2016.00040 is NOT arXiv 2016.00040).
|
|
63
|
+
_ARXIV_NEW_RE = re.compile(r"\b(\d{4}\.\d{4,5})(?:v\d+)?\b")
|
|
64
|
+
_ARXIV_OLD_RE = re.compile(r"\b([a-z\-]+(?:\.[A-Z]{2})?/\d{7})(?:v\d+)?\b", re.IGNORECASE)
|
|
65
|
+
_ARXIV_MARKED_RE = re.compile(
|
|
66
|
+
r"(?:arxiv[._:/]|abs/)\s*/?([a-z\-]*(?:\.[A-Z]{2})?/?\d{4}\.\d{4,5}(?:v\d+)?)",
|
|
67
|
+
re.IGNORECASE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _bare_arxiv(text: str) -> str | None:
|
|
72
|
+
m = _ARXIV_NEW_RE.search(text) or _ARXIV_OLD_RE.search(text)
|
|
73
|
+
return _strip_arxiv_version(m.group(1)) if m else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_arxiv_id(
|
|
77
|
+
eprint: str | None, archive_prefix: str | None = None, *, fallback_text: str | None = None
|
|
78
|
+
) -> str | None:
|
|
79
|
+
"""Pull an arXiv id from a trusted `eprint` field (guarded by archivePrefix=arXiv when
|
|
80
|
+
present), or from text that explicitly mentions arXiv (a 10.48550/arXiv.<id> DOI or an
|
|
81
|
+
arxiv.org/abs/<id> URL). Arbitrary DOIs are never scanned for bare ids."""
|
|
82
|
+
if eprint:
|
|
83
|
+
prefix_ok = (not archive_prefix) or archive_prefix.strip().lower() == "arxiv"
|
|
84
|
+
if prefix_ok:
|
|
85
|
+
got = _bare_arxiv(eprint)
|
|
86
|
+
if got:
|
|
87
|
+
return got
|
|
88
|
+
if fallback_text and re.search(r"arxiv|48550", fallback_text, re.IGNORECASE):
|
|
89
|
+
m = _ARXIV_MARKED_RE.search(fallback_text)
|
|
90
|
+
if m:
|
|
91
|
+
return _strip_arxiv_version(m.group(1).lstrip("/"))
|
|
92
|
+
got = _bare_arxiv(fallback_text) # safe: an arXiv marker is already present
|
|
93
|
+
if got:
|
|
94
|
+
return got
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _strip_arxiv_version(arxiv_id: str) -> str:
|
|
99
|
+
return re.sub(r"v\d+$", "", arxiv_id.strip())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def arxiv_to_doi(arxiv_id: str) -> str:
|
|
103
|
+
"""The DataCite DOI arXiv mints for every submission."""
|
|
104
|
+
return f"10.48550/arxiv.{arxiv_id.lower()}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def arxiv_submission_year(arxiv_id: str | None) -> int | None:
|
|
108
|
+
"""Year an arXiv id was first submitted — encoded in the id itself, so it pins the *original*
|
|
109
|
+
version (v1) independent of any later updates.
|
|
110
|
+
|
|
111
|
+
New scheme `YYMM.NNNNN` (since 2007-04) → 2000+YY. Old scheme `archive/YYMMNNN` (1991-2007) →
|
|
112
|
+
19YY for YY>=91 else 20YY. Returns None when the id matches neither shape.
|
|
113
|
+
"""
|
|
114
|
+
if not arxiv_id:
|
|
115
|
+
return None
|
|
116
|
+
s = _strip_arxiv_version(arxiv_id.strip())
|
|
117
|
+
new = re.match(r"^(\d{2})\d{2}\.\d{4,5}$", s)
|
|
118
|
+
if new:
|
|
119
|
+
return 2000 + int(new.group(1))
|
|
120
|
+
old = re.match(r"^[a-z-]+(?:\.[a-z]{2})?/(\d{2})\d{5}$", s, re.IGNORECASE)
|
|
121
|
+
if old:
|
|
122
|
+
yy = int(old.group(1))
|
|
123
|
+
return 1900 + yy if yy >= 91 else 2000 + yy
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def normalize_url(text: str | None) -> str | None:
|
|
128
|
+
if not text:
|
|
129
|
+
return None
|
|
130
|
+
url = text.strip()
|
|
131
|
+
return url or None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- OpenAlex Work id ---
|
|
135
|
+
# A canonical OpenAlex Work id is the token `W<digits>` carried by an openalex.org URL
|
|
136
|
+
# (https://openalex.org/W3034344071) or an API path (api.openalex.org/works/W...). Only trusted
|
|
137
|
+
# when an openalex.org host is present — a bare `W…` token is too ambiguous to scrape blindly.
|
|
138
|
+
_OPENALEX_ID_RE = re.compile(r"\bW\d+\b", re.IGNORECASE)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def normalize_openalex_id(text: str | None) -> str | None:
|
|
142
|
+
"""Return a bare, upper-cased OpenAlex Work id ('W3034344071') from an openalex.org URL, or None."""
|
|
143
|
+
if not text or "openalex.org" not in text.lower():
|
|
144
|
+
return None
|
|
145
|
+
m = _OPENALEX_ID_RE.search(text)
|
|
146
|
+
return m.group(0).upper() if m else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- Google Books volume id ---
|
|
150
|
+
# A Google Books volume id is the opaque `id=` token on a books.google.<tld> (or
|
|
151
|
+
# play.google.com/books) URL — e.g. https://books.google.com.sg/books?id=yIV_NMDDIvYC. It is the
|
|
152
|
+
# authoritative key for that exact volume, so the adapter can resolve it with no fuzzy matching.
|
|
153
|
+
# Only trusted when a Google Books host is present — a bare token is too ambiguous to scrape.
|
|
154
|
+
_GBOOKS_ID_RE = re.compile(r"[?&]id=([A-Za-z0-9_-]+)")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def normalize_google_books_id(text: str | None) -> str | None:
|
|
158
|
+
"""Return a Google Books volume id from a books.google / play.google books URL, or None."""
|
|
159
|
+
if not text:
|
|
160
|
+
return None
|
|
161
|
+
low = text.lower()
|
|
162
|
+
if "books.google." not in low and "play.google.com/books" not in low:
|
|
163
|
+
return None
|
|
164
|
+
m = _GBOOKS_ID_RE.search(text)
|
|
165
|
+
return m.group(1) if m else None
|