sciwrite-lint 0.2.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.
- sciwrite_lint/__init__.py +3 -0
- sciwrite_lint/__main__.py +527 -0
- sciwrite_lint/_network.py +195 -0
- sciwrite_lint/api.py +1484 -0
- sciwrite_lint/checks/__init__.py +1 -0
- sciwrite_lint/checks/_section_utils.py +111 -0
- sciwrite_lint/checks/cite_purpose.py +122 -0
- sciwrite_lint/checks/claim_support.py +96 -0
- sciwrite_lint/checks/cross_section_consistency.py +185 -0
- sciwrite_lint/checks/dangling_cite.py +93 -0
- sciwrite_lint/checks/dangling_ref.py +116 -0
- sciwrite_lint/checks/full_paper_consistency.py +604 -0
- sciwrite_lint/checks/ref_internal_checks.py +919 -0
- sciwrite_lint/checks/reference_accuracy.py +277 -0
- sciwrite_lint/checks/reference_exists.py +119 -0
- sciwrite_lint/checks/reference_unreliable.py +244 -0
- sciwrite_lint/checks/registry.py +136 -0
- sciwrite_lint/checks/retracted_cite.py +96 -0
- sciwrite_lint/checks/structure_promises.py +115 -0
- sciwrite_lint/checks/unreferenced_figure.py +70 -0
- sciwrite_lint/claims.py +330 -0
- sciwrite_lint/claude_backend.py +94 -0
- sciwrite_lint/claude_cli.py +405 -0
- sciwrite_lint/cli/__init__.py +1 -0
- sciwrite_lint/cli/check.py +480 -0
- sciwrite_lint/cli/config.py +229 -0
- sciwrite_lint/cli/fetch.py +250 -0
- sciwrite_lint/cli/misc.py +1202 -0
- sciwrite_lint/cli/rank.py +470 -0
- sciwrite_lint/cli/verify.py +437 -0
- sciwrite_lint/config.py +646 -0
- sciwrite_lint/cross_paper.py +174 -0
- sciwrite_lint/eval_claims.py +1196 -0
- sciwrite_lint/fulltext.py +851 -0
- sciwrite_lint/llm_utils.py +386 -0
- sciwrite_lint/local_pdfs.py +122 -0
- sciwrite_lint/manuscript_store.py +674 -0
- sciwrite_lint/models.py +139 -0
- sciwrite_lint/pdf/__init__.py +1 -0
- sciwrite_lint/pdf/grobid.py +785 -0
- sciwrite_lint/pdf/pdf_download.py +258 -0
- sciwrite_lint/pipeline.py +2694 -0
- sciwrite_lint/prompt_safety.py +30 -0
- sciwrite_lint/rate_limiter.py +227 -0
- sciwrite_lint/references/__init__.py +1 -0
- sciwrite_lint/references/citations.py +715 -0
- sciwrite_lint/references/crossref.py +282 -0
- sciwrite_lint/references/embedding_store.py +380 -0
- sciwrite_lint/references/matching.py +273 -0
- sciwrite_lint/references/metadata.py +273 -0
- sciwrite_lint/references/reference_store.py +823 -0
- sciwrite_lint/references/retraction_watch.py +178 -0
- sciwrite_lint/references/workspace_db.py +1390 -0
- sciwrite_lint/report.py +163 -0
- sciwrite_lint/schemas.py +260 -0
- sciwrite_lint/scoring/__init__.py +1 -0
- sciwrite_lint/scoring/chain.py +716 -0
- sciwrite_lint/scoring/contribution.py +322 -0
- sciwrite_lint/scoring/scilint_score.py +611 -0
- sciwrite_lint/tex_parser.py +248 -0
- sciwrite_lint/usage.py +594 -0
- sciwrite_lint/vision/__init__.py +1 -0
- sciwrite_lint/vision/cache.py +140 -0
- sciwrite_lint/vision/describe.py +311 -0
- sciwrite_lint/vision/image_extraction.py +491 -0
- sciwrite_lint/vision/pipeline.py +207 -0
- sciwrite_lint/vllm/__init__.py +1 -0
- sciwrite_lint/vllm/metrics.py +157 -0
- sciwrite_lint/vllm/vllm_server.py +445 -0
- sciwrite_lint/web.py +369 -0
- sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
- sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
- sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
- sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
- sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
- sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
sciwrite_lint/api.py
ADDED
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
"""API clients for citation verification (CrossRef + OpenAlex + Semantic Scholar + Open Library + Library of Congress).
|
|
2
|
+
|
|
3
|
+
All APIs are free and require no API keys. Rate limiting is client-side
|
|
4
|
+
via MonotonicRateLimiter. Batch methods use httpx.AsyncClient for concurrent
|
|
5
|
+
lookups.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from loguru import logger
|
|
17
|
+
|
|
18
|
+
from sciwrite_lint._network import (
|
|
19
|
+
clean_and_validate_doi,
|
|
20
|
+
is_valid_arxiv_id,
|
|
21
|
+
is_valid_isbn,
|
|
22
|
+
is_valid_lccn,
|
|
23
|
+
is_valid_pmcid,
|
|
24
|
+
is_valid_pmid,
|
|
25
|
+
)
|
|
26
|
+
from sciwrite_lint.config import LintConfig
|
|
27
|
+
from sciwrite_lint.models import Citation
|
|
28
|
+
from sciwrite_lint.rate_limiter import (
|
|
29
|
+
MonotonicRateLimiter,
|
|
30
|
+
rate_limited_get,
|
|
31
|
+
retry_on_transient,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_ARXIV_DOI_PREFIX = "10.48550/arXiv." # DataCite DOI prefix for arXiv papers
|
|
36
|
+
|
|
37
|
+
# Module-level rate limiters (persist across calls)
|
|
38
|
+
_openalex_limiter = MonotonicRateLimiter(10, 1.0) # 10 req/s
|
|
39
|
+
_s2_limiter = MonotonicRateLimiter(5, 1.0) # ~5 req/s
|
|
40
|
+
_openlibrary_limiter = MonotonicRateLimiter(5, 1.0) # ~5 req/s (be polite)
|
|
41
|
+
_loc_limiter = MonotonicRateLimiter(5, 1.0) # ~5 req/s (be polite)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CitationAPI:
|
|
45
|
+
"""Verify citations against CrossRef, OpenAlex, Semantic Scholar, Open Library, and Library of Congress."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
client: httpx.AsyncClient | None = None,
|
|
50
|
+
config: LintConfig | None = None,
|
|
51
|
+
):
|
|
52
|
+
self._client = client or httpx.AsyncClient(timeout=15.0, follow_redirects=True)
|
|
53
|
+
self._owns_client = client is None
|
|
54
|
+
self._config = config or LintConfig()
|
|
55
|
+
|
|
56
|
+
async def close(self) -> None:
|
|
57
|
+
if self._owns_client:
|
|
58
|
+
await self._client.aclose()
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self):
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
async def __aexit__(self, *args):
|
|
64
|
+
await self.close()
|
|
65
|
+
|
|
66
|
+
async def lookup(self, citation: Citation) -> dict[str, Any] | None:
|
|
67
|
+
"""Try CrossRef → OpenAlex → Semantic Scholar → Open Library → Library of Congress.
|
|
68
|
+
|
|
69
|
+
After each API returns a result, validates that the result plausibly
|
|
70
|
+
matches the citation (title similarity ≥ 0.50). If an ID-based lookup
|
|
71
|
+
returns a completely different paper (wrong DOI, hallucinated arXiv ID),
|
|
72
|
+
the result is discarded and the next API is tried.
|
|
73
|
+
"""
|
|
74
|
+
id_mismatch: dict[str, Any] | None = None
|
|
75
|
+
for api_fn in (
|
|
76
|
+
self._crossref_lookup,
|
|
77
|
+
self._openalex_lookup,
|
|
78
|
+
self._s2_lookup,
|
|
79
|
+
self._openlibrary_lookup,
|
|
80
|
+
self._loc_lookup,
|
|
81
|
+
):
|
|
82
|
+
result = await api_fn(citation)
|
|
83
|
+
if result and not result.get("error"):
|
|
84
|
+
if _id_result_matches(citation, result):
|
|
85
|
+
if id_mismatch is not None:
|
|
86
|
+
# We found the correct paper via title search after
|
|
87
|
+
# an ID-based lookup returned the wrong paper.
|
|
88
|
+
# Tag the result so downstream checks can report it.
|
|
89
|
+
result["id_mismatch"] = id_mismatch
|
|
90
|
+
return result
|
|
91
|
+
# Record the mismatch for downstream reporting
|
|
92
|
+
if not id_mismatch:
|
|
93
|
+
id_mismatch = {
|
|
94
|
+
"source": result.get("source", ""),
|
|
95
|
+
"wrong_title": result.get("title", ""),
|
|
96
|
+
"wrong_doi": result.get("doi", ""),
|
|
97
|
+
"bib_doi": citation.doi,
|
|
98
|
+
"bib_arxiv_id": citation.arxiv_id,
|
|
99
|
+
"bib_pmid": citation.pmid,
|
|
100
|
+
"bib_isbn": citation.isbn,
|
|
101
|
+
"bib_lccn": citation.lccn,
|
|
102
|
+
}
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# CrossRef (async httpx)
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async def _crossref_lookup(self, citation: Citation) -> dict[str, Any] | None:
|
|
110
|
+
try:
|
|
111
|
+
from sciwrite_lint.references.crossref import crossref_lookup
|
|
112
|
+
|
|
113
|
+
return await crossref_lookup(
|
|
114
|
+
citation,
|
|
115
|
+
polite_email=self._config.polite_email,
|
|
116
|
+
client=self._client,
|
|
117
|
+
)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.debug("CrossRef lookup failed for {}: {}", citation.key, e)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# OpenAlex
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async def _openalex_lookup(self, citation: Citation) -> dict[str, Any] | None:
|
|
127
|
+
try:
|
|
128
|
+
# Try DOI lookup
|
|
129
|
+
clean_doi = clean_and_validate_doi(citation.doi) if citation.doi else None
|
|
130
|
+
if clean_doi:
|
|
131
|
+
resp = await rate_limited_get(
|
|
132
|
+
_openalex_limiter,
|
|
133
|
+
f"https://api.openalex.org/works/doi:{clean_doi}",
|
|
134
|
+
params={"select": _OA_FIELDS},
|
|
135
|
+
label="OpenAlex DOI",
|
|
136
|
+
client=self._client,
|
|
137
|
+
service="openalex",
|
|
138
|
+
)
|
|
139
|
+
if resp.status_code == 200:
|
|
140
|
+
return _parse_openalex(resp.json())
|
|
141
|
+
|
|
142
|
+
# Try arXiv DOI (10.48550/arXiv.XXXX.XXXXX)
|
|
143
|
+
if (
|
|
144
|
+
citation.arxiv_id
|
|
145
|
+
and not clean_doi
|
|
146
|
+
and is_valid_arxiv_id(citation.arxiv_id)
|
|
147
|
+
):
|
|
148
|
+
arxiv_doi = f"{_ARXIV_DOI_PREFIX}{citation.arxiv_id}"
|
|
149
|
+
resp = await rate_limited_get(
|
|
150
|
+
_openalex_limiter,
|
|
151
|
+
f"https://api.openalex.org/works/doi:{arxiv_doi}",
|
|
152
|
+
params={"select": _OA_FIELDS},
|
|
153
|
+
label="OpenAlex arXiv DOI",
|
|
154
|
+
client=self._client,
|
|
155
|
+
service="openalex",
|
|
156
|
+
)
|
|
157
|
+
if resp.status_code == 200:
|
|
158
|
+
return _parse_openalex(resp.json())
|
|
159
|
+
logger.debug(
|
|
160
|
+
"OpenAlex arXiv DOI lookup failed for '{}' (status {})",
|
|
161
|
+
citation.key,
|
|
162
|
+
resp.status_code,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Try PMID lookup via filter
|
|
166
|
+
if citation.pmid and is_valid_pmid(citation.pmid):
|
|
167
|
+
resp = await rate_limited_get(
|
|
168
|
+
_openalex_limiter,
|
|
169
|
+
"https://api.openalex.org/works",
|
|
170
|
+
params={
|
|
171
|
+
"filter": f"ids.pmid:https://pubmed.ncbi.nlm.nih.gov/{citation.pmid}",
|
|
172
|
+
"select": _OA_FIELDS,
|
|
173
|
+
},
|
|
174
|
+
label="OpenAlex PMID",
|
|
175
|
+
client=self._client,
|
|
176
|
+
service="openalex",
|
|
177
|
+
)
|
|
178
|
+
if resp.status_code == 200:
|
|
179
|
+
results = resp.json().get("results", [])
|
|
180
|
+
if results:
|
|
181
|
+
return _parse_openalex(results[0])
|
|
182
|
+
|
|
183
|
+
query = _search_query(citation)
|
|
184
|
+
if not query:
|
|
185
|
+
return None
|
|
186
|
+
resp = await rate_limited_get(
|
|
187
|
+
_openalex_limiter,
|
|
188
|
+
"https://api.openalex.org/works",
|
|
189
|
+
params={
|
|
190
|
+
"filter": f"title.search:{query}",
|
|
191
|
+
"per_page": 10,
|
|
192
|
+
"select": _OA_FIELDS,
|
|
193
|
+
},
|
|
194
|
+
label="OpenAlex search",
|
|
195
|
+
client=self._client,
|
|
196
|
+
service="openalex",
|
|
197
|
+
)
|
|
198
|
+
if resp.status_code != 200:
|
|
199
|
+
return None
|
|
200
|
+
results = resp.json().get("results", [])
|
|
201
|
+
parsed = [_parse_openalex(r) for r in results]
|
|
202
|
+
return (
|
|
203
|
+
_best_match(
|
|
204
|
+
citation.title or "",
|
|
205
|
+
parsed,
|
|
206
|
+
query_authors=citation.authors,
|
|
207
|
+
query_year=int(citation.year) if citation.year.isdigit() else None,
|
|
208
|
+
query_venue=citation.venue,
|
|
209
|
+
)
|
|
210
|
+
if parsed
|
|
211
|
+
else None
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
return {"error": str(e), "source": "openalex"}
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Semantic Scholar
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
async def _s2_lookup(self, citation: Citation) -> dict[str, Any] | None:
|
|
222
|
+
try:
|
|
223
|
+
# Try direct identifier lookups (DOI, arXiv, PMID, PMC)
|
|
224
|
+
for prefix, value, label in self._s2_id_variants(citation):
|
|
225
|
+
resp = await rate_limited_get(
|
|
226
|
+
_s2_limiter,
|
|
227
|
+
f"https://api.semanticscholar.org/graph/v1/paper/{prefix}:{value}",
|
|
228
|
+
params={"fields": _S2_FIELDS},
|
|
229
|
+
label=f"S2 {label}",
|
|
230
|
+
client=self._client,
|
|
231
|
+
service="semantic_scholar",
|
|
232
|
+
)
|
|
233
|
+
if resp.status_code == 200:
|
|
234
|
+
return _parse_s2(resp.json())
|
|
235
|
+
logger.debug(
|
|
236
|
+
"S2 {} lookup failed for '{}' (status {})",
|
|
237
|
+
label,
|
|
238
|
+
citation.key,
|
|
239
|
+
resp.status_code,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Identifier lookups failed — try title/author search
|
|
243
|
+
query = _search_query(citation)
|
|
244
|
+
if not query:
|
|
245
|
+
return None
|
|
246
|
+
resp = await rate_limited_get(
|
|
247
|
+
_s2_limiter,
|
|
248
|
+
"https://api.semanticscholar.org/graph/v1/paper/search",
|
|
249
|
+
params={"query": query, "limit": 10, "fields": _S2_FIELDS},
|
|
250
|
+
label="S2 search",
|
|
251
|
+
client=self._client,
|
|
252
|
+
service="semantic_scholar",
|
|
253
|
+
)
|
|
254
|
+
if resp.status_code != 200:
|
|
255
|
+
return None
|
|
256
|
+
papers = resp.json().get("data", [])
|
|
257
|
+
parsed = [_parse_s2(p) for p in papers]
|
|
258
|
+
return (
|
|
259
|
+
_best_match(
|
|
260
|
+
citation.title or "",
|
|
261
|
+
parsed,
|
|
262
|
+
query_authors=citation.authors,
|
|
263
|
+
query_year=int(citation.year) if citation.year.isdigit() else None,
|
|
264
|
+
query_venue=citation.venue,
|
|
265
|
+
)
|
|
266
|
+
if parsed
|
|
267
|
+
else None
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
except Exception as e:
|
|
271
|
+
return {"error": str(e), "source": "semantic_scholar"}
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _s2_id_variants(
|
|
275
|
+
citation: Citation,
|
|
276
|
+
) -> list[tuple[str, str, str]]:
|
|
277
|
+
"""Return (prefix, value, label) tuples for S2 identifier lookup."""
|
|
278
|
+
variants: list[tuple[str, str, str]] = []
|
|
279
|
+
clean_doi = clean_and_validate_doi(citation.doi) if citation.doi else None
|
|
280
|
+
if clean_doi:
|
|
281
|
+
variants.append(("DOI", clean_doi, "DOI"))
|
|
282
|
+
if citation.arxiv_id and is_valid_arxiv_id(citation.arxiv_id):
|
|
283
|
+
variants.append(("ArXiv", citation.arxiv_id, "arXiv"))
|
|
284
|
+
if citation.pmid and is_valid_pmid(citation.pmid):
|
|
285
|
+
variants.append(("PMID", citation.pmid, "PMID"))
|
|
286
|
+
if citation.pmc_id and is_valid_pmcid(citation.pmc_id):
|
|
287
|
+
variants.append(("PMCID", citation.pmc_id, "PMC"))
|
|
288
|
+
if citation.isbn and is_valid_isbn(citation.isbn):
|
|
289
|
+
variants.append(("ISBN", citation.isbn, "ISBN"))
|
|
290
|
+
return variants
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Open Library (books, reports, monographs)
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
async def _openlibrary_lookup(self, citation: Citation) -> dict[str, Any] | None:
|
|
297
|
+
try:
|
|
298
|
+
# Direct ISBN lookup — high-confidence match, no title search needed
|
|
299
|
+
if citation.isbn and is_valid_isbn(citation.isbn):
|
|
300
|
+
isbn_result = await self._openlibrary_isbn_lookup(citation.isbn)
|
|
301
|
+
if isbn_result:
|
|
302
|
+
return isbn_result
|
|
303
|
+
|
|
304
|
+
query = _search_query(citation)
|
|
305
|
+
if not query:
|
|
306
|
+
return None
|
|
307
|
+
params: dict[str, str | int] = {"q": query, "limit": 10}
|
|
308
|
+
if citation.authors:
|
|
309
|
+
params["author"] = citation.authors[0].split()[-1]
|
|
310
|
+
resp = await rate_limited_get(
|
|
311
|
+
_openlibrary_limiter,
|
|
312
|
+
"https://openlibrary.org/search.json",
|
|
313
|
+
params=params,
|
|
314
|
+
label="Open Library search",
|
|
315
|
+
client=self._client,
|
|
316
|
+
service="openlibrary",
|
|
317
|
+
)
|
|
318
|
+
if resp.status_code != 200:
|
|
319
|
+
return None
|
|
320
|
+
docs = resp.json().get("docs", [])
|
|
321
|
+
return _parse_openlibrary(docs[0]) if docs else None
|
|
322
|
+
except Exception as e:
|
|
323
|
+
return {"error": str(e), "source": "openlibrary"}
|
|
324
|
+
|
|
325
|
+
async def _openlibrary_isbn_lookup(self, isbn: str) -> dict[str, Any] | None:
|
|
326
|
+
"""Direct ISBN lookup via Open Library ISBN API."""
|
|
327
|
+
resp = await rate_limited_get(
|
|
328
|
+
_openlibrary_limiter,
|
|
329
|
+
f"https://openlibrary.org/isbn/{isbn}.json",
|
|
330
|
+
label="Open Library ISBN lookup",
|
|
331
|
+
client=self._client,
|
|
332
|
+
service="openlibrary",
|
|
333
|
+
)
|
|
334
|
+
if resp.status_code != 200:
|
|
335
|
+
logger.debug(
|
|
336
|
+
"Open Library ISBN lookup failed for '{}': HTTP {}",
|
|
337
|
+
isbn,
|
|
338
|
+
resp.status_code,
|
|
339
|
+
)
|
|
340
|
+
return None
|
|
341
|
+
data = resp.json()
|
|
342
|
+
title = data.get("title", "")
|
|
343
|
+
if not title:
|
|
344
|
+
logger.debug("Open Library ISBN '{}': response has no title field", isbn)
|
|
345
|
+
return None
|
|
346
|
+
# OL stores subtitles separately — concatenate for matching
|
|
347
|
+
subtitle = data.get("subtitle", "")
|
|
348
|
+
if subtitle:
|
|
349
|
+
title = f"{title}: {subtitle}"
|
|
350
|
+
# Authors require a separate lookup (OL returns author keys, not names)
|
|
351
|
+
# but having title + ISBN is enough for a high-confidence match
|
|
352
|
+
return {
|
|
353
|
+
"source": "openlibrary",
|
|
354
|
+
"title": title,
|
|
355
|
+
"authors": [],
|
|
356
|
+
"year": _extract_ol_year(data),
|
|
357
|
+
"doi": "",
|
|
358
|
+
"venue": "",
|
|
359
|
+
"citation_count": 0,
|
|
360
|
+
"isbn": isbn,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
# Library of Congress (books, reports, government publications)
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
async def _loc_lookup(self, citation: Citation) -> dict[str, Any] | None:
|
|
368
|
+
try:
|
|
369
|
+
# Direct LCCN lookup — high-confidence match
|
|
370
|
+
if citation.lccn and is_valid_lccn(citation.lccn):
|
|
371
|
+
lccn_result = await self._loc_lccn_lookup(citation.lccn)
|
|
372
|
+
if lccn_result:
|
|
373
|
+
return lccn_result
|
|
374
|
+
|
|
375
|
+
query = _search_query(citation)
|
|
376
|
+
if not query:
|
|
377
|
+
return None
|
|
378
|
+
resp = await rate_limited_get(
|
|
379
|
+
_loc_limiter,
|
|
380
|
+
"https://www.loc.gov/books/",
|
|
381
|
+
params={"q": query, "fo": "json"},
|
|
382
|
+
label="Library of Congress search",
|
|
383
|
+
client=self._client,
|
|
384
|
+
service="loc",
|
|
385
|
+
)
|
|
386
|
+
if resp.status_code != 200:
|
|
387
|
+
return None
|
|
388
|
+
results = resp.json().get("results", [])
|
|
389
|
+
parsed = [_parse_loc(r) for r in results]
|
|
390
|
+
return (
|
|
391
|
+
_best_match(
|
|
392
|
+
citation.title or "",
|
|
393
|
+
parsed,
|
|
394
|
+
query_authors=citation.authors,
|
|
395
|
+
query_year=int(citation.year) if citation.year.isdigit() else None,
|
|
396
|
+
query_venue=citation.venue,
|
|
397
|
+
)
|
|
398
|
+
if parsed
|
|
399
|
+
else None
|
|
400
|
+
)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
return {"error": str(e), "source": "loc"}
|
|
403
|
+
|
|
404
|
+
async def _loc_lccn_lookup(self, lccn: str) -> dict[str, Any] | None:
|
|
405
|
+
"""Direct LCCN lookup via Library of Congress item API."""
|
|
406
|
+
resp = await rate_limited_get(
|
|
407
|
+
_loc_limiter,
|
|
408
|
+
f"https://www.loc.gov/item/{lccn}/",
|
|
409
|
+
params={"fo": "json"},
|
|
410
|
+
label="Library of Congress LCCN lookup",
|
|
411
|
+
client=self._client,
|
|
412
|
+
service="loc",
|
|
413
|
+
)
|
|
414
|
+
if resp.status_code != 200:
|
|
415
|
+
logger.debug(
|
|
416
|
+
"LoC LCCN lookup failed for '{}': HTTP {}",
|
|
417
|
+
lccn,
|
|
418
|
+
resp.status_code,
|
|
419
|
+
)
|
|
420
|
+
return None
|
|
421
|
+
data = resp.json()
|
|
422
|
+
item = data.get("item", {})
|
|
423
|
+
title = item.get("title", "")
|
|
424
|
+
if not title:
|
|
425
|
+
logger.debug("LoC LCCN '{}': response has no title field", lccn)
|
|
426
|
+
return None
|
|
427
|
+
contributors = item.get("contributor_names") or []
|
|
428
|
+
date_str = item.get("date") or ""
|
|
429
|
+
year = None
|
|
430
|
+
if date_str:
|
|
431
|
+
m = re.match(r"(\d{4})", date_str)
|
|
432
|
+
if m:
|
|
433
|
+
year = int(m.group(1))
|
|
434
|
+
return {
|
|
435
|
+
"source": "loc",
|
|
436
|
+
"title": title,
|
|
437
|
+
"authors": contributors,
|
|
438
|
+
"year": year,
|
|
439
|
+
"doi": "",
|
|
440
|
+
"venue": "",
|
|
441
|
+
"citation_count": 0,
|
|
442
|
+
"lccn": lccn,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
# Batch async API lookups
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def batch_openalex(
|
|
452
|
+
citations: list[Citation],
|
|
453
|
+
config: LintConfig | None = None,
|
|
454
|
+
) -> dict[str, dict[str, Any]]:
|
|
455
|
+
"""Batch DOI lookup via OpenAlex filter. One request for all DOIs.
|
|
456
|
+
|
|
457
|
+
Handles real DOIs and arXiv DOIs (10.48550/arXiv.XXXX.XXXXX) in a
|
|
458
|
+
single batch request. Returns {citation_key: parsed_result}.
|
|
459
|
+
"""
|
|
460
|
+
config = config or LintConfig()
|
|
461
|
+
|
|
462
|
+
# Collect all DOIs: explicit DOIs + arXiv synthetic DOIs
|
|
463
|
+
all_dois: list[tuple[str, str]] = [] # (key, doi)
|
|
464
|
+
for c in citations:
|
|
465
|
+
clean = clean_and_validate_doi(c.doi) if c.doi else None
|
|
466
|
+
if clean:
|
|
467
|
+
all_dois.append((c.key, clean))
|
|
468
|
+
elif c.arxiv_id and is_valid_arxiv_id(c.arxiv_id):
|
|
469
|
+
all_dois.append((c.key, f"{_ARXIV_DOI_PREFIX}{c.arxiv_id}"))
|
|
470
|
+
|
|
471
|
+
if not all_dois:
|
|
472
|
+
return {}
|
|
473
|
+
|
|
474
|
+
# OpenAlex supports pipe-separated DOIs in filter
|
|
475
|
+
doi_filter = "|".join(f"https://doi.org/{doi}" for _, doi in all_dois)
|
|
476
|
+
doi_by_key = {doi.lower(): key for key, doi in all_dois}
|
|
477
|
+
|
|
478
|
+
params: dict[str, str | int] = {
|
|
479
|
+
"filter": f"doi:{doi_filter}",
|
|
480
|
+
"per_page": 200,
|
|
481
|
+
"select": _OA_FIELDS,
|
|
482
|
+
}
|
|
483
|
+
if config.polite_email:
|
|
484
|
+
params["mailto"] = config.polite_email
|
|
485
|
+
|
|
486
|
+
# Secondary index by arXiv ID: OpenAlex may return a different
|
|
487
|
+
# canonical DOI than the query DOI (e.g. arXiv DOI → publisher DOI).
|
|
488
|
+
keys_in_batch = {key for key, _ in all_dois}
|
|
489
|
+
key_by_arxiv = {
|
|
490
|
+
c.arxiv_id.lower(): c.key
|
|
491
|
+
for c in citations
|
|
492
|
+
if c.arxiv_id and c.key in keys_in_batch
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
results: dict[str, dict[str, Any]] = {}
|
|
496
|
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
497
|
+
from sciwrite_lint.usage import tracked
|
|
498
|
+
|
|
499
|
+
async with tracked("openalex", dois=len(all_dois)):
|
|
500
|
+
resp = await retry_on_transient(
|
|
501
|
+
lambda: client.get("https://api.openalex.org/works", params=params),
|
|
502
|
+
label="OpenAlex batch",
|
|
503
|
+
)
|
|
504
|
+
if resp.status_code != 200:
|
|
505
|
+
return results
|
|
506
|
+
works = resp.json().get("results", [])
|
|
507
|
+
for work in works:
|
|
508
|
+
parsed = _parse_openalex(work)
|
|
509
|
+
doi = (parsed.get("doi") or "").lower()
|
|
510
|
+
key = doi_by_key.get(doi)
|
|
511
|
+
# Response DOI may differ from query DOI (arXiv → publisher).
|
|
512
|
+
# Try arXiv ID from response as secondary match.
|
|
513
|
+
if not key:
|
|
514
|
+
arxiv_id = (parsed.get("arxiv_id") or "").lower()
|
|
515
|
+
key = key_by_arxiv.get(arxiv_id)
|
|
516
|
+
if key:
|
|
517
|
+
results[key] = parsed
|
|
518
|
+
|
|
519
|
+
return results
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
async def batch_s2(
|
|
523
|
+
citations: list[Citation],
|
|
524
|
+
config: LintConfig | None = None,
|
|
525
|
+
) -> dict[str, dict[str, Any]]:
|
|
526
|
+
"""Batch lookup via Semantic Scholar POST endpoint.
|
|
527
|
+
|
|
528
|
+
Accepts up to 500 IDs per request. Returns {citation_key: parsed_result}.
|
|
529
|
+
"""
|
|
530
|
+
# Build ID list: DOI, arXiv, PMID — S2 batch supports all three.
|
|
531
|
+
id_entries: list[tuple[str, str]] = [] # (key, s2_id_string)
|
|
532
|
+
for c in citations:
|
|
533
|
+
if c.doi:
|
|
534
|
+
id_entries.append((c.key, f"DOI:{c.doi}"))
|
|
535
|
+
elif c.arxiv_id and is_valid_arxiv_id(c.arxiv_id):
|
|
536
|
+
id_entries.append((c.key, f"ARXIV:{c.arxiv_id}"))
|
|
537
|
+
elif c.pmid and is_valid_pmid(c.pmid):
|
|
538
|
+
id_entries.append((c.key, f"PMID:{c.pmid}"))
|
|
539
|
+
|
|
540
|
+
if not id_entries:
|
|
541
|
+
return {}
|
|
542
|
+
|
|
543
|
+
results: dict[str, dict[str, Any]] = {}
|
|
544
|
+
|
|
545
|
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
546
|
+
from sciwrite_lint.usage import tracked
|
|
547
|
+
|
|
548
|
+
# S2 batch: max 500 per request
|
|
549
|
+
for i in range(0, len(id_entries), 500):
|
|
550
|
+
batch = id_entries[i : i + 500]
|
|
551
|
+
ids = [s2_id for _, s2_id in batch]
|
|
552
|
+
async with tracked("semantic_scholar", ids=len(ids)):
|
|
553
|
+
try:
|
|
554
|
+
resp = await retry_on_transient(
|
|
555
|
+
lambda: client.post(
|
|
556
|
+
"https://api.semanticscholar.org/graph/v1/paper/batch",
|
|
557
|
+
json={"ids": ids},
|
|
558
|
+
params={"fields": _S2_FIELDS},
|
|
559
|
+
),
|
|
560
|
+
label="S2 batch",
|
|
561
|
+
)
|
|
562
|
+
if resp.status_code != 200:
|
|
563
|
+
continue
|
|
564
|
+
papers = resp.json()
|
|
565
|
+
for paper, (key, _s2_id) in zip(papers, batch):
|
|
566
|
+
if paper: # S2 returns null for not-found
|
|
567
|
+
results[key] = _parse_s2(paper)
|
|
568
|
+
except httpx.HTTPError as e:
|
|
569
|
+
logger.debug("S2 batch request failed: {}", e)
|
|
570
|
+
continue
|
|
571
|
+
|
|
572
|
+
return results
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
async def parallel_crossref(
|
|
576
|
+
citations: list[Citation],
|
|
577
|
+
config: LintConfig | None = None,
|
|
578
|
+
) -> dict[str, dict[str, Any]]:
|
|
579
|
+
"""Parallel CrossRef lookups with rate-limiting semaphore.
|
|
580
|
+
|
|
581
|
+
Async httpx — one shared client, no threads, no per-call SSL overhead.
|
|
582
|
+
"""
|
|
583
|
+
from sciwrite_lint.references.crossref import crossref_lookup
|
|
584
|
+
|
|
585
|
+
config = config or LintConfig()
|
|
586
|
+
results: dict[str, dict[str, Any]] = {}
|
|
587
|
+
# CrossRef polite pool (with mailto): 10 req/s, 3 concurrent
|
|
588
|
+
# Public pool (no mailto): 5 req/s, 1 concurrent
|
|
589
|
+
if config.polite_email:
|
|
590
|
+
crossref_limiter = MonotonicRateLimiter(10, 1.0)
|
|
591
|
+
sem = asyncio.Semaphore(3)
|
|
592
|
+
else:
|
|
593
|
+
crossref_limiter = MonotonicRateLimiter(5, 1.0)
|
|
594
|
+
sem = asyncio.Semaphore(1)
|
|
595
|
+
|
|
596
|
+
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
|
597
|
+
|
|
598
|
+
async def _lookup_one(c: Citation) -> None:
|
|
599
|
+
async with sem, crossref_limiter:
|
|
600
|
+
from sciwrite_lint.usage import tracked
|
|
601
|
+
|
|
602
|
+
async with tracked("crossref"):
|
|
603
|
+
try:
|
|
604
|
+
result = await crossref_lookup(
|
|
605
|
+
c,
|
|
606
|
+
polite_email=config.polite_email,
|
|
607
|
+
client=client,
|
|
608
|
+
)
|
|
609
|
+
if result and not result.get("error"):
|
|
610
|
+
results[c.key] = result
|
|
611
|
+
except Exception as e:
|
|
612
|
+
logger.debug("CrossRef lookup failed for {}: {}", c.key, e)
|
|
613
|
+
|
|
614
|
+
await asyncio.gather(*[_lookup_one(c) for c in citations])
|
|
615
|
+
return results
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
# Response parsing
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
_OA_FIELDS = (
|
|
623
|
+
"id,ids,doi,title,authorships,publication_year,"
|
|
624
|
+
"cited_by_count,primary_location,locations,open_access,abstract_inverted_index"
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
_S2_FIELDS = (
|
|
628
|
+
"title,authors,year,venue,citationCount,externalIds,abstract,url,openAccessPdf"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _decode_inverted_abstract(inverted: dict[str, list[int]]) -> str:
|
|
633
|
+
word_positions = [
|
|
634
|
+
(pos, word) for word, positions in inverted.items() for pos in positions
|
|
635
|
+
]
|
|
636
|
+
word_positions.sort()
|
|
637
|
+
return " ".join(w for _, w in word_positions)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _parse_openalex(work: dict) -> dict[str, Any]:
|
|
641
|
+
authors = [
|
|
642
|
+
a.get("author", {}).get("display_name", "")
|
|
643
|
+
for a in work.get("authorships", [])
|
|
644
|
+
if a.get("author", {}).get("display_name")
|
|
645
|
+
]
|
|
646
|
+
doi = work.get("doi", "") or ""
|
|
647
|
+
if doi.startswith("https://doi.org/"):
|
|
648
|
+
doi = doi[len("https://doi.org/") :]
|
|
649
|
+
abstract = None
|
|
650
|
+
inverted = work.get("abstract_inverted_index")
|
|
651
|
+
if inverted:
|
|
652
|
+
abstract = _decode_inverted_abstract(inverted)
|
|
653
|
+
|
|
654
|
+
# Extract arxiv_id from locations (ids.arxiv is often missing)
|
|
655
|
+
arxiv_id = None
|
|
656
|
+
for loc in work.get("locations") or []:
|
|
657
|
+
landing = loc.get("landing_page_url") or ""
|
|
658
|
+
if "arxiv.org/abs/" in landing:
|
|
659
|
+
arxiv_id = landing.split("arxiv.org/abs/")[-1]
|
|
660
|
+
break
|
|
661
|
+
|
|
662
|
+
# Extract PMCID from OpenAlex ids (format: "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC...")
|
|
663
|
+
pmcid = None
|
|
664
|
+
oa_pmcid = (work.get("ids") or {}).get("pmcid") or ""
|
|
665
|
+
if "PMC" in oa_pmcid:
|
|
666
|
+
pmcid = oa_pmcid.split("/")[-1] if "/" in oa_pmcid else oa_pmcid
|
|
667
|
+
|
|
668
|
+
# Venue from primary_location.source (canonical name from OpenAlex)
|
|
669
|
+
venue = ""
|
|
670
|
+
primary_loc = work.get("primary_location") or {}
|
|
671
|
+
source = primary_loc.get("source") or {}
|
|
672
|
+
venue = source.get("display_name") or ""
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
"source": "openalex",
|
|
676
|
+
"title": work.get("title", ""),
|
|
677
|
+
"authors": authors,
|
|
678
|
+
"year": work.get("publication_year"),
|
|
679
|
+
"doi": doi,
|
|
680
|
+
"venue": venue,
|
|
681
|
+
"citation_count": work.get("cited_by_count", 0),
|
|
682
|
+
"pdf_url": work.get("open_access", {}).get("oa_url"),
|
|
683
|
+
"arxiv_id": arxiv_id,
|
|
684
|
+
"pmcid": pmcid,
|
|
685
|
+
"abstract": abstract,
|
|
686
|
+
"retracted": work.get("is_retracted", False),
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _parse_s2(paper: dict) -> dict[str, Any]:
|
|
691
|
+
ext_ids = paper.get("externalIds") or {}
|
|
692
|
+
oa_pdf = paper.get("openAccessPdf") or {}
|
|
693
|
+
return {
|
|
694
|
+
"source": "semantic_scholar",
|
|
695
|
+
"title": paper.get("title", ""),
|
|
696
|
+
"authors": [a.get("name", "") for a in (paper.get("authors") or [])],
|
|
697
|
+
"year": paper.get("year"),
|
|
698
|
+
"doi": ext_ids.get("DOI", ""),
|
|
699
|
+
"venue": paper.get("venue", ""),
|
|
700
|
+
"citation_count": paper.get("citationCount", 0),
|
|
701
|
+
"arxiv_id": ext_ids.get("ArXiv"),
|
|
702
|
+
"pmcid": ext_ids.get("PubMedCentral"),
|
|
703
|
+
"s2_pdf_url": oa_pdf.get("url"),
|
|
704
|
+
"abstract": paper.get("abstract"),
|
|
705
|
+
"url": paper.get("url", ""),
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _extract_ol_year(data: dict) -> int | None:
|
|
710
|
+
"""Extract publication year from Open Library ISBN API response."""
|
|
711
|
+
pd = data.get("publish_date", "")
|
|
712
|
+
if pd:
|
|
713
|
+
m = re.search(r"\b(\d{4})\b", pd)
|
|
714
|
+
if m:
|
|
715
|
+
return int(m.group(1))
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _parse_openlibrary(doc: dict) -> dict[str, Any]:
|
|
720
|
+
"""Parse an Open Library search result into normalized format."""
|
|
721
|
+
isbn_list = doc.get("isbn") or []
|
|
722
|
+
title = doc.get("title", "")
|
|
723
|
+
subtitle = doc.get("subtitle", "")
|
|
724
|
+
if subtitle:
|
|
725
|
+
title = f"{title}: {subtitle}"
|
|
726
|
+
return {
|
|
727
|
+
"source": "openlibrary",
|
|
728
|
+
"title": title,
|
|
729
|
+
"authors": doc.get("author_name") or [],
|
|
730
|
+
"year": doc.get("first_publish_year"),
|
|
731
|
+
"doi": "",
|
|
732
|
+
"venue": doc.get("publisher", [""])[0] if doc.get("publisher") else "",
|
|
733
|
+
"citation_count": 0,
|
|
734
|
+
"isbn": isbn_list[0] if isbn_list else "",
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _parse_loc(item: dict) -> dict[str, Any]:
|
|
739
|
+
"""Parse a Library of Congress search result into normalized format."""
|
|
740
|
+
contributors = item.get("contributor") or []
|
|
741
|
+
date_str = item.get("date") or ""
|
|
742
|
+
# LoC dates can be "1910" or "1910-01-01" etc.
|
|
743
|
+
year = None
|
|
744
|
+
if date_str:
|
|
745
|
+
match = re.match(r"(\d{4})", date_str)
|
|
746
|
+
if match:
|
|
747
|
+
year = int(match.group(1))
|
|
748
|
+
return {
|
|
749
|
+
"source": "loc",
|
|
750
|
+
"title": item.get("title", ""),
|
|
751
|
+
"authors": contributors,
|
|
752
|
+
"year": year,
|
|
753
|
+
"doi": "",
|
|
754
|
+
"venue": "",
|
|
755
|
+
"citation_count": 0,
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _name_variants(name: str) -> list[str]:
|
|
760
|
+
"""Generate common formatting variants of an author name.
|
|
761
|
+
|
|
762
|
+
Given "John A. Smith", produces variants like:
|
|
763
|
+
- "john a smith", "john smith", "j a smith", "j smith"
|
|
764
|
+
- "smith john a", "smith john", "smith j a", "smith j"
|
|
765
|
+
|
|
766
|
+
Handles comma-delimited "Family, Given" format, anyascii
|
|
767
|
+
transliteration (Müller→Mueller, Cyrillic→Latin), given names
|
|
768
|
+
as initials or full, middle names droppable, and both
|
|
769
|
+
given-family and family-given orderings.
|
|
770
|
+
|
|
771
|
+
For 3+ token names, tries both last-token-as-family (Western)
|
|
772
|
+
and first-token-as-family (East Asian, Hungarian) to cover
|
|
773
|
+
ambiguous name orderings without a comma delimiter.
|
|
774
|
+
|
|
775
|
+
All-initials variants (e.g. "w w") are filtered out as too
|
|
776
|
+
ambiguous. Family name is never reduced to an initial.
|
|
777
|
+
"""
|
|
778
|
+
from anyascii import anyascii
|
|
779
|
+
|
|
780
|
+
name = anyascii(name).lower().strip()
|
|
781
|
+
if not name:
|
|
782
|
+
return []
|
|
783
|
+
|
|
784
|
+
# Handle "Family, Given" format before stripping punctuation
|
|
785
|
+
if "," in name:
|
|
786
|
+
parts = [p.strip() for p in name.split(",", 1)]
|
|
787
|
+
if len(parts) == 2 and parts[1]:
|
|
788
|
+
name = f"{parts[1]} {parts[0]}"
|
|
789
|
+
|
|
790
|
+
name = re.sub(r"[.,;:'\"]+", " ", name)
|
|
791
|
+
name = re.sub(r"\s+", " ", name).strip()
|
|
792
|
+
|
|
793
|
+
tokens = name.split()
|
|
794
|
+
if not tokens:
|
|
795
|
+
return []
|
|
796
|
+
if len(tokens) == 1:
|
|
797
|
+
return [tokens[0]]
|
|
798
|
+
|
|
799
|
+
# Build initial variants for each token: "john" → ["john", "j"]
|
|
800
|
+
token_forms: list[list[str]] = []
|
|
801
|
+
for t in tokens:
|
|
802
|
+
forms = [t]
|
|
803
|
+
if len(t) > 1:
|
|
804
|
+
forms.append(t[0]) # initial
|
|
805
|
+
token_forms.append(forms)
|
|
806
|
+
|
|
807
|
+
# Generate given-name combinations: first given name is always present
|
|
808
|
+
# (full or initial), middle names can be full/initial/dropped.
|
|
809
|
+
def _middle_combos(forms: list[list[str]]) -> list[list[str]]:
|
|
810
|
+
"""Cartesian product of middle-name forms, allowing drops."""
|
|
811
|
+
if not forms:
|
|
812
|
+
return [[]]
|
|
813
|
+
combos: list[list[str]] = []
|
|
814
|
+
for f in forms[0]:
|
|
815
|
+
for rest in _middle_combos(forms[1:]):
|
|
816
|
+
combos.append([f, *rest])
|
|
817
|
+
# Also drop this middle name entirely
|
|
818
|
+
for rest in _middle_combos(forms[1:]):
|
|
819
|
+
combos.append(rest)
|
|
820
|
+
return combos
|
|
821
|
+
|
|
822
|
+
def _variants_for_split(family: str, given_forms: list[list[str]]) -> set[str]:
|
|
823
|
+
"""Generate variants for one family/given assignment."""
|
|
824
|
+
result: set[str] = set()
|
|
825
|
+
first_forms_list = given_forms[0] if given_forms else []
|
|
826
|
+
mid_forms = given_forms[1:] if len(given_forms) > 1 else []
|
|
827
|
+
|
|
828
|
+
combos: list[list[str]] = []
|
|
829
|
+
if first_forms_list:
|
|
830
|
+
for ff in first_forms_list:
|
|
831
|
+
for mc in _middle_combos(mid_forms):
|
|
832
|
+
combos.append([ff, *mc])
|
|
833
|
+
# Also try without given names entirely (just family)
|
|
834
|
+
combos.append([])
|
|
835
|
+
else:
|
|
836
|
+
combos.append([])
|
|
837
|
+
|
|
838
|
+
for combo in combos:
|
|
839
|
+
result.add(" ".join([*combo, family]))
|
|
840
|
+
result.add(" ".join([family, *combo]))
|
|
841
|
+
result.add(family)
|
|
842
|
+
return result
|
|
843
|
+
|
|
844
|
+
variants: set[str] = set()
|
|
845
|
+
|
|
846
|
+
# Primary: assume last token is family (Western convention)
|
|
847
|
+
variants |= _variants_for_split(tokens[-1], token_forms[:-1])
|
|
848
|
+
|
|
849
|
+
# Also try first token as family (East Asian, Hungarian, etc.)
|
|
850
|
+
# For 2-token names this is redundant (cross-product symmetry),
|
|
851
|
+
# but for 3+ tokens the middle names shift.
|
|
852
|
+
if len(tokens) >= 3:
|
|
853
|
+
variants |= _variants_for_split(tokens[0], token_forms[1:])
|
|
854
|
+
|
|
855
|
+
# Drop all-initials variants (e.g. "w w") — too ambiguous
|
|
856
|
+
return [v for v in variants if any(len(t) > 1 for t in v.split())]
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _author_overlap(
|
|
860
|
+
query_authors: list[str] | tuple[str, ...],
|
|
861
|
+
item_authors: list[str] | tuple[str, ...],
|
|
862
|
+
) -> float:
|
|
863
|
+
"""Pairwise best-match author similarity (0.0 to 1.0).
|
|
864
|
+
|
|
865
|
+
For each query author, generates all common name formatting variants
|
|
866
|
+
and finds the best fuzzy match against all variants of each item
|
|
867
|
+
author. Returns the average of these best matches across query authors.
|
|
868
|
+
"""
|
|
869
|
+
from rapidfuzz import fuzz
|
|
870
|
+
|
|
871
|
+
q_variant_lists = [_name_variants(a) for a in query_authors if a.strip()]
|
|
872
|
+
i_variant_lists = [_name_variants(a) for a in item_authors if a.strip()]
|
|
873
|
+
q_variant_lists = [v for v in q_variant_lists if v]
|
|
874
|
+
i_variant_lists = [v for v in i_variant_lists if v]
|
|
875
|
+
if not q_variant_lists or not i_variant_lists:
|
|
876
|
+
return 0.0
|
|
877
|
+
|
|
878
|
+
total = 0.0
|
|
879
|
+
for q_variants in q_variant_lists:
|
|
880
|
+
best = 0.0
|
|
881
|
+
for i_variants in i_variant_lists:
|
|
882
|
+
for qv in q_variants:
|
|
883
|
+
for iv in i_variants:
|
|
884
|
+
score = fuzz.ratio(qv, iv) / 100.0
|
|
885
|
+
if score > best:
|
|
886
|
+
best = score
|
|
887
|
+
total += best
|
|
888
|
+
return total / len(q_variant_lists)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _best_match(
|
|
892
|
+
query_title: str,
|
|
893
|
+
candidates: list[dict[str, Any]],
|
|
894
|
+
threshold: float = 0.70,
|
|
895
|
+
query_authors: list[str] | tuple[str, ...] | None = None,
|
|
896
|
+
query_year: int | None = None,
|
|
897
|
+
query_venue: str = "",
|
|
898
|
+
) -> dict[str, Any] | None:
|
|
899
|
+
"""Pick the candidate whose title best matches the query.
|
|
900
|
+
|
|
901
|
+
API title searches can return commentaries, reviews, or unrelated
|
|
902
|
+
papers above the actual match. Scores all candidates by fuzzy title
|
|
903
|
+
similarity and returns the best one above *threshold*.
|
|
904
|
+
|
|
905
|
+
If *query_authors* is provided, candidates are penalized based on
|
|
906
|
+
pairwise fuzzy author name similarity (handles initials, name order,
|
|
907
|
+
transliteration). Score is scaled from 0.5 (no match) to 1.0 (perfect).
|
|
908
|
+
|
|
909
|
+
If *query_year* is provided, candidates whose year diverges are
|
|
910
|
+
penalized with quadratic decay: ``1 / (1 + (delta/3)²)``.
|
|
911
|
+
Tolerates ±1 year (preprint→published) while crushing large deltas.
|
|
912
|
+
|
|
913
|
+
If *query_venue* is provided, candidates with a matching venue get
|
|
914
|
+
a small boost (tiebreaker). Score is scaled from 0.9 (no match) to
|
|
915
|
+
1.0 (perfect match). Never penalizes below 0.9 — venue is only
|
|
916
|
+
used to prefer the right version of the same paper.
|
|
917
|
+
"""
|
|
918
|
+
from rapidfuzz import fuzz
|
|
919
|
+
|
|
920
|
+
query_lower = query_title.lower().strip()
|
|
921
|
+
query_venue_lower = query_venue.lower().strip()
|
|
922
|
+
|
|
923
|
+
best: dict[str, Any] | None = None
|
|
924
|
+
best_score = 0.0
|
|
925
|
+
|
|
926
|
+
for item in candidates:
|
|
927
|
+
item_title = (item.get("title") or "").lower().strip()
|
|
928
|
+
if not item_title:
|
|
929
|
+
continue
|
|
930
|
+
score = fuzz.ratio(query_lower, item_title) / 100.0
|
|
931
|
+
|
|
932
|
+
# Graduated author penalty (pairwise fuzzy name matching)
|
|
933
|
+
if query_authors:
|
|
934
|
+
item_authors = item.get("authors") or []
|
|
935
|
+
if item_authors:
|
|
936
|
+
sim = _author_overlap(query_authors, item_authors)
|
|
937
|
+
# Scale from 0.5 (no match) to 1.0 (perfect match)
|
|
938
|
+
score *= 0.5 + 0.5 * sim
|
|
939
|
+
|
|
940
|
+
# Graduated year penalty — quadratic decay (tolerates ±1 for
|
|
941
|
+
# preprint→published, crushes large deltas like 13+ years)
|
|
942
|
+
if query_year is not None:
|
|
943
|
+
item_year = item.get("year")
|
|
944
|
+
if item_year is not None:
|
|
945
|
+
delta = abs(item_year - query_year)
|
|
946
|
+
score *= 1.0 / (1.0 + (delta / 3.0) ** 2)
|
|
947
|
+
|
|
948
|
+
# Venue tiebreaker — boost matching venue, never penalize below 0.9
|
|
949
|
+
if query_venue_lower:
|
|
950
|
+
item_venue = (item.get("venue") or "").lower().strip()
|
|
951
|
+
if item_venue:
|
|
952
|
+
vsim = fuzz.partial_ratio(query_venue_lower, item_venue) / 100.0
|
|
953
|
+
score *= 0.9 + 0.1 * vsim
|
|
954
|
+
|
|
955
|
+
if score > best_score:
|
|
956
|
+
best_score = score
|
|
957
|
+
best = item
|
|
958
|
+
|
|
959
|
+
return best if best_score >= threshold else None
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _id_result_matches(citation: Citation, result: dict[str, Any]) -> bool:
|
|
963
|
+
"""Check whether an ID-based lookup result plausibly matches the citation.
|
|
964
|
+
|
|
965
|
+
LLMs can hallucinate or corrupt DOIs, arXiv IDs, and PMIDs. When an ID
|
|
966
|
+
lookup returns a completely different paper, the result should be
|
|
967
|
+
discarded so the matching engine can find the correct one.
|
|
968
|
+
|
|
969
|
+
Uses the same composite scoring as ``_best_match`` (title × author ×
|
|
970
|
+
year) with a lenient threshold (0.40) — we only reject clear
|
|
971
|
+
mismatches, not minor metadata variations.
|
|
972
|
+
"""
|
|
973
|
+
# Treat the result as a single-candidate selection problem
|
|
974
|
+
score = _best_match(
|
|
975
|
+
citation.title or "",
|
|
976
|
+
[result],
|
|
977
|
+
threshold=0.0, # always return a score, we check manually
|
|
978
|
+
query_authors=citation.authors if citation.authors else None,
|
|
979
|
+
query_year=int(citation.year) if citation.year.isdigit() else None,
|
|
980
|
+
query_venue=citation.venue,
|
|
981
|
+
)
|
|
982
|
+
if score is None:
|
|
983
|
+
# No title at all — can't validate, trust the ID
|
|
984
|
+
return True
|
|
985
|
+
|
|
986
|
+
# Re-score to get the actual numeric value (we need the score, not
|
|
987
|
+
# just the match). Use title sim as primary gate since _best_match
|
|
988
|
+
# returns the item not the score.
|
|
989
|
+
from rapidfuzz import fuzz
|
|
990
|
+
|
|
991
|
+
if not citation.title:
|
|
992
|
+
return True
|
|
993
|
+
api_title = (result.get("title") or "").strip()
|
|
994
|
+
if not api_title:
|
|
995
|
+
return True
|
|
996
|
+
|
|
997
|
+
# Try all subtitle combinations: books often have subtitles in bib
|
|
998
|
+
# but not in the API (or vice versa). Take the best match.
|
|
999
|
+
bib_titles = [citation.title.lower()]
|
|
1000
|
+
if ":" in citation.title:
|
|
1001
|
+
bib_titles.append(citation.title.split(":")[0].strip().lower())
|
|
1002
|
+
api_titles = [api_title.lower()]
|
|
1003
|
+
if ":" in api_title:
|
|
1004
|
+
api_titles.append(api_title.split(":")[0].strip().lower())
|
|
1005
|
+
title_sim = max(
|
|
1006
|
+
fuzz.ratio(bt, at) / 100.0 for bt in bib_titles for at in api_titles
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
# Author check — if both have authors and overlap is very low, suspect
|
|
1010
|
+
author_sim = 1.0
|
|
1011
|
+
api_authors = result.get("authors") or []
|
|
1012
|
+
if citation.authors and api_authors:
|
|
1013
|
+
author_sim = _author_overlap(citation.authors, api_authors)
|
|
1014
|
+
|
|
1015
|
+
# Year check — lenient for ID-based lookups. The ID already resolved
|
|
1016
|
+
# to a paper; we only need to catch hallucinated IDs pointing to a
|
|
1017
|
+
# completely different paper. Year metadata drifts (preprint→published,
|
|
1018
|
+
# API re-dating like OpenAlex updating Vaswani 2017 to 2025) should
|
|
1019
|
+
# not reject an otherwise perfect title+author match. Floor at 0.50
|
|
1020
|
+
# so year alone cannot push composite below 0.40 when title≈1.0.
|
|
1021
|
+
year_factor = 1.0
|
|
1022
|
+
api_year = result.get("year")
|
|
1023
|
+
if citation.year.isdigit() and api_year is not None:
|
|
1024
|
+
delta = abs(int(citation.year) - api_year)
|
|
1025
|
+
year_factor = max(0.50, 1.0 / (1.0 + (delta / 3.0) ** 2))
|
|
1026
|
+
|
|
1027
|
+
composite = title_sim * (0.5 + 0.5 * author_sim) * year_factor
|
|
1028
|
+
|
|
1029
|
+
if composite >= 0.40:
|
|
1030
|
+
return True
|
|
1031
|
+
|
|
1032
|
+
logger.debug(
|
|
1033
|
+
"ID lookup mismatch for {}: composite={:.2f} "
|
|
1034
|
+
"(title={:.2f}, author={:.2f}, year={:.2f}), "
|
|
1035
|
+
"bib='{}', API='{}'",
|
|
1036
|
+
citation.key,
|
|
1037
|
+
composite,
|
|
1038
|
+
title_sim,
|
|
1039
|
+
author_sim,
|
|
1040
|
+
year_factor,
|
|
1041
|
+
citation.title[:60],
|
|
1042
|
+
api_title[:60],
|
|
1043
|
+
)
|
|
1044
|
+
return False
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
# ---------------------------------------------------------------------------
|
|
1048
|
+
# Cross-validate multiple identifiers in a single bib entry
|
|
1049
|
+
# ---------------------------------------------------------------------------
|
|
1050
|
+
|
|
1051
|
+
# Regex for extracting DOI / arXiv ID from URLs
|
|
1052
|
+
_URL_DOI_RE = re.compile(r"(10\.\d{4,9}/[^\s,}]+)")
|
|
1053
|
+
_URL_ARXIV_RE = re.compile(r"arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})")
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _results_match(
|
|
1057
|
+
canonical: dict[str, Any],
|
|
1058
|
+
other: dict[str, Any],
|
|
1059
|
+
) -> bool:
|
|
1060
|
+
"""Check whether two API results refer to the same paper.
|
|
1061
|
+
|
|
1062
|
+
Reuses ``_id_result_matches`` by building a temporary Citation from the
|
|
1063
|
+
canonical result, so the exact same scoring logic (title × author × year,
|
|
1064
|
+
threshold 0.40) applies.
|
|
1065
|
+
"""
|
|
1066
|
+
proxy = Citation(
|
|
1067
|
+
key="_cross_validate",
|
|
1068
|
+
raw_text="",
|
|
1069
|
+
title=canonical.get("title") or "",
|
|
1070
|
+
authors=canonical.get("authors") or [],
|
|
1071
|
+
year=str(canonical["year"]) if canonical.get("year") is not None else "",
|
|
1072
|
+
)
|
|
1073
|
+
return _id_result_matches(proxy, other)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
async def cross_validate_ids(
|
|
1077
|
+
citation: Citation,
|
|
1078
|
+
canonical: dict[str, Any],
|
|
1079
|
+
config: LintConfig | None = None,
|
|
1080
|
+
client: httpx.AsyncClient | None = None,
|
|
1081
|
+
) -> list[str]:
|
|
1082
|
+
"""Look up each bib ID independently via OpenAlex and verify they resolve to the same paper.
|
|
1083
|
+
|
|
1084
|
+
Compares metadata from each secondary ID lookup against the canonical
|
|
1085
|
+
result using composite scoring (title × author × year). Reports
|
|
1086
|
+
conflicting identifiers where the score falls below 0.40.
|
|
1087
|
+
|
|
1088
|
+
Uses the shared ``_openalex_limiter`` for rate limiting. Pass an existing
|
|
1089
|
+
``client`` to reuse connections; if None, creates a temporary one.
|
|
1090
|
+
|
|
1091
|
+
Returns list of issue strings (empty if all IDs agree or only one ID exists).
|
|
1092
|
+
"""
|
|
1093
|
+
config = config or LintConfig()
|
|
1094
|
+
canon_title = (canonical.get("title") or "")[:60]
|
|
1095
|
+
|
|
1096
|
+
# Collect secondary IDs to check — skip the one used for the canonical lookup
|
|
1097
|
+
canon_doi = (canonical.get("doi") or "").lower()
|
|
1098
|
+
canon_arxiv = canonical.get("arxiv_id") or ""
|
|
1099
|
+
canon_pmcid = canonical.get("pmcid") or ""
|
|
1100
|
+
|
|
1101
|
+
lookups: list[tuple[str, str, str]] = [] # (id_type, id_value, oa_url_or_filter)
|
|
1102
|
+
|
|
1103
|
+
# DOI
|
|
1104
|
+
if citation.doi:
|
|
1105
|
+
clean_doi = clean_and_validate_doi(citation.doi)
|
|
1106
|
+
if clean_doi and clean_doi.lower() != canon_doi:
|
|
1107
|
+
lookups.append(("DOI", clean_doi, f"doi:{clean_doi}"))
|
|
1108
|
+
|
|
1109
|
+
# arXiv ID
|
|
1110
|
+
if citation.arxiv_id and is_valid_arxiv_id(citation.arxiv_id):
|
|
1111
|
+
if citation.arxiv_id != canon_arxiv:
|
|
1112
|
+
arxiv_doi = f"{_ARXIV_DOI_PREFIX}{citation.arxiv_id}"
|
|
1113
|
+
lookups.append(("arXiv ID", citation.arxiv_id, f"doi:{arxiv_doi}"))
|
|
1114
|
+
|
|
1115
|
+
# PMID
|
|
1116
|
+
if citation.pmid and is_valid_pmid(citation.pmid):
|
|
1117
|
+
lookups.append(
|
|
1118
|
+
(
|
|
1119
|
+
"PMID",
|
|
1120
|
+
citation.pmid,
|
|
1121
|
+
f"filter:ids.pmid:https://pubmed.ncbi.nlm.nih.gov/{citation.pmid}",
|
|
1122
|
+
)
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
# PMC ID
|
|
1126
|
+
if citation.pmc_id and is_valid_pmcid(citation.pmc_id):
|
|
1127
|
+
if citation.pmc_id != canon_pmcid:
|
|
1128
|
+
lookups.append(
|
|
1129
|
+
(
|
|
1130
|
+
"PMC ID",
|
|
1131
|
+
citation.pmc_id,
|
|
1132
|
+
f"filter:ids.pmcid:https://www.ncbi.nlm.nih.gov/pmc/articles/{citation.pmc_id}",
|
|
1133
|
+
)
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
# URL — extract embedded DOI or arXiv ID from the URL text.
|
|
1137
|
+
# If no ID in the URL, HEAD-request it to follow redirects and
|
|
1138
|
+
# extract a DOI from the final URL (many publisher URLs redirect
|
|
1139
|
+
# through doi.org).
|
|
1140
|
+
url_needs_resolve = False
|
|
1141
|
+
if citation.url:
|
|
1142
|
+
url_doi_match = _URL_DOI_RE.search(citation.url)
|
|
1143
|
+
url_arxiv_match = _URL_ARXIV_RE.search(citation.url)
|
|
1144
|
+
if url_doi_match:
|
|
1145
|
+
url_doi = url_doi_match.group(1).rstrip(".")
|
|
1146
|
+
clean_url_doi = clean_and_validate_doi(url_doi)
|
|
1147
|
+
if clean_url_doi and clean_url_doi.lower() != canon_doi:
|
|
1148
|
+
lookups.append(("URL (DOI)", clean_url_doi, f"doi:{clean_url_doi}"))
|
|
1149
|
+
elif url_arxiv_match:
|
|
1150
|
+
url_arxiv = url_arxiv_match.group(1)
|
|
1151
|
+
if url_arxiv != canon_arxiv:
|
|
1152
|
+
url_arxiv_doi = f"{_ARXIV_DOI_PREFIX}{url_arxiv}"
|
|
1153
|
+
lookups.append(("URL (arXiv)", url_arxiv, f"doi:{url_arxiv_doi}"))
|
|
1154
|
+
else:
|
|
1155
|
+
# No ID in URL text — will HEAD-request to resolve
|
|
1156
|
+
url_needs_resolve = True
|
|
1157
|
+
|
|
1158
|
+
if not lookups and not url_needs_resolve:
|
|
1159
|
+
return []
|
|
1160
|
+
|
|
1161
|
+
issues: list[str] = []
|
|
1162
|
+
owns_client = client is None
|
|
1163
|
+
if client is None:
|
|
1164
|
+
client = httpx.AsyncClient(timeout=15.0, follow_redirects=True)
|
|
1165
|
+
try:
|
|
1166
|
+
# Resolve URL via HEAD request if no DOI/arXiv was in the URL text
|
|
1167
|
+
if url_needs_resolve:
|
|
1168
|
+
try:
|
|
1169
|
+
resolved_doi = await _resolve_url_to_doi(citation.url)
|
|
1170
|
+
if resolved_doi and resolved_doi.lower() != canon_doi:
|
|
1171
|
+
lookups.append(
|
|
1172
|
+
("URL (resolved)", resolved_doi, f"doi:{resolved_doi}")
|
|
1173
|
+
)
|
|
1174
|
+
elif not resolved_doi:
|
|
1175
|
+
# URL is alive but no DOI in the final URL — can't validate
|
|
1176
|
+
issues.append(
|
|
1177
|
+
f"Unverified URL identifier: URL '{citation.url[:80]}' "
|
|
1178
|
+
f"could not be cross-validated (no DOI in resolved URL)"
|
|
1179
|
+
)
|
|
1180
|
+
except Exception as e:
|
|
1181
|
+
logger.debug("URL resolve failed for {}: {}", citation.url, e)
|
|
1182
|
+
issues.append(
|
|
1183
|
+
f"Unverified URL identifier: URL '{citation.url[:80]}' "
|
|
1184
|
+
f"could not be cross-validated ({e})"
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
for id_type, id_value, oa_query in lookups:
|
|
1188
|
+
is_url = id_type.startswith("URL")
|
|
1189
|
+
try:
|
|
1190
|
+
other = await _oa_lookup_by_query(oa_query, id_type, client)
|
|
1191
|
+
if other is None:
|
|
1192
|
+
# Lookup returned no result — warn that we couldn't validate
|
|
1193
|
+
if is_url:
|
|
1194
|
+
issues.append(
|
|
1195
|
+
f"Unverified URL identifier: {id_type} '{id_value}' "
|
|
1196
|
+
f"could not be cross-validated (no OpenAlex record)"
|
|
1197
|
+
)
|
|
1198
|
+
else:
|
|
1199
|
+
issues.append(
|
|
1200
|
+
f"Unverified identifier mismatch: {id_type} '{id_value}' "
|
|
1201
|
+
f"could not be cross-validated (no OpenAlex record)"
|
|
1202
|
+
)
|
|
1203
|
+
continue
|
|
1204
|
+
|
|
1205
|
+
if not _results_match(canonical, other):
|
|
1206
|
+
other_title = (other.get("title") or "")[:60]
|
|
1207
|
+
issues.append(
|
|
1208
|
+
f"Identifier mismatch: {id_type} '{id_value}' resolves to "
|
|
1209
|
+
f"'{other_title}', "
|
|
1210
|
+
f"not matching the verified paper '{canon_title}'"
|
|
1211
|
+
)
|
|
1212
|
+
logger.debug(
|
|
1213
|
+
"Cross-validate mismatch for {}: {} '{}' → '{}'",
|
|
1214
|
+
citation.key,
|
|
1215
|
+
id_type,
|
|
1216
|
+
id_value,
|
|
1217
|
+
other_title,
|
|
1218
|
+
)
|
|
1219
|
+
except Exception as e:
|
|
1220
|
+
logger.debug(
|
|
1221
|
+
"Cross-validate lookup failed for {} {}: {}",
|
|
1222
|
+
id_type,
|
|
1223
|
+
id_value,
|
|
1224
|
+
e,
|
|
1225
|
+
)
|
|
1226
|
+
continue
|
|
1227
|
+
finally:
|
|
1228
|
+
if owns_client and client is not None:
|
|
1229
|
+
await client.aclose()
|
|
1230
|
+
|
|
1231
|
+
return issues
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
async def _oa_lookup_by_query(
|
|
1235
|
+
oa_query: str,
|
|
1236
|
+
id_type: str,
|
|
1237
|
+
client: httpx.AsyncClient,
|
|
1238
|
+
) -> dict[str, Any] | None:
|
|
1239
|
+
"""Single OpenAlex lookup for cross-validation. Returns parsed result or None."""
|
|
1240
|
+
if oa_query.startswith("filter:"):
|
|
1241
|
+
filter_str = oa_query.removeprefix("filter:")
|
|
1242
|
+
resp = await rate_limited_get(
|
|
1243
|
+
_openalex_limiter,
|
|
1244
|
+
"https://api.openalex.org/works",
|
|
1245
|
+
params={"filter": filter_str, "select": _OA_FIELDS},
|
|
1246
|
+
label=f"OpenAlex cross-validate {id_type}",
|
|
1247
|
+
client=client,
|
|
1248
|
+
service="openalex",
|
|
1249
|
+
)
|
|
1250
|
+
if resp.status_code != 200:
|
|
1251
|
+
return None
|
|
1252
|
+
results = resp.json().get("results", [])
|
|
1253
|
+
return _parse_openalex(results[0]) if results else None
|
|
1254
|
+
|
|
1255
|
+
resp = await rate_limited_get(
|
|
1256
|
+
_openalex_limiter,
|
|
1257
|
+
f"https://api.openalex.org/works/{oa_query}",
|
|
1258
|
+
params={"select": _OA_FIELDS},
|
|
1259
|
+
label=f"OpenAlex cross-validate {id_type}",
|
|
1260
|
+
client=client,
|
|
1261
|
+
service="openalex",
|
|
1262
|
+
)
|
|
1263
|
+
if resp.status_code != 200:
|
|
1264
|
+
return None
|
|
1265
|
+
return _parse_openalex(resp.json())
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
async def _resolve_url_to_doi(
|
|
1269
|
+
url: str,
|
|
1270
|
+
) -> str | None:
|
|
1271
|
+
"""HEAD-request a URL, follow redirects, extract DOI from the final URL.
|
|
1272
|
+
|
|
1273
|
+
Many publisher URLs redirect through doi.org or embed the DOI in the
|
|
1274
|
+
final landing page URL. Returns cleaned DOI or None if the URL is
|
|
1275
|
+
alive but contains no DOI.
|
|
1276
|
+
|
|
1277
|
+
Uses SSRF-safe transport (blocks private/reserved IPs) and validates
|
|
1278
|
+
URL scheme (https/http only). Raises on network errors — caller
|
|
1279
|
+
decides how to report. Tracks usage via ``usage.tracked("fetch")``.
|
|
1280
|
+
"""
|
|
1281
|
+
from urllib.parse import urlparse
|
|
1282
|
+
|
|
1283
|
+
from sciwrite_lint._network import ssrf_safe_client
|
|
1284
|
+
from sciwrite_lint.usage import tracked
|
|
1285
|
+
|
|
1286
|
+
parsed = urlparse(url)
|
|
1287
|
+
if parsed.scheme not in ("http", "https"):
|
|
1288
|
+
raise ValueError(f"Unsupported URL scheme: {parsed.scheme!r}")
|
|
1289
|
+
|
|
1290
|
+
async with tracked("fetch"):
|
|
1291
|
+
async with ssrf_safe_client(timeout=10.0) as client:
|
|
1292
|
+
resp = await client.head(url)
|
|
1293
|
+
if resp.status_code == 405:
|
|
1294
|
+
resp = await client.get(url)
|
|
1295
|
+
final_url = str(resp.url)
|
|
1296
|
+
|
|
1297
|
+
doi_match = _URL_DOI_RE.search(final_url)
|
|
1298
|
+
if doi_match:
|
|
1299
|
+
raw_doi = doi_match.group(1).rstrip(".")
|
|
1300
|
+
return clean_and_validate_doi(raw_doi)
|
|
1301
|
+
return None
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _first_surname(authors: list[str] | tuple[str, ...]) -> str:
|
|
1305
|
+
"""Extract the surname of the first author for search enrichment."""
|
|
1306
|
+
if not authors:
|
|
1307
|
+
return ""
|
|
1308
|
+
name = authors[0].strip()
|
|
1309
|
+
# "Smith" or "J Smith" or "John Smith" → "Smith" (last token)
|
|
1310
|
+
# "Smith, J" → "Smith" (first token before comma)
|
|
1311
|
+
if "," in name:
|
|
1312
|
+
return name.split(",")[0].strip()
|
|
1313
|
+
parts = name.split()
|
|
1314
|
+
return parts[-1] if parts else ""
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _search_query(citation: Citation) -> str:
|
|
1318
|
+
"""Build a search query string from a citation.
|
|
1319
|
+
|
|
1320
|
+
Includes first author surname when available — improves API hit rate
|
|
1321
|
+
and reduces wrong-paper matches.
|
|
1322
|
+
"""
|
|
1323
|
+
query = citation.title or citation.raw_text[:120]
|
|
1324
|
+
# Strip everything except word chars, spaces, and hyphens to avoid
|
|
1325
|
+
# breaking OpenAlex filter syntax (commas, apostrophes, colons, etc.).
|
|
1326
|
+
query = re.sub(r"[^\w\s-]", "", query).strip()
|
|
1327
|
+
surname = _first_surname(citation.authors)
|
|
1328
|
+
if surname:
|
|
1329
|
+
query = f"{surname} {query}"
|
|
1330
|
+
return query
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
# ---------------------------------------------------------------------------
|
|
1334
|
+
# Verification pipeline
|
|
1335
|
+
# ---------------------------------------------------------------------------
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
async def verify_citations(
|
|
1339
|
+
citations: list[Citation],
|
|
1340
|
+
api: CitationAPI | None = None,
|
|
1341
|
+
config: LintConfig | None = None,
|
|
1342
|
+
references_dir: Path | None = None,
|
|
1343
|
+
progress: bool = True,
|
|
1344
|
+
save: bool = True,
|
|
1345
|
+
) -> None:
|
|
1346
|
+
"""Verify a list of citations against APIs. Modifies citations in place.
|
|
1347
|
+
|
|
1348
|
+
Web resources (@misc with URL, no DOI) skip academic APIs and instead
|
|
1349
|
+
verify the URL is alive + download content as markdown.
|
|
1350
|
+
"""
|
|
1351
|
+
from sciwrite_lint.references.citations import is_web_resource
|
|
1352
|
+
from sciwrite_lint.references.metadata import (
|
|
1353
|
+
build_metadata_from_citation,
|
|
1354
|
+
load_metadata,
|
|
1355
|
+
merge_source_paper,
|
|
1356
|
+
save_metadata,
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
config = config or LintConfig()
|
|
1360
|
+
refs_dir = references_dir or config.effective_references_dir()
|
|
1361
|
+
|
|
1362
|
+
own_api = api is None
|
|
1363
|
+
if own_api:
|
|
1364
|
+
api = CitationAPI(config=config)
|
|
1365
|
+
assert api is not None # narrowing for mypy
|
|
1366
|
+
|
|
1367
|
+
try:
|
|
1368
|
+
total = len(citations)
|
|
1369
|
+
for i, c in enumerate(citations, 1):
|
|
1370
|
+
if progress:
|
|
1371
|
+
print(f" [{i}/{total}] {c.key}...", end="", flush=True)
|
|
1372
|
+
|
|
1373
|
+
if is_web_resource(c):
|
|
1374
|
+
result = await _verify_web_resource(c, api._client, refs_dir)
|
|
1375
|
+
else:
|
|
1376
|
+
result = await _verify_academic(c, api, refs_dir)
|
|
1377
|
+
|
|
1378
|
+
# Persist metadata
|
|
1379
|
+
if save:
|
|
1380
|
+
existing = load_metadata(c.key, refs_dir)
|
|
1381
|
+
if (
|
|
1382
|
+
existing
|
|
1383
|
+
and existing.api_match == "verified"
|
|
1384
|
+
and c.api_match == "not_found"
|
|
1385
|
+
):
|
|
1386
|
+
merge_source_paper(existing, c.source_paper)
|
|
1387
|
+
save_metadata(existing, refs_dir)
|
|
1388
|
+
c.tier = existing.access.get("tier", "")
|
|
1389
|
+
else:
|
|
1390
|
+
meta = build_metadata_from_citation(
|
|
1391
|
+
c, result, references_dir=refs_dir
|
|
1392
|
+
)
|
|
1393
|
+
if existing:
|
|
1394
|
+
merge_source_paper(meta, c.source_paper)
|
|
1395
|
+
for sp in existing.bibitem.get("source_papers", []):
|
|
1396
|
+
merge_source_paper(meta, sp)
|
|
1397
|
+
if existing.manual_override:
|
|
1398
|
+
meta.manual_override = existing.manual_override
|
|
1399
|
+
save_metadata(meta, refs_dir)
|
|
1400
|
+
c.tier = meta.access.get("tier", "")
|
|
1401
|
+
|
|
1402
|
+
if progress:
|
|
1403
|
+
tier_str = f" [{c.tier}]" if c.tier else ""
|
|
1404
|
+
print(f" {c.api_match}{tier_str}")
|
|
1405
|
+
finally:
|
|
1406
|
+
if own_api:
|
|
1407
|
+
await api.close()
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
async def _verify_web_resource(
|
|
1411
|
+
c: Citation,
|
|
1412
|
+
client: httpx.AsyncClient,
|
|
1413
|
+
references_dir: Path,
|
|
1414
|
+
) -> dict | None:
|
|
1415
|
+
"""Verify a web resource citation: check URL + download content."""
|
|
1416
|
+
from sciwrite_lint.web import fetch_web_content
|
|
1417
|
+
|
|
1418
|
+
web_result = await fetch_web_content(c.url, c.key, references_dir, client)
|
|
1419
|
+
resolved_url = web_result.resolved_url or c.url
|
|
1420
|
+
|
|
1421
|
+
if web_result.url_alive:
|
|
1422
|
+
c.api_match = "web_verified"
|
|
1423
|
+
c.api_source = "web"
|
|
1424
|
+
c.api_data = {
|
|
1425
|
+
"source": "web",
|
|
1426
|
+
"url": resolved_url,
|
|
1427
|
+
"status_code": web_result.status_code,
|
|
1428
|
+
"content_type": web_result.content_type,
|
|
1429
|
+
"title": web_result.title,
|
|
1430
|
+
}
|
|
1431
|
+
if web_result.local_path:
|
|
1432
|
+
c.local_status = "md"
|
|
1433
|
+
c.local_path = str(references_dir / web_result.local_path)
|
|
1434
|
+
c.issues.append(f"Web content saved: {web_result.local_path}")
|
|
1435
|
+
else:
|
|
1436
|
+
c.issues.append(
|
|
1437
|
+
f"URL alive but content extraction failed: {web_result.error or 'unknown'}"
|
|
1438
|
+
)
|
|
1439
|
+
else:
|
|
1440
|
+
c.api_match = "web_dead"
|
|
1441
|
+
c.api_source = "web"
|
|
1442
|
+
c.api_data = {"source": "web", "url": resolved_url}
|
|
1443
|
+
if web_result.error and web_result.status_code == 0:
|
|
1444
|
+
# Connection/DNS/timeout error — include the reason, not just "0"
|
|
1445
|
+
c.issues.append(f"Dead URL ({web_result.error}): {resolved_url}")
|
|
1446
|
+
else:
|
|
1447
|
+
c.issues.append(f"Dead URL (HTTP {web_result.status_code}): {resolved_url}")
|
|
1448
|
+
|
|
1449
|
+
return c.api_data
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
async def _verify_academic(
|
|
1453
|
+
c: Citation,
|
|
1454
|
+
api: CitationAPI,
|
|
1455
|
+
references_dir: Path | None = None,
|
|
1456
|
+
) -> dict | None:
|
|
1457
|
+
"""Verify an academic citation against CrossRef, OpenAlex, S2.
|
|
1458
|
+
|
|
1459
|
+
When all academic APIs fail but the citation has a URL, verifies the URL
|
|
1460
|
+
is alive as a last resort before marking not_found.
|
|
1461
|
+
"""
|
|
1462
|
+
from sciwrite_lint.references.matching import compare_citation_detailed
|
|
1463
|
+
|
|
1464
|
+
result = await api.lookup(c)
|
|
1465
|
+
if result and not result.get("error"):
|
|
1466
|
+
c.api_data = result
|
|
1467
|
+
c.api_source = result.get("source", "")
|
|
1468
|
+
c.issues.extend(compare_citation_detailed(c, result))
|
|
1469
|
+
c.issues.extend(await cross_validate_ids(c, result, api._config, api._client))
|
|
1470
|
+
c.api_match = (
|
|
1471
|
+
"mismatch" if any("mismatch" in i.lower() for i in c.issues) else "verified"
|
|
1472
|
+
)
|
|
1473
|
+
elif c.url and references_dir is not None:
|
|
1474
|
+
# All academic APIs failed but the citation has a URL —
|
|
1475
|
+
# verify URL liveness as last resort before marking T3.
|
|
1476
|
+
logger.debug("Academic APIs failed for '{}'; trying URL: {}", c.key, c.url)
|
|
1477
|
+
result = await _verify_web_resource(c, api._client, references_dir)
|
|
1478
|
+
else:
|
|
1479
|
+
c.api_match = "not_found"
|
|
1480
|
+
c.issues.append(
|
|
1481
|
+
"Not found in CrossRef, OpenAlex, Semantic Scholar, Open Library, or Library of Congress"
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
return result
|