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.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. 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