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
@@ -0,0 +1,851 @@
1
+ """Full-text acquisition: download PDFs from open-access sources.
2
+
3
+ Downloads go to references/. Sources are tried in priority order — trusted,
4
+ validated sources first; less reliable sources last:
5
+
6
+ 1. arXiv (direct PDF, most reliable)
7
+ 2. Semantic Scholar openAccessPdf (URL from verify stage)
8
+ 3. OA URL from OpenAlex (often arXiv or publisher open-access)
9
+ 4. PubMed Central (NIH-funded, trusted; DOI→PMCID lookup if needed)
10
+ 5. Europe PMC (broader than PMC, includes preprints)
11
+ 6. Unpaywall (legal OA copy aggregator)
12
+ 7. bioRxiv/medRxiv (preprint servers, DOI prefix 10.1101/)
13
+ 8. CORE (institutional repos — least reliable, can return corrupt files)
14
+
15
+ All downloads are validated: minimum 5 KB size + PDF magic header check.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pydantic import BaseModel
21
+ from pathlib import Path
22
+ from collections.abc import Awaitable
23
+ from typing import Any
24
+
25
+ import httpx
26
+ from loguru import logger
27
+
28
+ from sciwrite_lint._network import (
29
+ ResponseTooLarge,
30
+ clean_and_validate_doi as _clean_and_validate_doi,
31
+ is_valid_arxiv_id,
32
+ ssrf_safe_client,
33
+ stream_with_limit,
34
+ )
35
+ from sciwrite_lint.config import LintConfig
36
+ from sciwrite_lint.rate_limiter import (
37
+ MonotonicRateLimiter,
38
+ rate_limited_get,
39
+ retry_on_transient,
40
+ )
41
+
42
+ _CORE_BASE = "https://api.core.ac.uk/v3"
43
+ _UNPAYWALL_BASE = "https://api.unpaywall.org/v2"
44
+ _PMC_ID_CONVERTER = "https://www.ncbi.nlm.nih.gov/pmc/utils/idconv/v1.0/"
45
+ _EUROPEPMC_BASE = "https://www.ebi.ac.uk/europepmc/webservices/rest"
46
+
47
+ _MIN_PDF_SIZE = 5000 # bytes — reject anything smaller
48
+ _MAX_PDF_SIZE = 100 * 1024 * 1024 # 100 MB — reject downloads larger than this
49
+
50
+
51
+ def _is_valid_pdf(data: bytes) -> bool:
52
+ """Check if bytes start with PDF magic header and meet min/max size."""
53
+ return _MIN_PDF_SIZE <= len(data) <= _MAX_PDF_SIZE and data[:5] == b"%PDF-"
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # API key helpers (read from ~/.sciwrite-lint/)
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def _read_key_file(name: str) -> str | None:
62
+ """Read an API key from ~/.sciwrite-lint/{name} if it exists."""
63
+ import stat
64
+
65
+ key_path = Path.home() / ".sciwrite-lint" / name
66
+ if key_path.exists():
67
+ # Warn if the key file is readable by group or others
68
+ mode = key_path.stat().st_mode
69
+ if mode & (stat.S_IRGRP | stat.S_IROTH):
70
+ logger.warning(
71
+ "API key file {} is readable by other users (mode {:o}). "
72
+ "Run: chmod 600 {}",
73
+ key_path,
74
+ stat.S_IMODE(mode),
75
+ key_path,
76
+ )
77
+ text = key_path.read_text().strip()
78
+ return text if text else None
79
+ return None
80
+
81
+
82
+ def _read_core_key() -> str | None:
83
+ """Read CORE API key from ~/.sciwrite-lint/core_api_key."""
84
+ return _read_key_file("core_api_key")
85
+
86
+
87
+ def _read_ncbi_key() -> str | None:
88
+ """Read NCBI API key from ~/.sciwrite-lint/ncbi_api_key.
89
+
90
+ With an NCBI API key, PMC rate limit increases from 3 to 10 req/s.
91
+ Get one at: https://www.ncbi.nlm.nih.gov/account/settings/
92
+ """
93
+ return _read_key_file("ncbi_api_key")
94
+
95
+
96
+ def _read_s2_key() -> str | None:
97
+ """Read Semantic Scholar API key from ~/.sciwrite-lint/s2_api_key.
98
+
99
+ With an S2 API key, rate limit increases from 1 to 100 req/s.
100
+ Request one at: https://www.semanticscholar.org/product/api#api-key
101
+ """
102
+ return _read_key_file("s2_api_key")
103
+
104
+
105
+ # Rate limiters: per-API, not per-download.
106
+ # PMC rate depends on whether NCBI API key is present (~/.sciwrite-lint/ncbi_api_key).
107
+ _core_limiter = MonotonicRateLimiter(1, 1.0)
108
+ _unpaywall_limiter = MonotonicRateLimiter(1, 0.1)
109
+ _pmc_limiter = MonotonicRateLimiter(
110
+ 10 if _read_ncbi_key() else 3,
111
+ 1.0,
112
+ )
113
+ _europepmc_limiter = MonotonicRateLimiter(10, 1.0)
114
+
115
+
116
+ class AcquisitionResult(BaseModel):
117
+ """Result of a full-text acquisition attempt."""
118
+
119
+ found: bool
120
+ source: str = ""
121
+ local_path: str | None = None
122
+ url: str | None = None
123
+ abstract: str | None = None
124
+ reason: str = ""
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Direct PDF download helper
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ async def _download_pdf(
133
+ url: str,
134
+ dest: Path,
135
+ references_dir: Path,
136
+ source: str,
137
+ timeout: float = 30.0,
138
+ expected_title: str = "",
139
+ title_threshold: float = 0.65,
140
+ ) -> AcquisitionResult:
141
+ """Download a PDF from URL, validate, and save. Reuse cached file.
142
+
143
+ When *expected_title* is provided the downloaded PDF's title is extracted
144
+ via GROBID's processHeaderDocument endpoint and fuzzy-matched. A score
145
+ below *title_threshold* rejects the PDF so that wrong-paper downloads
146
+ from APIs (e.g. CrossRef returning an unrelated DOI) are caught early.
147
+ Threshold 0.65 cleanly separates correct matches (>0.85) from
148
+ wrong-paper downloads (<0.60 in observed cases).
149
+ """
150
+ if dest.exists() and _is_valid_pdf(dest.read_bytes()):
151
+ # Validate cached file against expected title
152
+ if expected_title and not await _check_pdf_title(
153
+ dest, expected_title, title_threshold
154
+ ):
155
+ logger.warning(
156
+ "Cached PDF {} failed title check — removing",
157
+ dest.name,
158
+ )
159
+ dest.unlink()
160
+ else:
161
+ return AcquisitionResult(
162
+ found=True,
163
+ source=source,
164
+ local_path=str(dest.relative_to(references_dir)),
165
+ )
166
+ try:
167
+ async with ssrf_safe_client(timeout=timeout) as client:
168
+ resp = await retry_on_transient(
169
+ lambda: stream_with_limit(client, url, _MAX_PDF_SIZE),
170
+ label=f"PDF download ({source})",
171
+ )
172
+ if resp.status_code == 200 and _is_valid_pdf(resp.content):
173
+ references_dir.mkdir(parents=True, exist_ok=True)
174
+ dest.write_bytes(resp.content)
175
+ if expected_title and not await _check_pdf_title(
176
+ dest, expected_title, title_threshold
177
+ ):
178
+ logger.warning(
179
+ "Downloaded PDF from {} failed title check — "
180
+ "expected '{}', rejecting",
181
+ source,
182
+ expected_title[:60],
183
+ )
184
+ dest.unlink()
185
+ return AcquisitionResult(
186
+ found=False,
187
+ source=source,
188
+ url=url,
189
+ reason=f"title mismatch from {source}",
190
+ )
191
+ return AcquisitionResult(
192
+ found=True,
193
+ source=source,
194
+ local_path=str(dest.relative_to(references_dir)),
195
+ )
196
+ except ResponseTooLarge as e:
197
+ logger.debug("PDF too large, skipping {}: {}", url, e)
198
+ return AcquisitionResult(
199
+ found=False,
200
+ source=source,
201
+ url=url,
202
+ reason=f"too large ({source})",
203
+ )
204
+ except httpx.HTTPError as e:
205
+ logger.debug("PDF download failed for {}: {}", url, e)
206
+ return AcquisitionResult(
207
+ found=False,
208
+ source=source,
209
+ url=url,
210
+ reason=f"{type(e).__name__} from {source}",
211
+ )
212
+ return AcquisitionResult(found=False, source=source, url=url)
213
+
214
+
215
+ async def _check_pdf_title(
216
+ pdf_path: Path,
217
+ expected_title: str,
218
+ threshold: float = 0.65,
219
+ ) -> bool:
220
+ """Return True if the PDF's title is close enough to *expected_title*.
221
+
222
+ Uses GROBID's processHeaderDocument endpoint for title extraction.
223
+ """
224
+ if not expected_title:
225
+ return True # nothing to compare against
226
+
227
+ from sciwrite_lint.pdf.grobid import extract_title_from_header
228
+ from sciwrite_lint.pdf.pdf_download import _title_similarity
229
+
230
+ grobid_title = await extract_title_from_header(pdf_path)
231
+ if not grobid_title:
232
+ # No title in header — likely non-formal document (news, guide, etc.).
233
+ # Accept it; formal/non-formal classification happens after full parse.
234
+ logger.debug(
235
+ "GROBID could not extract title from {} — accepting "
236
+ "(formal classification deferred to parse stage)",
237
+ pdf_path.name,
238
+ )
239
+ return True
240
+
241
+ score = _title_similarity(expected_title, grobid_title)
242
+ if score >= threshold:
243
+ return True
244
+
245
+ logger.warning(
246
+ "Title mismatch (score={:.2f}): expected '{}', got '{}'",
247
+ score,
248
+ expected_title[:60],
249
+ grobid_title[:60],
250
+ )
251
+ return False
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # arXiv
256
+ # ---------------------------------------------------------------------------
257
+
258
+
259
+ async def download_arxiv_pdf(
260
+ arxiv_id: str,
261
+ key: str,
262
+ references_dir: Path,
263
+ expected_title: str = "",
264
+ ) -> AcquisitionResult:
265
+ """Download PDF from arXiv."""
266
+ if not arxiv_id:
267
+ return AcquisitionResult(found=False)
268
+ clean_id = arxiv_id.strip()
269
+ if not is_valid_arxiv_id(clean_id):
270
+ logger.debug("Invalid arXiv ID format, skipping: {}", clean_id[:40])
271
+ return AcquisitionResult(found=False)
272
+ url = f"https://arxiv.org/pdf/{clean_id}.pdf"
273
+ dest = references_dir / f"{key}_arxiv.pdf"
274
+ return await _download_pdf(
275
+ url, dest, references_dir, "arxiv", expected_title=expected_title
276
+ )
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Semantic Scholar openAccessPdf
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ async def download_s2_pdf(
285
+ s2_pdf_url: str,
286
+ key: str,
287
+ references_dir: Path,
288
+ expected_title: str = "",
289
+ expected_authors: list[str] | None = None,
290
+ ) -> AcquisitionResult:
291
+ """Download PDF from Semantic Scholar openAccessPdf URL."""
292
+ if not s2_pdf_url:
293
+ return AcquisitionResult(found=False)
294
+ dest = references_dir / f"{key}_s2.pdf"
295
+ return await _download_pdf(
296
+ s2_pdf_url,
297
+ dest,
298
+ references_dir,
299
+ "semantic_scholar",
300
+ expected_title=expected_title,
301
+ )
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # PubMed Central (PMC)
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ async def lookup_pmc(doi: str) -> str | None:
310
+ """Convert DOI to PMCID via NCBI ID Converter API. Returns PMCID or None."""
311
+ if not doi:
312
+ return None
313
+ clean_doi = _clean_and_validate_doi(doi)
314
+ if not clean_doi:
315
+ return None
316
+ params: dict[str, str] = {
317
+ "ids": clean_doi,
318
+ "format": "json",
319
+ "tool": "sciwrite-lint",
320
+ }
321
+ ncbi_key = _read_ncbi_key()
322
+ if ncbi_key:
323
+ params["api_key"] = ncbi_key
324
+ try:
325
+ resp = await rate_limited_get(
326
+ _pmc_limiter,
327
+ _PMC_ID_CONVERTER,
328
+ params=params,
329
+ timeout=5.0,
330
+ label="PMC ID converter",
331
+ service="pmc",
332
+ )
333
+ if resp.status_code != 200:
334
+ return None
335
+ records = resp.json().get("records") or []
336
+ for rec in records:
337
+ pmcid = rec.get("pmcid")
338
+ if pmcid:
339
+ return pmcid
340
+ except httpx.HTTPError as e:
341
+ logger.debug("PMC ID converter failed for DOI {}: {}", doi, e)
342
+ return None
343
+
344
+
345
+ async def download_pmc_pdf(
346
+ pmcid: str,
347
+ key: str,
348
+ references_dir: Path,
349
+ expected_title: str = "",
350
+ ) -> AcquisitionResult:
351
+ """Download PDF from PubMed Central."""
352
+ if not pmcid:
353
+ return AcquisitionResult(found=False)
354
+ url = f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmcid}/pdf/"
355
+ dest = references_dir / f"{key}_pmc.pdf"
356
+ return await _download_pdf(
357
+ url, dest, references_dir, "pmc", expected_title=expected_title
358
+ )
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # Europe PMC
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ async def lookup_europepmc(
367
+ doi: str,
368
+ pmcid: str | None = None,
369
+ ) -> str | None:
370
+ """Look up paper on Europe PMC. Returns PDF URL or None."""
371
+ if pmcid:
372
+ return (
373
+ f"https://europepmc.org/backend/ptpmcrender.fcgi?accid={pmcid}&blobtype=pdf"
374
+ )
375
+
376
+ if not doi:
377
+ return None
378
+ clean_doi = _clean_and_validate_doi(doi)
379
+ if not clean_doi:
380
+ return None
381
+
382
+ try:
383
+ resp = await rate_limited_get(
384
+ _europepmc_limiter,
385
+ f"{_EUROPEPMC_BASE}/search",
386
+ params={
387
+ "query": f"DOI:{clean_doi}",
388
+ "format": "json",
389
+ "resultType": "core",
390
+ },
391
+ label="Europe PMC",
392
+ service="europepmc",
393
+ )
394
+ if resp.status_code != 200:
395
+ return None
396
+ results = (resp.json().get("resultList") or {}).get("result") or []
397
+ for result in results:
398
+ for url_info in result.get("fullTextUrlList", {}).get("fullTextUrl", []):
399
+ if url_info.get("documentStyle") == "pdf":
400
+ return url_info.get("url")
401
+ # If has PMCID, construct direct URL
402
+ epmc_pmcid = result.get("pmcid")
403
+ if epmc_pmcid:
404
+ return f"https://europepmc.org/backend/ptpmcrender.fcgi?accid={epmc_pmcid}&blobtype=pdf"
405
+ except httpx.HTTPError as e:
406
+ logger.debug("Europe PMC lookup failed for DOI {}: {}", doi, e)
407
+ return None
408
+
409
+
410
+ async def download_europepmc_pdf(
411
+ pdf_url: str,
412
+ key: str,
413
+ references_dir: Path,
414
+ expected_title: str = "",
415
+ ) -> AcquisitionResult:
416
+ """Download PDF from Europe PMC."""
417
+ if not pdf_url:
418
+ return AcquisitionResult(found=False)
419
+ dest = references_dir / f"{key}_europepmc.pdf"
420
+ return await _download_pdf(
421
+ pdf_url, dest, references_dir, "europepmc", expected_title=expected_title
422
+ )
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # bioRxiv / medRxiv
427
+ # ---------------------------------------------------------------------------
428
+
429
+
430
+ async def download_biorxiv_pdf(
431
+ doi: str,
432
+ key: str,
433
+ references_dir: Path,
434
+ expected_title: str = "",
435
+ ) -> AcquisitionResult:
436
+ """Download from bioRxiv/medRxiv if DOI matches 10.1101/ prefix."""
437
+ if not doi:
438
+ return AcquisitionResult(found=False)
439
+ clean_doi = _clean_and_validate_doi(doi)
440
+ if not clean_doi or not clean_doi.startswith("10.1101/"):
441
+ return AcquisitionResult(found=False)
442
+
443
+ # bioRxiv and medRxiv both serve PDFs at this pattern
444
+ url = f"https://www.biorxiv.org/content/{clean_doi}v1.full.pdf"
445
+ dest = references_dir / f"{key}_biorxiv.pdf"
446
+ result = await _download_pdf(
447
+ url, dest, references_dir, "biorxiv", expected_title=expected_title
448
+ )
449
+ if result.found:
450
+ return result
451
+
452
+ # medRxiv uses the same DOI prefix but different domain
453
+ url = f"https://www.medrxiv.org/content/{clean_doi}v1.full.pdf"
454
+ dest = references_dir / f"{key}_medrxiv.pdf"
455
+ return await _download_pdf(
456
+ url, dest, references_dir, "medrxiv", expected_title=expected_title
457
+ )
458
+
459
+
460
+ # ---------------------------------------------------------------------------
461
+ # CORE
462
+ # ---------------------------------------------------------------------------
463
+
464
+
465
+ async def search_core(
466
+ doi: str,
467
+ interval: float = 1.0,
468
+ ) -> dict[str, Any] | None:
469
+ """Look up a paper by DOI via CORE API."""
470
+ if not doi:
471
+ return None
472
+
473
+ clean_doi = _clean_and_validate_doi(doi)
474
+ if not clean_doi:
475
+ return None
476
+ core_key = _read_core_key()
477
+ headers: dict[str, str] = {}
478
+ if core_key:
479
+ headers["Authorization"] = f"Bearer {core_key}"
480
+
481
+ try:
482
+ resp = await rate_limited_get(
483
+ _core_limiter,
484
+ f"{_CORE_BASE}/search/works/",
485
+ params={"q": f'doi:"{clean_doi}"', "limit": "1"},
486
+ headers=headers,
487
+ timeout=15.0,
488
+ label="CORE",
489
+ service="core",
490
+ )
491
+ if resp.status_code in (401, 403):
492
+ return None
493
+ if resp.status_code != 200:
494
+ return None
495
+ data = resp.json()
496
+
497
+ results = data.get("results", [])
498
+ if not results:
499
+ return None
500
+
501
+ work = results[0]
502
+ for candidate in results:
503
+ if candidate.get("abstract"):
504
+ work = candidate
505
+ break
506
+
507
+ abstract = work.get("abstract")
508
+ if not abstract:
509
+ return None
510
+
511
+ authors = [
512
+ a.get("name", "") for a in (work.get("authors") or []) if a.get("name")
513
+ ]
514
+
515
+ return {
516
+ "abstract": abstract,
517
+ "download_url": work.get("downloadUrl"),
518
+ "authors": authors,
519
+ "year": work.get("yearPublished"),
520
+ "core_id": work.get("id"),
521
+ "source": "CORE",
522
+ }
523
+ except httpx.HTTPError as e:
524
+ logger.debug("CORE API lookup failed for DOI {}: {}", doi, e)
525
+ return None
526
+
527
+
528
+ async def download_core_pdf(
529
+ download_url: str,
530
+ key: str,
531
+ references_dir: Path,
532
+ expected_title: str = "",
533
+ ) -> AcquisitionResult:
534
+ """Download full text from CORE repository URL."""
535
+ if not download_url:
536
+ return AcquisitionResult(found=False)
537
+ dest = references_dir / f"{key}_core.pdf"
538
+ return await _download_pdf(
539
+ download_url, dest, references_dir, "core", expected_title=expected_title
540
+ )
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Unpaywall
545
+ # ---------------------------------------------------------------------------
546
+
547
+
548
+ async def lookup_unpaywall(
549
+ doi: str,
550
+ polite_email: str = "",
551
+ interval: float = 0.1,
552
+ ) -> dict[str, Any] | None:
553
+ """Look up DOI on Unpaywall for legal OA copy."""
554
+ if not polite_email or not doi:
555
+ return None
556
+
557
+ clean_doi = _clean_and_validate_doi(doi)
558
+ if not clean_doi:
559
+ return None
560
+
561
+ try:
562
+ resp = await rate_limited_get(
563
+ _unpaywall_limiter,
564
+ f"{_UNPAYWALL_BASE}/{clean_doi}",
565
+ params={"email": polite_email},
566
+ label="Unpaywall",
567
+ service="unpaywall",
568
+ )
569
+ if resp.status_code == 404:
570
+ return None
571
+ resp.raise_for_status()
572
+ data = resp.json()
573
+
574
+ if not data.get("is_oa"):
575
+ return None
576
+
577
+ best_oa = data.get("best_oa_location") or {}
578
+ oa_url = best_oa.get("url_for_landing_page") or best_oa.get("url")
579
+ pdf_url = best_oa.get("url_for_pdf")
580
+
581
+ if not oa_url and not pdf_url:
582
+ return None
583
+
584
+ return {
585
+ "oa_url": oa_url,
586
+ "pdf_url": pdf_url,
587
+ "is_oa": True,
588
+ "source": "Unpaywall",
589
+ }
590
+ except httpx.HTTPError as e:
591
+ logger.debug("Unpaywall lookup failed for DOI {}: {}", doi, e)
592
+ return None
593
+
594
+
595
+ # ---------------------------------------------------------------------------
596
+ # Orchestrator
597
+ # ---------------------------------------------------------------------------
598
+
599
+
600
+ async def acquire_fulltext(
601
+ key: str,
602
+ references_dir: Path,
603
+ config: LintConfig | None = None,
604
+ doi: str | None = None,
605
+ arxiv_id: str | None = None,
606
+ oa_url: str | None = None,
607
+ s2_pdf_url: str | None = None,
608
+ pmcid: str | None = None,
609
+ expected_title: str = "",
610
+ expected_authors: list[str] | None = None,
611
+ progress: bool = True,
612
+ ) -> AcquisitionResult:
613
+ """Try to acquire full text for a citation.
614
+
615
+ Sources are tried in priority order: arXiv → S2 PDF → OA URL → PMC →
616
+ Europe PMC → Unpaywall → bioRxiv/medRxiv → CORE.
617
+ Trusted sources first; CORE is last (least reliable).
618
+ """
619
+ config = config or LintConfig()
620
+ failed_sources: list[str] = []
621
+
622
+ async def _try_source(
623
+ label: str,
624
+ coro: Awaitable[AcquisitionResult],
625
+ ) -> AcquisitionResult | None:
626
+ """Try a download source, print progress, return result if found."""
627
+ if progress:
628
+ print(f" Trying {label}...", end="", flush=True)
629
+ result = await coro
630
+ if result.found:
631
+ if progress:
632
+ print(" downloaded")
633
+ return result
634
+ if progress:
635
+ print(" not available")
636
+ if result.reason:
637
+ failed_sources.append(result.reason)
638
+ else:
639
+ failed_sources.append(f"{label}: not available")
640
+ return None
641
+
642
+ # 1. arXiv (most reliable)
643
+ if arxiv_id:
644
+ result = await _try_source(
645
+ f"arXiv ({arxiv_id})",
646
+ download_arxiv_pdf(
647
+ arxiv_id, key, references_dir, expected_title=expected_title
648
+ ),
649
+ )
650
+ if result:
651
+ return result
652
+
653
+ # 2. Semantic Scholar openAccessPdf
654
+ if s2_pdf_url:
655
+ result = await _try_source(
656
+ "Semantic Scholar PDF",
657
+ download_s2_pdf(
658
+ s2_pdf_url, key, references_dir, expected_title, expected_authors
659
+ ),
660
+ )
661
+ if result:
662
+ return result
663
+
664
+ # 3. OA URL from OpenAlex (often arXiv or publisher)
665
+ if oa_url:
666
+ if progress:
667
+ print(" Trying OA URL...", end="", flush=True)
668
+ dl = await _try_smart_download(
669
+ oa_url, key, references_dir, expected_title, expected_authors, progress
670
+ )
671
+ if dl.found:
672
+ return dl
673
+ if dl.reason:
674
+ failed_sources.append(dl.reason)
675
+ else:
676
+ failed_sources.append("OA URL: not available")
677
+
678
+ # 4. PubMed Central
679
+ pmc_id = pmcid
680
+ if not pmc_id and doi:
681
+ if progress:
682
+ print(" Looking up PMC...", end="", flush=True)
683
+ pmc_id = await lookup_pmc(doi)
684
+ if progress:
685
+ print(f" {pmc_id}" if pmc_id else " not found")
686
+ if pmc_id:
687
+ result = await _try_source(
688
+ f"PMC ({pmc_id})",
689
+ download_pmc_pdf(
690
+ pmc_id, key, references_dir, expected_title=expected_title
691
+ ),
692
+ )
693
+ if result:
694
+ return result
695
+
696
+ # 5. Europe PMC
697
+ if doi or pmc_id:
698
+ if progress:
699
+ print(" Trying Europe PMC...", end="", flush=True)
700
+ epmc_url = await lookup_europepmc(doi or "", pmcid=pmc_id)
701
+ if epmc_url:
702
+ result = await download_europepmc_pdf(
703
+ epmc_url, key, references_dir, expected_title=expected_title
704
+ )
705
+ if result.found:
706
+ if progress:
707
+ print(" downloaded")
708
+ return result
709
+ if result.reason:
710
+ failed_sources.append(result.reason)
711
+ else:
712
+ failed_sources.append("Europe PMC: download failed")
713
+ else:
714
+ failed_sources.append("Europe PMC: no PDF URL found")
715
+ if progress:
716
+ print(" not found")
717
+
718
+ # 6. Unpaywall
719
+ uw_data = None
720
+ if doi:
721
+ if progress:
722
+ print(" Trying Unpaywall...", end="", flush=True)
723
+ uw_data = await lookup_unpaywall(
724
+ doi,
725
+ polite_email=config.polite_email,
726
+ interval=config.unpaywall_interval,
727
+ )
728
+ if uw_data:
729
+ url = uw_data.get("pdf_url") or uw_data.get("oa_url")
730
+ if url:
731
+ dl = await _try_smart_download(
732
+ url, key, references_dir, expected_title, expected_authors, progress
733
+ )
734
+ if dl.found:
735
+ return dl
736
+ if dl.reason:
737
+ failed_sources.append(dl.reason)
738
+ else:
739
+ failed_sources.append("Unpaywall: download failed")
740
+ else:
741
+ failed_sources.append("Unpaywall: no PDF URL in response")
742
+ else:
743
+ failed_sources.append("Unpaywall: not found")
744
+ if progress:
745
+ print(" not found")
746
+
747
+ # 7. bioRxiv / medRxiv (only for 10.1101/ DOIs)
748
+ if doi:
749
+ clean_doi = _clean_and_validate_doi(doi)
750
+ if clean_doi and clean_doi.startswith("10.1101/"):
751
+ result = await _try_source(
752
+ "bioRxiv/medRxiv",
753
+ download_biorxiv_pdf(
754
+ doi, key, references_dir, expected_title=expected_title
755
+ ),
756
+ )
757
+ if result:
758
+ return result
759
+
760
+ # 8. CORE (least reliable — can return corrupt files)
761
+ core_abstract = None
762
+ if doi:
763
+ if progress:
764
+ print(" Trying CORE...", end="", flush=True)
765
+ core_data = await search_core(doi, interval=config.core_interval)
766
+ if core_data:
767
+ download_url = core_data.get("download_url")
768
+ if download_url:
769
+ result = await download_core_pdf(
770
+ download_url, key, references_dir, expected_title=expected_title
771
+ )
772
+ if result.found:
773
+ if progress:
774
+ print(" downloaded")
775
+ result.abstract = core_data.get("abstract")
776
+ return result
777
+ if result.reason:
778
+ failed_sources.append(result.reason)
779
+ else:
780
+ failed_sources.append("CORE: download failed")
781
+ else:
782
+ failed_sources.append("CORE: no download URL")
783
+ core_abstract = core_data.get("abstract")
784
+ if progress:
785
+ print(" abstract only")
786
+ else:
787
+ failed_sources.append("CORE: not found")
788
+ if progress:
789
+ print(" not found")
790
+
791
+ # 9. Suggest manual download
792
+ combined_reason = (
793
+ "; ".join(failed_sources) if failed_sources else "no sources available"
794
+ )
795
+ best_url = None
796
+ if uw_data:
797
+ best_url = uw_data.get("pdf_url") or uw_data.get("oa_url")
798
+ if not best_url and oa_url:
799
+ best_url = oa_url
800
+ if best_url:
801
+ if progress:
802
+ print(f" Manual download needed: {best_url}")
803
+ return AcquisitionResult(
804
+ found=False,
805
+ source="manual",
806
+ url=best_url,
807
+ abstract=core_abstract,
808
+ reason=combined_reason,
809
+ )
810
+
811
+ return AcquisitionResult(
812
+ found=False, abstract=core_abstract, reason=combined_reason
813
+ )
814
+
815
+
816
+ async def _try_smart_download(
817
+ url: str,
818
+ key: str,
819
+ references_dir: Path,
820
+ expected_title: str,
821
+ expected_authors: list[str] | None,
822
+ progress: bool,
823
+ ) -> AcquisitionResult:
824
+ """Download PDF from URL, validate title, save if match."""
825
+ from sciwrite_lint.pdf.pdf_download import download_and_validate
826
+
827
+ if progress:
828
+ print(" downloading...", end="", flush=True)
829
+
830
+ dl = await download_and_validate(
831
+ url,
832
+ key,
833
+ references_dir,
834
+ expected_title=expected_title,
835
+ expected_authors=expected_authors,
836
+ )
837
+
838
+ if dl["found"]:
839
+ if progress:
840
+ score = dl.get("match_score", 0)
841
+ print(f" validated (title match: {score:.0%})")
842
+ return AcquisitionResult(
843
+ found=True,
844
+ source="download",
845
+ local_path=dl["local_path"],
846
+ )
847
+ else:
848
+ reason = dl.get("reason", "failed")
849
+ if progress:
850
+ print(f" {reason}")
851
+ return AcquisitionResult(found=False, source="download", url=url, reason=reason)