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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Smart PDF downloading with validation.
|
|
2
|
+
|
|
3
|
+
Downloads papers from OA URLs (Unpaywall, publisher sites). Handles:
|
|
4
|
+
- Direct PDF links (Content-Type: application/pdf)
|
|
5
|
+
- HTML pages with embedded/linked PDFs
|
|
6
|
+
- Validates downloaded PDF matches expected paper (title matching)
|
|
7
|
+
|
|
8
|
+
No LLM — just HTTP, HTML parsing, GROBID title extraction,
|
|
9
|
+
and fuzzy string matching.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import urljoin
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from sciwrite_lint._network import ResponseTooLarge, ssrf_safe_client, stream_with_limit
|
|
23
|
+
|
|
24
|
+
_MIN_PDF_SIZE = 5_000
|
|
25
|
+
_MAX_PDF_SIZE = 100 * 1024 * 1024 # 100 MB — reject downloads larger than this
|
|
26
|
+
_DEFAULT_USER_AGENT = "sciwrite-lint/0.1 (citation-verification)"
|
|
27
|
+
|
|
28
|
+
_SKIP_PATTERNS = re.compile(
|
|
29
|
+
r"(advertisement|banner|sidebar|nav|footer|header|cookie|consent|signup|login"
|
|
30
|
+
r"|newsletter|subscription|purchase|buy|cart|checkout)",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def download_and_validate(
|
|
36
|
+
url: str,
|
|
37
|
+
key: str,
|
|
38
|
+
references_dir: Path,
|
|
39
|
+
expected_title: str = "",
|
|
40
|
+
expected_authors: list[str] | None = None,
|
|
41
|
+
title_threshold: float = 0.65,
|
|
42
|
+
user_agent: str = _DEFAULT_USER_AGENT,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
"""Download a PDF from a URL and validate it matches the expected paper.
|
|
45
|
+
|
|
46
|
+
Returns dict with: found, local_path, source_url, pdf_title, match_score, reason.
|
|
47
|
+
"""
|
|
48
|
+
result = {
|
|
49
|
+
"found": False,
|
|
50
|
+
"local_path": None,
|
|
51
|
+
"source_url": url,
|
|
52
|
+
"pdf_title": "",
|
|
53
|
+
"match_score": 0.0,
|
|
54
|
+
"reason": "",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
async with ssrf_safe_client(
|
|
59
|
+
timeout=30.0,
|
|
60
|
+
headers={"User-Agent": user_agent},
|
|
61
|
+
) as client:
|
|
62
|
+
resp = await stream_with_limit(client, url, _MAX_PDF_SIZE)
|
|
63
|
+
if resp.status_code != 200:
|
|
64
|
+
result["reason"] = f"HTTP {resp.status_code}"
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
content_type = resp.headers.get("content-type", "").lower()
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
"application/pdf" in content_type
|
|
71
|
+
or "application/octet-stream" in content_type
|
|
72
|
+
or url.lower().endswith(".pdf")
|
|
73
|
+
):
|
|
74
|
+
return await _validate_and_save(
|
|
75
|
+
resp.content,
|
|
76
|
+
url,
|
|
77
|
+
key,
|
|
78
|
+
references_dir,
|
|
79
|
+
expected_title,
|
|
80
|
+
expected_authors,
|
|
81
|
+
title_threshold,
|
|
82
|
+
result,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if "text/html" in content_type:
|
|
86
|
+
pdf_url = _find_pdf_in_html(resp.text, url)
|
|
87
|
+
if pdf_url:
|
|
88
|
+
pdf_resp = await stream_with_limit(client, pdf_url, _MAX_PDF_SIZE)
|
|
89
|
+
if pdf_resp.status_code == 200:
|
|
90
|
+
return await _validate_and_save(
|
|
91
|
+
pdf_resp.content,
|
|
92
|
+
pdf_url,
|
|
93
|
+
key,
|
|
94
|
+
references_dir,
|
|
95
|
+
expected_title,
|
|
96
|
+
expected_authors,
|
|
97
|
+
title_threshold,
|
|
98
|
+
result,
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
result["reason"] = (
|
|
102
|
+
f"PDF link returned HTTP {pdf_resp.status_code}"
|
|
103
|
+
)
|
|
104
|
+
return result
|
|
105
|
+
else:
|
|
106
|
+
result["reason"] = "HTML page with no PDF links found"
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
result["reason"] = f"Unexpected content type: {content_type}"
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
except ResponseTooLarge as e:
|
|
113
|
+
result["reason"] = str(e)
|
|
114
|
+
return result
|
|
115
|
+
except httpx.TimeoutException:
|
|
116
|
+
result["reason"] = "Timeout"
|
|
117
|
+
return result
|
|
118
|
+
except Exception as e:
|
|
119
|
+
result["reason"] = str(e)[:200]
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _validate_and_save(
|
|
124
|
+
content: bytes,
|
|
125
|
+
source_url: str,
|
|
126
|
+
key: str,
|
|
127
|
+
references_dir: Path,
|
|
128
|
+
expected_title: str,
|
|
129
|
+
expected_authors: list[str] | None,
|
|
130
|
+
threshold: float,
|
|
131
|
+
result: dict,
|
|
132
|
+
) -> dict:
|
|
133
|
+
"""Validate PDF content and save if it matches."""
|
|
134
|
+
result["source_url"] = source_url
|
|
135
|
+
|
|
136
|
+
if len(content) < _MIN_PDF_SIZE:
|
|
137
|
+
result["reason"] = f"PDF too small ({len(content)} bytes) — likely error page"
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
if not content[:5] == b"%PDF-":
|
|
141
|
+
result["reason"] = "Not a valid PDF (bad magic bytes)"
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
|
145
|
+
tmp.write(content)
|
|
146
|
+
tmp_path = Path(tmp.name)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
if expected_title:
|
|
150
|
+
from sciwrite_lint.pdf.grobid import extract_title_from_header
|
|
151
|
+
|
|
152
|
+
grobid_title = await extract_title_from_header(tmp_path)
|
|
153
|
+
result["pdf_title"] = grobid_title
|
|
154
|
+
|
|
155
|
+
if grobid_title:
|
|
156
|
+
score = _title_similarity(expected_title, grobid_title)
|
|
157
|
+
result["match_score"] = score
|
|
158
|
+
if score < threshold:
|
|
159
|
+
result["reason"] = (
|
|
160
|
+
f"Title mismatch (score={score:.2f}): "
|
|
161
|
+
f"expected '{expected_title[:50]}', "
|
|
162
|
+
f"got '{grobid_title[:50]}'"
|
|
163
|
+
)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
references_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
dest = references_dir / f"{key}.pdf"
|
|
168
|
+
dest.write_bytes(content)
|
|
169
|
+
result["found"] = True
|
|
170
|
+
result["local_path"] = f"{key}.pdf"
|
|
171
|
+
result["reason"] = (
|
|
172
|
+
f"Downloaded and validated (score={result['match_score']:.2f})"
|
|
173
|
+
)
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
finally:
|
|
177
|
+
tmp_path.unlink(missing_ok=True)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _title_similarity(a: str, b: str) -> float:
|
|
181
|
+
"""Fuzzy title similarity with subtitle-variant handling.
|
|
182
|
+
|
|
183
|
+
Books often have subtitles in one source but not the other (e.g.
|
|
184
|
+
"Mind in Society" in bib vs "Mind in Society: The Development of
|
|
185
|
+
Higher Psychological Processes" from the PDF). Tries all combinations
|
|
186
|
+
of full title and main title (before colon) and returns the best score.
|
|
187
|
+
"""
|
|
188
|
+
a_low = a.lower().strip()
|
|
189
|
+
b_low = b.lower().strip()
|
|
190
|
+
a_variants = [a_low]
|
|
191
|
+
if ":" in a_low:
|
|
192
|
+
a_variants.append(a_low.split(":")[0].strip())
|
|
193
|
+
b_variants = [b_low]
|
|
194
|
+
if ":" in b_low:
|
|
195
|
+
b_variants.append(b_low.split(":")[0].strip())
|
|
196
|
+
|
|
197
|
+
return max(_fuzzy_score(av, bv) for av in a_variants for bv in b_variants)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fuzzy_score(a: str, b: str) -> float:
|
|
201
|
+
"""Raw fuzzy similarity between two lowercased strings."""
|
|
202
|
+
if not a or not b:
|
|
203
|
+
return 0.0
|
|
204
|
+
try:
|
|
205
|
+
from rapidfuzz import fuzz
|
|
206
|
+
|
|
207
|
+
return fuzz.ratio(a, b) / 100.0
|
|
208
|
+
except ImportError:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
wa = set(a.split())
|
|
212
|
+
wb = set(b.split())
|
|
213
|
+
if not wa or not wb:
|
|
214
|
+
return 0.0
|
|
215
|
+
return (2 * len(wa & wb)) / (len(wa) + len(wb))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _find_pdf_in_html(html: str, base_url: str) -> str | None:
|
|
219
|
+
"""Find the most likely PDF link in an HTML page."""
|
|
220
|
+
candidates: list[tuple[int, str]] = []
|
|
221
|
+
|
|
222
|
+
for pattern in [
|
|
223
|
+
r'<embed[^>]+src=["\']([^"\']+\.pdf[^"\']*)["\']',
|
|
224
|
+
r'<iframe[^>]+src=["\']([^"\']+\.pdf[^"\']*)["\']',
|
|
225
|
+
r'<object[^>]+data=["\']([^"\']+\.pdf[^"\']*)["\']',
|
|
226
|
+
]:
|
|
227
|
+
for match in re.finditer(pattern, html, re.IGNORECASE):
|
|
228
|
+
url = match.group(1)
|
|
229
|
+
if not _SKIP_PATTERNS.search(url):
|
|
230
|
+
candidates.append((0, urljoin(base_url, url)))
|
|
231
|
+
|
|
232
|
+
for match in re.finditer(
|
|
233
|
+
r'<a[^>]+href=["\']([^"\']+\.pdf[^"\']*)["\'][^>]*>(.*?)</a>',
|
|
234
|
+
html,
|
|
235
|
+
re.IGNORECASE | re.DOTALL,
|
|
236
|
+
):
|
|
237
|
+
url = match.group(1)
|
|
238
|
+
link_text = match.group(2).lower()
|
|
239
|
+
if _SKIP_PATTERNS.search(url) or _SKIP_PATTERNS.search(link_text):
|
|
240
|
+
continue
|
|
241
|
+
if any(kw in link_text for kw in ["download", "full text", "pdf", "full-text"]):
|
|
242
|
+
candidates.append((1, urljoin(base_url, url)))
|
|
243
|
+
else:
|
|
244
|
+
candidates.append((2, urljoin(base_url, url)))
|
|
245
|
+
|
|
246
|
+
meta = re.search(
|
|
247
|
+
r'<meta[^>]+url=(["\']?)([^"\'\s>]+\.pdf[^"\'\s>]*)\1',
|
|
248
|
+
html,
|
|
249
|
+
re.IGNORECASE,
|
|
250
|
+
)
|
|
251
|
+
if meta:
|
|
252
|
+
candidates.append((0, urljoin(base_url, meta.group(2))))
|
|
253
|
+
|
|
254
|
+
if not candidates:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
candidates.sort(key=lambda x: x[0])
|
|
258
|
+
return candidates[0][1]
|