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.
Files changed (32) hide show
  1. paperstack_cli-0.3.2/README.md → paperstack_cli-0.4.0/PKG-INFO +36 -0
  2. paperstack_cli-0.3.2/PKG-INFO → paperstack_cli-0.4.0/README.md +14 -17
  3. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/pyproject.toml +8 -1
  4. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/cli.py +7 -4
  5. paperstack_cli-0.4.0/src/paperstack/content/arxiv_pdf.py +253 -0
  6. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/entrypoint.py +5 -0
  7. paperstack_cli-0.3.2/src/paperstack/content/arxiv_pdf.py +0 -103
  8. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/.gitignore +0 -0
  9. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/LICENSE +0 -0
  10. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/app.js +0 -0
  11. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/entry.html +0 -0
  12. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/favicon.svg +0 -0
  13. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/index.html +0 -0
  14. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/site/style.css +0 -0
  15. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/vendor/marked.LICENSE +0 -0
  16. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/scripts/build/vendor/marked.min.js +0 -0
  17. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/__init__.py +0 -0
  18. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/arxiv.py +0 -0
  19. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/citations.py +0 -0
  20. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/__init__.py +0 -0
  21. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/arxiv_source.py +0 -0
  22. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/vendor/latexpand +0 -0
  23. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/content/vendor/latexpand.LICENSE +0 -0
  24. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/corpora.py +0 -0
  25. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/credentials.py +0 -0
  26. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/dblp_build.py +0 -0
  27. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/dblp_catalog.py +0 -0
  28. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/dblp_index.py +0 -0
  29. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/entry_types.py +0 -0
  30. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/metadata.py +0 -0
  31. {paperstack_cli-0.3.2 → paperstack_cli-0.4.0}/src/paperstack/semantic_scholar.py +0 -0
  32. {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
+ [![CI](https://github.com/MilkClouds/paperstack/actions/workflows/ci.yml/badge.svg)](https://github.com/MilkClouds/paperstack/actions/workflows/ci.yml) [![PyPI version](https://img.shields.io/pypi/v/paperstack-cli.svg)](https://pypi.org/project/paperstack-cli/) [![Python versions](https://img.shields.io/pypi/pyversions/paperstack-cli.svg)](https://pypi.org/project/paperstack-cli/) [![License](https://img.shields.io/pypi/l/paperstack-cli.svg)](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
+ [![CI](https://github.com/MilkClouds/paperstack/actions/workflows/ci.yml/badge.svg)](https://github.com/MilkClouds/paperstack/actions/workflows/ci.yml) [![PyPI version](https://img.shields.io/pypi/v/paperstack-cli.svg)](https://pypi.org/project/paperstack-cli/) [![Python versions](https://img.shields.io/pypi/pyversions/paperstack-cli.svg)](https://pypi.org/project/paperstack-cli/) [![License](https://img.shields.io/pypi/l/paperstack-cli.svg)](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 = ["pymupdf4llm>=0.0.17"]
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.CACHE_DIR / ref.value / "paper.md"
1008
- if offline and (not cached_pdf.is_file() or cached_pdf.stat().st_size <= 1000):
1009
- die(f"no complete cached PDF conversion for arxiv:{ref.value}")
1010
- if not arxiv_pdf.convert(ref.value):
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