paperstack-cli 0.3.2__tar.gz → 0.4.0__tar.gz
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.
- paperstack_cli-0.3.2/README.md → paperstack_cli-0.4.0/PKG-INFO +36 -0
- paperstack_cli-0.3.2/PKG-INFO → paperstack_cli-0.4.0/README.md +14 -17
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/pyproject.toml +8 -1
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/cli.py +7 -4
- paperstack_cli-0.4.0/src/paperstack/content/arxiv_pdf.py +253 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/entrypoint.py +5 -0
- paperstack_cli-0.3.2/src/paperstack/content/arxiv_pdf.py +0 -103
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/.gitignore +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/LICENSE +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/app.js +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/entry.html +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/favicon.svg +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/index.html +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/style.css +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/vendor/marked.LICENSE +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/vendor/marked.min.js +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/__init__.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/arxiv.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/citations.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/__init__.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/arxiv_source.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/vendor/latexpand +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/vendor/latexpand.LICENSE +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/corpora.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/credentials.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/dblp_build.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/dblp_catalog.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/dblp_index.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/entry_types.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/metadata.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/semantic_scholar.py +0 -0
- {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/viewer.py +0 -0
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: paperstack-cli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Review, inspect, and retrieve research sources from one CLI
|
|
5
|
+
Project-URL: Repository, https://github.com/MilkClouds/paperstack
|
|
6
|
+
Project-URL: Issues, https://github.com/MilkClouds/paperstack/issues
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: filelock>=3.20
|
|
16
|
+
Requires-Dist: polars>=1.43
|
|
17
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
18
|
+
Requires-Dist: pyyaml>=6
|
|
19
|
+
Provides-Extra: pdf
|
|
20
|
+
Requires-Dist: pdf-inspector<2,>=1.17; extra == 'pdf'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
1
23
|
# Paperstack
|
|
2
24
|
|
|
25
|
+
[](https://github.com/MilkClouds/paperstack/actions/workflows/ci.yml) [](https://pypi.org/project/paperstack-cli/) [](https://pypi.org/project/paperstack-cli/) [](https://github.com/MilkClouds/paperstack/blob/main/LICENSE)
|
|
26
|
+
|
|
3
27
|
Paperstack is an open-source CLI for maintaining, querying, and publishing an independently authored research corpus.
|
|
4
28
|
The software and corpus are separate: Paperstack can be public while each corpus and its editorial judgments remain
|
|
5
29
|
under its maintainer's control.
|
|
@@ -21,6 +45,18 @@ PDF conversion is optional:
|
|
|
21
45
|
uv tool install 'paperstack-cli[pdf]'
|
|
22
46
|
```
|
|
23
47
|
|
|
48
|
+
The PDF extra uses `pdf-inspector` for native extraction and selective OCR.
|
|
49
|
+
Clean text PDFs need no external runtime, while routed OCR pages require the pinned PDFium and ONNX Runtime libraries described in the [OCR runtime guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md).
|
|
50
|
+
The first routed page downloads and verifies the pinned OCR model set unless an offline model directory is configured.
|
|
51
|
+
When the shared libraries are not on the platform search path, point `pdf-inspector` at the extracted files:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export PDFIUM_LIB_PATH=/absolute/path/to/libpdfium.so
|
|
55
|
+
export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The runtime guide lists the matching downloads and filenames for Linux, macOS, and Windows.
|
|
59
|
+
|
|
24
60
|
## AI agent skill
|
|
25
61
|
|
|
26
62
|
Paperstack ships an [Agent Skills](https://agentskills.io)-compatible skill for Codex, Claude Code, and other
|
|
@@ -1,22 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: paperstack-cli
|
|
3
|
-
Version: 0.3.2
|
|
4
|
-
Summary: Review, inspect, and retrieve research sources from one CLI
|
|
5
|
-
Project-URL: Repository, https://github.com/MilkClouds/paperstack
|
|
6
|
-
Project-URL: Issues, https://github.com/MilkClouds/paperstack/issues
|
|
7
|
-
License-Expression: Apache-2.0
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Requires-Python: >=3.11
|
|
10
|
-
Requires-Dist: filelock>=3.20
|
|
11
|
-
Requires-Dist: polars>=1.43
|
|
12
|
-
Requires-Dist: python-dotenv>=1.2.2
|
|
13
|
-
Requires-Dist: pyyaml>=6
|
|
14
|
-
Provides-Extra: pdf
|
|
15
|
-
Requires-Dist: pymupdf4llm>=0.0.17; extra == 'pdf'
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
|
|
18
1
|
# Paperstack
|
|
19
2
|
|
|
3
|
+
[](https://github.com/MilkClouds/paperstack/actions/workflows/ci.yml) [](https://pypi.org/project/paperstack-cli/) [](https://pypi.org/project/paperstack-cli/) [](https://github.com/MilkClouds/paperstack/blob/main/LICENSE)
|
|
4
|
+
|
|
20
5
|
Paperstack is an open-source CLI for maintaining, querying, and publishing an independently authored research corpus.
|
|
21
6
|
The software and corpus are separate: Paperstack can be public while each corpus and its editorial judgments remain
|
|
22
7
|
under its maintainer's control.
|
|
@@ -38,6 +23,18 @@ PDF conversion is optional:
|
|
|
38
23
|
uv tool install 'paperstack-cli[pdf]'
|
|
39
24
|
```
|
|
40
25
|
|
|
26
|
+
The PDF extra uses `pdf-inspector` for native extraction and selective OCR.
|
|
27
|
+
Clean text PDFs need no external runtime, while routed OCR pages require the pinned PDFium and ONNX Runtime libraries described in the [OCR runtime guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md).
|
|
28
|
+
The first routed page downloads and verifies the pinned OCR model set unless an offline model directory is configured.
|
|
29
|
+
When the shared libraries are not on the platform search path, point `pdf-inspector` at the extracted files:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export PDFIUM_LIB_PATH=/absolute/path/to/libpdfium.so
|
|
33
|
+
export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The runtime guide lists the matching downloads and filenames for Linux, macOS, and Windows.
|
|
37
|
+
|
|
41
38
|
## AI agent skill
|
|
42
39
|
|
|
43
40
|
Paperstack ships an [Agent Skills](https://agentskills.io)-compatible skill for Codex, Claude Code, and other
|
|
@@ -5,6 +5,13 @@ description = "Review, inspect, and retrieve research sources from one CLI"
|
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
7
7
|
requires-python = ">=3.11"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
10
|
+
"Programming Language :: Python :: 3.11",
|
|
11
|
+
"Programming Language :: Python :: 3.12",
|
|
12
|
+
"Programming Language :: Python :: 3.13",
|
|
13
|
+
"Programming Language :: Python :: 3.14",
|
|
14
|
+
]
|
|
8
15
|
dependencies = [
|
|
9
16
|
"filelock>=3.20",
|
|
10
17
|
"polars>=1.43",
|
|
@@ -13,7 +20,7 @@ dependencies = [
|
|
|
13
20
|
]
|
|
14
21
|
|
|
15
22
|
[project.optional-dependencies]
|
|
16
|
-
pdf = ["
|
|
23
|
+
pdf = ["pdf-inspector>=1.17,<2"]
|
|
17
24
|
|
|
18
25
|
[dependency-groups]
|
|
19
26
|
lint = ["ruff>=0.12"]
|
|
@@ -1004,10 +1004,13 @@ def _run_paper(a: argparse.Namespace) -> int:
|
|
|
1004
1004
|
from .content import arxiv_pdf
|
|
1005
1005
|
|
|
1006
1006
|
arxiv_pdf.CACHE_DIR = _paper_cache()
|
|
1007
|
-
cached_pdf = arxiv_pdf.
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1007
|
+
cached_pdf = arxiv_pdf._cached_conversion(
|
|
1008
|
+
arxiv_pdf.CACHE_DIR / ref.value,
|
|
1009
|
+
allow_native_fallback=offline,
|
|
1010
|
+
)
|
|
1011
|
+
if offline and cached_pdf is None:
|
|
1012
|
+
die(f"no usable cached PDF conversion for arxiv:{ref.value}")
|
|
1013
|
+
if not arxiv_pdf.convert(ref.value, allow_native_fallback=offline):
|
|
1011
1014
|
return 1
|
|
1012
1015
|
return 0
|
|
1013
1016
|
raise AssertionError(a.paper_cmd)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Convert arXiv PDF submissions to Markdown with selective OCR.
|
|
2
|
+
|
|
3
|
+
Used by `paperstack paper pdf` after the source fetcher reports no TeX.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
20
|
+
CACHE_DIR = Path(os.environ.get("PAPERSTACK_PAPERS_DIR", _CACHE_ROOT / "paperstack" / "papers"))
|
|
21
|
+
CONVERTER = "pdf-inspector"
|
|
22
|
+
MIN_MARKDOWN_CHARS = 100
|
|
23
|
+
OCR_RUNTIME_GUIDE = "https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fetch(url: str, timeout: int = 60) -> bytes | None:
|
|
27
|
+
req = urllib.request.Request(url, headers={"User-Agent": "paperstack/1.0 (+arxiv pdf fetch)"})
|
|
28
|
+
for attempt in range(3):
|
|
29
|
+
try:
|
|
30
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
31
|
+
return resp.read()
|
|
32
|
+
except urllib.error.HTTPError as e:
|
|
33
|
+
if e.code == 429:
|
|
34
|
+
time.sleep(15 * (attempt + 1))
|
|
35
|
+
continue
|
|
36
|
+
if attempt == 2:
|
|
37
|
+
return None
|
|
38
|
+
time.sleep(5)
|
|
39
|
+
except (urllib.error.URLError, OSError, TimeoutError):
|
|
40
|
+
if attempt == 2:
|
|
41
|
+
return None
|
|
42
|
+
time.sleep(5)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cached_conversion(d: Path, *, allow_native_fallback: bool = False) -> Path | None:
|
|
47
|
+
md_path = d / "paper.md"
|
|
48
|
+
meta_path = d / "meta.json"
|
|
49
|
+
if not md_path.is_file() or md_path.stat().st_size < MIN_MARKDOWN_CHARS or not meta_path.is_file():
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
markdown = md_path.read_bytes()
|
|
53
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
55
|
+
return None
|
|
56
|
+
if not isinstance(meta, dict):
|
|
57
|
+
return None
|
|
58
|
+
valid = (
|
|
59
|
+
meta.get("converter") == CONVERTER
|
|
60
|
+
and meta.get("bytes") == len(markdown)
|
|
61
|
+
and meta.get("sha256") == hashlib.sha256(markdown).hexdigest()
|
|
62
|
+
and (allow_native_fallback or meta.get("conversion_mode") != "native_fallback")
|
|
63
|
+
)
|
|
64
|
+
return md_path if valid else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _converter_version() -> str:
|
|
68
|
+
try:
|
|
69
|
+
return importlib.metadata.version(CONVERTER)
|
|
70
|
+
except importlib.metadata.PackageNotFoundError:
|
|
71
|
+
return "unknown"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ocr_reasons(items) -> list[dict]:
|
|
75
|
+
return [{"page": item.page, "reasons": list(item.reasons)} for item in items]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _page_provenance(page) -> dict:
|
|
79
|
+
provenance = page.provenance
|
|
80
|
+
model = provenance.ocr_model
|
|
81
|
+
return {
|
|
82
|
+
"page": page.page_number,
|
|
83
|
+
"source": provenance.source,
|
|
84
|
+
"ocr_model": None if model is None else {"name": model.name, "revision": model.revision},
|
|
85
|
+
"render_dpi": provenance.render_dpi,
|
|
86
|
+
"ocr_confidence": provenance.ocr_confidence,
|
|
87
|
+
"hosted_recommended": provenance.hosted_recommended,
|
|
88
|
+
"warnings": list(provenance.warnings),
|
|
89
|
+
"timings": {
|
|
90
|
+
"render_ms": provenance.timings.render_ms,
|
|
91
|
+
"ocr_ms": provenance.timings.ocr_ms,
|
|
92
|
+
"assembly_ms": provenance.timings.assembly_ms,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _base_meta(arxiv_id: str, url: str, markdown: str) -> dict:
|
|
98
|
+
encoded = markdown.encode("utf-8")
|
|
99
|
+
return {
|
|
100
|
+
"schema_version": 1,
|
|
101
|
+
"arxiv_id": arxiv_id,
|
|
102
|
+
"bytes": len(encoded),
|
|
103
|
+
"sha256": hashlib.sha256(encoded).hexdigest(),
|
|
104
|
+
"url": url,
|
|
105
|
+
"converter": CONVERTER,
|
|
106
|
+
"converter_version": _converter_version(),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ocr_meta(arxiv_id: str, url: str, markdown: str, result) -> dict:
|
|
111
|
+
meta = _base_meta(arxiv_id, url, markdown)
|
|
112
|
+
meta.update(
|
|
113
|
+
{
|
|
114
|
+
"conversion_mode": "selective_ocr",
|
|
115
|
+
"quality": "partial" if result.pages_recommending_hosted else "complete",
|
|
116
|
+
"page_count": result.page_count,
|
|
117
|
+
"pages_recommended_for_ocr": list(result.pages_recommended_for_ocr),
|
|
118
|
+
"pages_routed_to_ocr": list(result.pages_routed_to_ocr),
|
|
119
|
+
"pages_recommending_hosted": list(result.pages_recommending_hosted),
|
|
120
|
+
"ocr_reasons_by_page": _ocr_reasons(result.ocr_reasons_by_page),
|
|
121
|
+
"pages_with_tables": list(result.pages_with_tables),
|
|
122
|
+
"pages_with_columns": list(result.pages_with_columns),
|
|
123
|
+
"pages": [_page_provenance(page) for page in result.pages],
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
return meta
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _native_fallback_meta(arxiv_id: str, url: str, markdown: str, result, error: ValueError) -> dict:
|
|
130
|
+
meta = _ocr_meta(arxiv_id, url, markdown, result)
|
|
131
|
+
meta["conversion_mode"] = "native_fallback"
|
|
132
|
+
meta["quality"] = "partial"
|
|
133
|
+
meta["ocr_error"] = str(error)
|
|
134
|
+
return meta
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _usable(markdown: str | None) -> bool:
|
|
138
|
+
return markdown is not None and len(markdown.strip()) >= MIN_MARKDOWN_CHARS
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_pdf(path: Path) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
with path.open("rb") as stream:
|
|
144
|
+
return stream.read(4) == b"%PDF"
|
|
145
|
+
except OSError:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _download_pdf(url: str, pdf_path: Path) -> bool:
|
|
150
|
+
raw = _fetch(url)
|
|
151
|
+
if raw is None:
|
|
152
|
+
print(f"could not fetch {url}", file=sys.stderr)
|
|
153
|
+
return False
|
|
154
|
+
if not raw.startswith(b"%PDF"):
|
|
155
|
+
print(f"{url} did not return a PDF", file=sys.stderr)
|
|
156
|
+
return False
|
|
157
|
+
descriptor, staged = tempfile.mkstemp(prefix=f".{pdf_path.name}-", suffix=".part", dir=pdf_path.parent)
|
|
158
|
+
try:
|
|
159
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
160
|
+
stream.write(raw)
|
|
161
|
+
os.replace(staged, pdf_path)
|
|
162
|
+
finally:
|
|
163
|
+
try:
|
|
164
|
+
os.unlink(staged)
|
|
165
|
+
except FileNotFoundError:
|
|
166
|
+
pass
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def convert(arxiv_id: str, *, allow_native_fallback: bool = False) -> bool:
|
|
171
|
+
d = CACHE_DIR / arxiv_id
|
|
172
|
+
cached = _cached_conversion(d, allow_native_fallback=allow_native_fallback)
|
|
173
|
+
if cached is not None:
|
|
174
|
+
print(f"{arxiv_id}: cached at {cached}")
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
import pdf_inspector
|
|
179
|
+
except ImportError:
|
|
180
|
+
print(
|
|
181
|
+
"PDF conversion needs `paperstack-cli[pdf]`; reinstall with "
|
|
182
|
+
"`uv tool install --force 'paperstack-cli[pdf]'`.",
|
|
183
|
+
file=sys.stderr,
|
|
184
|
+
)
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
url = f"https://arxiv.org/pdf/{arxiv_id}"
|
|
188
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
pdf_path = d / "paper.pdf"
|
|
190
|
+
reused_cached_pdf = _is_pdf(pdf_path)
|
|
191
|
+
if not reused_cached_pdf and not _download_pdf(url, pdf_path):
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
while True:
|
|
195
|
+
ocr_error = None
|
|
196
|
+
try:
|
|
197
|
+
result = pdf_inspector.process_pdf_with_ocr(str(pdf_path), mode="auto")
|
|
198
|
+
parsed = result
|
|
199
|
+
md = result.markdown
|
|
200
|
+
meta = _ocr_meta(arxiv_id, url, md, result)
|
|
201
|
+
except ValueError as error:
|
|
202
|
+
ocr_error = error
|
|
203
|
+
try:
|
|
204
|
+
native = pdf_inspector.process_pdf_with_ocr(str(pdf_path), mode="off")
|
|
205
|
+
except ValueError:
|
|
206
|
+
native = None
|
|
207
|
+
parsed = native
|
|
208
|
+
if native is None:
|
|
209
|
+
md = None
|
|
210
|
+
else:
|
|
211
|
+
md = native.markdown
|
|
212
|
+
if _usable(md):
|
|
213
|
+
meta = _native_fallback_meta(arxiv_id, url, md, native, error)
|
|
214
|
+
|
|
215
|
+
if _usable(md):
|
|
216
|
+
if ocr_error is not None:
|
|
217
|
+
print(f"{arxiv_id}: OCR unavailable; cached {meta['quality']} native extraction", file=sys.stderr)
|
|
218
|
+
break
|
|
219
|
+
needs_ocr = parsed is not None and bool(parsed.pages_recommended_for_ocr)
|
|
220
|
+
if reused_cached_pdf and not needs_ocr and _download_pdf(url, pdf_path):
|
|
221
|
+
reused_cached_pdf = False
|
|
222
|
+
continue
|
|
223
|
+
if ocr_error is not None:
|
|
224
|
+
print(f"{arxiv_id}: PDF conversion failed: {ocr_error}", file=sys.stderr)
|
|
225
|
+
if needs_ocr:
|
|
226
|
+
print(
|
|
227
|
+
"OCR needs PDFium and ONNX Runtime; set PDFIUM_LIB_PATH and ORT_DYLIB_PATH.",
|
|
228
|
+
file=sys.stderr,
|
|
229
|
+
)
|
|
230
|
+
print(f"OCR runtime setup: {OCR_RUNTIME_GUIDE}", file=sys.stderr)
|
|
231
|
+
return False
|
|
232
|
+
chars = len(md.strip()) if md else 0
|
|
233
|
+
print(f"{arxiv_id}: converted to only {chars} chars, treat as a failure", file=sys.stderr)
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
md_path = d / "paper.md"
|
|
237
|
+
md_path.write_bytes(md.encode("utf-8"))
|
|
238
|
+
(d / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
239
|
+
print(f"{arxiv_id}: {len(md)} chars -> {md_path}")
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main(argv: list[str]) -> None:
|
|
244
|
+
if not argv:
|
|
245
|
+
sys.exit("pass one or more arXiv ids")
|
|
246
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
failures = [a for a in argv if not convert(a)]
|
|
248
|
+
if failures:
|
|
249
|
+
sys.exit(f"failed: {' '.join(failures)}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
main(sys.argv[1:])
|
|
@@ -9,6 +9,8 @@ from dotenv import find_dotenv, load_dotenv
|
|
|
9
9
|
|
|
10
10
|
from . import credentials
|
|
11
11
|
|
|
12
|
+
_EXPORTED_ONLY = ("PDFIUM_LIB_PATH", "ORT_DYLIB_PATH")
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
def load_environment() -> None:
|
|
14
16
|
"""Load the nearest .env without replacing exported variables."""
|
|
@@ -16,6 +18,9 @@ def load_environment() -> None:
|
|
|
16
18
|
path = find_dotenv(usecwd=True)
|
|
17
19
|
if path:
|
|
18
20
|
load_dotenv(path, override=False)
|
|
21
|
+
for name in _EXPORTED_ONLY:
|
|
22
|
+
if name not in exported_keys:
|
|
23
|
+
os.environ.pop(name, None)
|
|
19
24
|
credentials.set_environment_context(exported_keys, Path(path) if path else None)
|
|
20
25
|
|
|
21
26
|
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""Convert native-PDF arXiv submissions to Markdown.
|
|
2
|
-
|
|
3
|
-
Used by `paperstack paper pdf` after the source fetcher reports no TeX.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import hashlib
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
import sys
|
|
12
|
-
import time
|
|
13
|
-
import urllib.error
|
|
14
|
-
import urllib.request
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
_CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
18
|
-
CACHE_DIR = Path(os.environ.get("PAPERSTACK_PAPERS_DIR", _CACHE_ROOT / "paperstack" / "papers"))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _fetch(url: str, timeout: int = 60) -> bytes | None:
|
|
22
|
-
req = urllib.request.Request(url, headers={"User-Agent": "paperstack/1.0 (+arxiv pdf fetch)"})
|
|
23
|
-
for attempt in range(3):
|
|
24
|
-
try:
|
|
25
|
-
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
26
|
-
return resp.read()
|
|
27
|
-
except urllib.error.HTTPError as e:
|
|
28
|
-
if e.code == 429:
|
|
29
|
-
time.sleep(15 * (attempt + 1))
|
|
30
|
-
continue
|
|
31
|
-
if attempt == 2:
|
|
32
|
-
return None
|
|
33
|
-
time.sleep(5)
|
|
34
|
-
except (urllib.error.URLError, OSError, TimeoutError):
|
|
35
|
-
if attempt == 2:
|
|
36
|
-
return None
|
|
37
|
-
time.sleep(5)
|
|
38
|
-
return None
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def convert(arxiv_id: str) -> bool:
|
|
42
|
-
d = CACHE_DIR / arxiv_id
|
|
43
|
-
md_path = d / "paper.md"
|
|
44
|
-
if md_path.exists() and md_path.stat().st_size > 1000:
|
|
45
|
-
print(f"{arxiv_id}: cached at {md_path}")
|
|
46
|
-
return True
|
|
47
|
-
|
|
48
|
-
try:
|
|
49
|
-
import pymupdf4llm
|
|
50
|
-
except ImportError:
|
|
51
|
-
print(
|
|
52
|
-
"PDF conversion needs `paperstack-cli[pdf]`; reinstall with "
|
|
53
|
-
"`uv tool install --force 'paperstack-cli[pdf]'`.",
|
|
54
|
-
file=sys.stderr,
|
|
55
|
-
)
|
|
56
|
-
return False
|
|
57
|
-
|
|
58
|
-
url = f"https://arxiv.org/pdf/{arxiv_id}"
|
|
59
|
-
raw = _fetch(url)
|
|
60
|
-
if raw is None:
|
|
61
|
-
print(f"{arxiv_id}: could not fetch {url}", file=sys.stderr)
|
|
62
|
-
return False
|
|
63
|
-
if not raw.startswith(b"%PDF"):
|
|
64
|
-
print(f"{arxiv_id}: {url} did not return a PDF", file=sys.stderr)
|
|
65
|
-
return False
|
|
66
|
-
|
|
67
|
-
d.mkdir(parents=True, exist_ok=True)
|
|
68
|
-
pdf_path = d / "paper.pdf"
|
|
69
|
-
pdf_path.write_bytes(raw)
|
|
70
|
-
md = pymupdf4llm.to_markdown(str(pdf_path))
|
|
71
|
-
if len(md) < 500:
|
|
72
|
-
print(f"{arxiv_id}: converted to only {len(md)} chars, treat as a failure", file=sys.stderr)
|
|
73
|
-
return False
|
|
74
|
-
|
|
75
|
-
md_path.write_text(md, encoding="utf-8")
|
|
76
|
-
(d / "meta.json").write_text(
|
|
77
|
-
json.dumps(
|
|
78
|
-
{
|
|
79
|
-
"arxiv_id": arxiv_id,
|
|
80
|
-
"bytes": len(md),
|
|
81
|
-
"sha256": hashlib.sha256(md.encode("utf-8")).hexdigest(),
|
|
82
|
-
"url": url,
|
|
83
|
-
"converter": "pymupdf4llm",
|
|
84
|
-
},
|
|
85
|
-
indent=2,
|
|
86
|
-
),
|
|
87
|
-
encoding="utf-8",
|
|
88
|
-
)
|
|
89
|
-
print(f"{arxiv_id}: {len(md)} chars -> {md_path}")
|
|
90
|
-
return True
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def main(argv: list[str]) -> None:
|
|
94
|
-
if not argv:
|
|
95
|
-
sys.exit("pass one or more arXiv ids")
|
|
96
|
-
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
97
|
-
failures = [a for a in argv if not convert(a)]
|
|
98
|
-
if failures:
|
|
99
|
-
sys.exit(f"failed: {' '.join(failures)}")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if __name__ == "__main__":
|
|
103
|
-
main(sys.argv[1:])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/vendor/latexpand.LICENSE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|