paperstack-cli 0.1.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.
- paperstack/__init__.py +1 -0
- paperstack/citations.py +97 -0
- paperstack/cli.py +779 -0
- paperstack/content/__init__.py +1 -0
- paperstack/content/arxiv_pdf.py +103 -0
- paperstack/content/arxiv_source.py +440 -0
- paperstack/content/vendor/latexpand +736 -0
- paperstack/content/vendor/latexpand.LICENSE +31 -0
- paperstack/dblp_index.py +568 -0
- paperstack/entrypoint.py +20 -0
- paperstack/metadata.py +392 -0
- paperstack_cli-0.1.0.dist-info/METADATA +203 -0
- paperstack_cli-0.1.0.dist-info/RECORD +15 -0
- paperstack_cli-0.1.0.dist-info/WHEEL +4 -0
- paperstack_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Paper content fetchers used by the unified CLI."""
|
|
@@ -0,0 +1,103 @@
|
|
|
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": "my-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 the optional dependency: install paperstack[pdf] "
|
|
53
|
+
"or run `uv tool install --with pymupdf4llm ...`",
|
|
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:])
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Read an arXiv LaTeX source or inspect its section structure.
|
|
2
|
+
|
|
3
|
+
Uses latexpand from PATH, or the vendored copy via Perl. Does not execute TeX.
|
|
4
|
+
Section ids follow source heading order. Used by `paperstack paper read`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import gzip
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tarfile
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
import zlib
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
_CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
26
|
+
CACHE_DIR = Path(os.environ.get("PAPERSTACK_PAPERS_DIR", _CACHE_ROOT / "paperstack" / "papers"))
|
|
27
|
+
|
|
28
|
+
LEVELS = {"section": 1, "subsection": 2, "subsubsection": 3}
|
|
29
|
+
|
|
30
|
+
HEAD_RE = re.compile(r"\\(section|subsection|subsubsection)\s*\*?\s*(?=[\[{])")
|
|
31
|
+
TEX_SUFFIXES = {".tex", ".ltx", ".latex"}
|
|
32
|
+
VENDORED_LATEXPAND = Path(__file__).parent / "vendor" / "latexpand"
|
|
33
|
+
LATEXPAND_ARGS = ("--keep-comments", "--empty-comments", "--fatal", "--define", r"subfile=\input")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fetch_bytes(url: str, timeout: int = 60) -> bytes | None:
|
|
37
|
+
req = urllib.request.Request(
|
|
38
|
+
url,
|
|
39
|
+
headers={
|
|
40
|
+
"User-Agent": "my-paperstack/1.0 (+arxiv e-print fetch)",
|
|
41
|
+
"Accept": "*/*",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
for attempt in range(3):
|
|
45
|
+
try:
|
|
46
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
47
|
+
return resp.read()
|
|
48
|
+
except urllib.error.HTTPError as e:
|
|
49
|
+
if e.code == 429:
|
|
50
|
+
time.sleep(15 * (attempt + 1))
|
|
51
|
+
continue
|
|
52
|
+
if e.code == 404 or attempt == 2:
|
|
53
|
+
return None
|
|
54
|
+
time.sleep(5)
|
|
55
|
+
except (urllib.error.URLError, OSError, TimeoutError):
|
|
56
|
+
if attempt < 2:
|
|
57
|
+
time.sleep(5)
|
|
58
|
+
continue
|
|
59
|
+
return None
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _safe_extract(tar: tarfile.TarFile, dest: Path) -> int:
|
|
64
|
+
"""Extract regular files without path traversal."""
|
|
65
|
+
n = 0
|
|
66
|
+
root = dest.resolve()
|
|
67
|
+
for m in tar.getmembers():
|
|
68
|
+
if not (m.isreg() or m.isdir()):
|
|
69
|
+
continue
|
|
70
|
+
target = (root / m.name).resolve()
|
|
71
|
+
if target != root and root not in target.parents:
|
|
72
|
+
continue
|
|
73
|
+
if m.isdir():
|
|
74
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
continue
|
|
76
|
+
src = tar.extractfile(m)
|
|
77
|
+
if src is None:
|
|
78
|
+
continue
|
|
79
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
target.write_bytes(src.read())
|
|
81
|
+
n += 1
|
|
82
|
+
return n
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _unpack(raw: bytes, dest: Path) -> int:
|
|
86
|
+
"""Unpack a tar or bare TeX response; return zero for PDFs."""
|
|
87
|
+
body = raw
|
|
88
|
+
if raw[:2] == b"\x1f\x8b":
|
|
89
|
+
try:
|
|
90
|
+
body = gzip.decompress(raw)
|
|
91
|
+
except (gzip.BadGzipFile, EOFError, zlib.error):
|
|
92
|
+
return 0
|
|
93
|
+
if body[:5] == b"%PDF-":
|
|
94
|
+
return 0
|
|
95
|
+
try:
|
|
96
|
+
with tarfile.open(fileobj=io.BytesIO(body), mode="r:*") as tar:
|
|
97
|
+
return _safe_extract(tar, dest)
|
|
98
|
+
except tarfile.TarError:
|
|
99
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
(dest / "main.tex").write_bytes(body)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ensure_source(arxiv_id: str, refresh: bool = False) -> Path:
|
|
105
|
+
paper_dir = CACHE_DIR / arxiv_id
|
|
106
|
+
src = paper_dir / "src"
|
|
107
|
+
if not refresh and src.is_dir() and _tex_candidates(src):
|
|
108
|
+
return src
|
|
109
|
+
url = f"https://arxiv.org/e-print/{arxiv_id}"
|
|
110
|
+
raw = _fetch_bytes(url)
|
|
111
|
+
if raw is None:
|
|
112
|
+
sys.exit(f"{arxiv_id}: could not fetch {url}")
|
|
113
|
+
paper_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
with tempfile.TemporaryDirectory(prefix=".src-", dir=paper_dir) as tmp:
|
|
115
|
+
staged = Path(tmp) / "new"
|
|
116
|
+
staged.mkdir()
|
|
117
|
+
unpacked = _unpack(raw, staged)
|
|
118
|
+
if not unpacked or not _tex_candidates(staged):
|
|
119
|
+
reason = "no LaTeX source (PDF-only submission?)" if not unpacked else "no .tex files in archive"
|
|
120
|
+
sys.exit(f"{arxiv_id}: {reason} at {url}")
|
|
121
|
+
|
|
122
|
+
previous = Path(tmp) / "previous"
|
|
123
|
+
if src.exists():
|
|
124
|
+
src.rename(previous)
|
|
125
|
+
try:
|
|
126
|
+
staged.rename(src)
|
|
127
|
+
except OSError:
|
|
128
|
+
if previous.exists():
|
|
129
|
+
previous.rename(src)
|
|
130
|
+
raise
|
|
131
|
+
return src
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _tex_candidates(src: Path) -> list[Path]:
|
|
135
|
+
out = []
|
|
136
|
+
for p in sorted(src.rglob("*")):
|
|
137
|
+
if not p.is_file() or "__MACOSX" in p.parts:
|
|
138
|
+
continue
|
|
139
|
+
if p.suffix.lower() in TEX_SUFFIXES or (not p.suffix and p.stat().st_size < 2_000_000):
|
|
140
|
+
out.append(p)
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _read(p: Path) -> str:
|
|
145
|
+
return p.read_bytes().decode("utf-8", errors="replace")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _mask_comments(text: str) -> str:
|
|
149
|
+
"""Mask comments without changing offsets."""
|
|
150
|
+
out = []
|
|
151
|
+
for line in text.splitlines(keepends=True):
|
|
152
|
+
i, n = 0, len(line)
|
|
153
|
+
while i < n:
|
|
154
|
+
c = line[i]
|
|
155
|
+
if c == "\\":
|
|
156
|
+
i += 2
|
|
157
|
+
continue
|
|
158
|
+
if c == "%":
|
|
159
|
+
nl = len(line) - len(line.rstrip("\r\n"))
|
|
160
|
+
out.append(line[:i] + " " * (n - i - nl) + line[n - nl :])
|
|
161
|
+
break
|
|
162
|
+
i += 1
|
|
163
|
+
else:
|
|
164
|
+
out.append(line)
|
|
165
|
+
return "".join(out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _balanced(text: str, start: int, open_ch: str, close_ch: str) -> tuple[str, int] | None:
|
|
169
|
+
"""Return balanced content and the position after its closing delimiter."""
|
|
170
|
+
if start >= len(text) or text[start] != open_ch:
|
|
171
|
+
return None
|
|
172
|
+
depth, i = 0, start
|
|
173
|
+
while i < len(text):
|
|
174
|
+
c = text[i]
|
|
175
|
+
if c == "\\":
|
|
176
|
+
i += 2
|
|
177
|
+
continue
|
|
178
|
+
if c == open_ch:
|
|
179
|
+
depth += 1
|
|
180
|
+
elif c == close_ch:
|
|
181
|
+
depth -= 1
|
|
182
|
+
if depth == 0:
|
|
183
|
+
return text[start + 1 : i], i + 1
|
|
184
|
+
i += 1
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _flatten(path: Path, src: Path) -> str | None:
|
|
189
|
+
"""Flatten one root with latexpand, or return None when it is unusable."""
|
|
190
|
+
command = [shutil.which("latexpand") or "perl"]
|
|
191
|
+
if command[0] == "perl":
|
|
192
|
+
command.append(str(VENDORED_LATEXPAND))
|
|
193
|
+
try:
|
|
194
|
+
proc = subprocess.run(
|
|
195
|
+
[*command, *LATEXPAND_ARGS, str(path.relative_to(src))],
|
|
196
|
+
cwd=src,
|
|
197
|
+
capture_output=True,
|
|
198
|
+
timeout=120,
|
|
199
|
+
check=False,
|
|
200
|
+
)
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
sys.exit("latexpand not found and Perl is unavailable for the vendored copy")
|
|
203
|
+
except subprocess.TimeoutExpired:
|
|
204
|
+
return None
|
|
205
|
+
return proc.stdout.decode("utf-8", errors="replace") if proc.returncode == 0 else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _body_span(masked: str) -> tuple[int, int]:
|
|
209
|
+
"""Return the document body without preamble headings."""
|
|
210
|
+
lo = masked.find(r"\begin{document}")
|
|
211
|
+
start = lo + len(r"\begin{document}") if lo >= 0 else 0
|
|
212
|
+
hi = masked.rfind(r"\end{document}")
|
|
213
|
+
return start, (hi if hi > start else len(masked))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_MACRO_RE = re.compile(r"\\(?:(?:new|renew|provide)command\*?\s*\{?|def\s*)\\([A-Za-z@]+)\}?")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _collect_macros(masked: str) -> dict[str, str]:
|
|
220
|
+
"""Collect zero-argument macro bodies used in headings."""
|
|
221
|
+
macros: dict[str, str] = {}
|
|
222
|
+
for m in _MACRO_RE.finditer(masked):
|
|
223
|
+
i = m.end()
|
|
224
|
+
while i < len(masked) and masked[i] in " \t":
|
|
225
|
+
i += 1
|
|
226
|
+
if i >= len(masked) or masked[i] != "{":
|
|
227
|
+
continue
|
|
228
|
+
bal = _balanced(masked, i, "{", "}")
|
|
229
|
+
if bal is not None:
|
|
230
|
+
macros[m.group(1)] = bal[0]
|
|
231
|
+
return macros
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _find_sections(text: str) -> list[dict]:
|
|
235
|
+
"""Parse sections with dotted ids, levels, titles, and text."""
|
|
236
|
+
masked_full = _mask_comments(text)
|
|
237
|
+
macros = _collect_macros(masked_full)
|
|
238
|
+
lo, hi = _body_span(masked_full)
|
|
239
|
+
body, masked = text[lo:hi], masked_full[lo:hi]
|
|
240
|
+
found = []
|
|
241
|
+
for m in HEAD_RE.finditer(masked):
|
|
242
|
+
i = m.end()
|
|
243
|
+
if masked[i] == "[":
|
|
244
|
+
opt = _balanced(masked, i, "[", "]")
|
|
245
|
+
if opt is None:
|
|
246
|
+
continue
|
|
247
|
+
i = opt[1]
|
|
248
|
+
while i < len(masked) and masked[i].isspace():
|
|
249
|
+
i += 1
|
|
250
|
+
if i >= len(masked) or masked[i] != "{":
|
|
251
|
+
continue
|
|
252
|
+
arg = _balanced(masked, i, "{", "}")
|
|
253
|
+
if arg is None:
|
|
254
|
+
continue
|
|
255
|
+
# Keep empty headings so subsection nesting remains intact.
|
|
256
|
+
title = _clean_title(arg[0], macros) or "(untitled)"
|
|
257
|
+
found.append({"level": LEVELS[m.group(1)], "title": title, "start": m.start()})
|
|
258
|
+
|
|
259
|
+
counters = [0, 0, 0]
|
|
260
|
+
for k, s in enumerate(found):
|
|
261
|
+
lvl = s["level"]
|
|
262
|
+
counters[lvl - 1] += 1
|
|
263
|
+
for j in range(lvl, 3):
|
|
264
|
+
counters[j] = 0
|
|
265
|
+
for j in range(lvl - 1):
|
|
266
|
+
counters[j] = max(counters[j], 1)
|
|
267
|
+
s["id"] = ".".join(str(c) for c in counters[:lvl])
|
|
268
|
+
s["end"] = len(body)
|
|
269
|
+
for nxt in found[k + 1 :]:
|
|
270
|
+
if nxt["level"] <= lvl:
|
|
271
|
+
s["end"] = nxt["start"]
|
|
272
|
+
break
|
|
273
|
+
s["text"] = body[s["start"] : s["end"]]
|
|
274
|
+
return found
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
_DROP_CMDS = ("label", "footnote", "thanks", "vspace", "hspace", "protect", "index")
|
|
278
|
+
_SPACE_CMDS = re.compile(r"\\(?:quad|qquad|,|;|:|!|\s)")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _clean_title(s: str, macros: dict[str, str] | None = None) -> str:
|
|
282
|
+
for _ in range(4):
|
|
283
|
+
if not macros:
|
|
284
|
+
break
|
|
285
|
+
new = re.sub(
|
|
286
|
+
r"\\([A-Za-z@]+)\s*(?:\{\})?",
|
|
287
|
+
lambda m: macros.get(m.group(1), m.group(0)),
|
|
288
|
+
s,
|
|
289
|
+
)
|
|
290
|
+
if new == s:
|
|
291
|
+
break
|
|
292
|
+
s = new
|
|
293
|
+
for cmd in _DROP_CMDS:
|
|
294
|
+
while True:
|
|
295
|
+
m = re.search(r"\\" + cmd + r"\s*\{", s)
|
|
296
|
+
if not m:
|
|
297
|
+
break
|
|
298
|
+
bal = _balanced(s, m.end() - 1, "{", "}")
|
|
299
|
+
if bal is None:
|
|
300
|
+
break
|
|
301
|
+
s = s[: m.start()] + s[bal[1] :]
|
|
302
|
+
while True:
|
|
303
|
+
m = re.search(r"\\texorpdfstring\s*\{", s)
|
|
304
|
+
if not m:
|
|
305
|
+
break
|
|
306
|
+
first = _balanced(s, m.end() - 1, "{", "}")
|
|
307
|
+
if first is None:
|
|
308
|
+
break
|
|
309
|
+
second = _balanced(s, first[1], "{", "}")
|
|
310
|
+
if second is None:
|
|
311
|
+
break
|
|
312
|
+
s = s[: m.start()] + second[0] + s[second[1] :]
|
|
313
|
+
for _ in range(6):
|
|
314
|
+
new = re.sub(r"\\[a-zA-Z]+\*?\s*\{([^{}]*)\}", r"\1", s)
|
|
315
|
+
if new == s:
|
|
316
|
+
break
|
|
317
|
+
s = new
|
|
318
|
+
s = s.replace("\\\\", " ")
|
|
319
|
+
s = _SPACE_CMDS.sub(" ", s)
|
|
320
|
+
s = re.sub(r"\\([a-zA-Z]+)\*?", r"\1", s)
|
|
321
|
+
s = re.sub(r"\\(.)", r"\1", s)
|
|
322
|
+
s = re.sub(r"[${}$~]", " ", s)
|
|
323
|
+
return re.sub(r"\s+", " ", s).strip()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _load_document(arxiv_id: str, refresh: bool) -> tuple[str, list[dict]]:
|
|
327
|
+
src = _ensure_source(arxiv_id, refresh)
|
|
328
|
+
cands = _tex_candidates(src)
|
|
329
|
+
scored = []
|
|
330
|
+
for p in cands:
|
|
331
|
+
head = _mask_comments(_read(p)[:200_000])
|
|
332
|
+
if r"\documentclass" not in head:
|
|
333
|
+
continue
|
|
334
|
+
flat = _flatten(p, src)
|
|
335
|
+
if flat is None:
|
|
336
|
+
continue
|
|
337
|
+
sections = _find_sections(flat)
|
|
338
|
+
scored.append((r"\begin{document}" in head, len(sections), -len(p.parts), flat, sections))
|
|
339
|
+
if not scored: # Fall back when no file declares a document class.
|
|
340
|
+
for p in cands:
|
|
341
|
+
flat = _flatten(p, src)
|
|
342
|
+
if flat is None:
|
|
343
|
+
continue
|
|
344
|
+
sections = _find_sections(flat)
|
|
345
|
+
scored.append((False, len(sections), -len(p.parts), flat, sections))
|
|
346
|
+
if not scored:
|
|
347
|
+
sys.exit(f"{arxiv_id}: no usable .tex file in {src}")
|
|
348
|
+
best = max(scored, key=lambda t: (t[0], t[1], t[2]))
|
|
349
|
+
if not best[4]:
|
|
350
|
+
sys.exit(f"{arxiv_id}: LaTeX source found but no \\section commands in it")
|
|
351
|
+
masked = _mask_comments(best[3])
|
|
352
|
+
lo, hi = _body_span(masked)
|
|
353
|
+
return best[3][lo:hi], best[4]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _load(arxiv_id: str, refresh: bool) -> list[dict]:
|
|
357
|
+
return _load_document(arxiv_id, refresh)[1]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _print_chunk(text: str, start: int, max_chars: int) -> None:
|
|
361
|
+
start = max(0, start)
|
|
362
|
+
chunk = text[start : start + max_chars] if max_chars else text[start:]
|
|
363
|
+
print(chunk)
|
|
364
|
+
shown = start + len(chunk)
|
|
365
|
+
if shown < len(text):
|
|
366
|
+
print(
|
|
367
|
+
f"[truncated: {start}..{shown} of {len(text)} chars; --start {shown} for more]",
|
|
368
|
+
file=sys.stderr,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
373
|
+
for s in _load(args.arxiv_id, args.refresh):
|
|
374
|
+
print(f"{s['id']}\t{s['level']}\t{s['title']}")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_section(args: argparse.Namespace) -> None:
|
|
378
|
+
sections = _load(args.arxiv_id, args.refresh)
|
|
379
|
+
want = args.section_id.strip()
|
|
380
|
+
hit = next((s for s in sections if s["id"] == want or s["title"] == want), None)
|
|
381
|
+
if hit is None:
|
|
382
|
+
hit = next((s for s in sections if s["title"].lower() == want.lower()), None)
|
|
383
|
+
if hit is None:
|
|
384
|
+
avail = ", ".join(f"{s['id']} {s['title']}" for s in sections[:20])
|
|
385
|
+
sys.exit(f"{args.arxiv_id}: no section {want!r}. Available: {avail}")
|
|
386
|
+
_print_chunk(hit["text"], args.start, args.max_chars)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def cmd_read(args: argparse.Namespace) -> None:
|
|
390
|
+
body, sections = _load_document(args.arxiv_id, args.refresh)
|
|
391
|
+
if args.outline:
|
|
392
|
+
for section in sections:
|
|
393
|
+
print(f"{section['id']}\t{section['level']}\t{section['title']}")
|
|
394
|
+
return
|
|
395
|
+
if args.section_id:
|
|
396
|
+
want = args.section_id.strip()
|
|
397
|
+
hit = next((s for s in sections if s["id"] == want or s["title"] == want), None)
|
|
398
|
+
if hit is None:
|
|
399
|
+
hit = next((s for s in sections if s["title"].lower() == want.lower()), None)
|
|
400
|
+
if hit is None:
|
|
401
|
+
avail = ", ".join(f"{s['id']} {s['title']}" for s in sections[:20])
|
|
402
|
+
sys.exit(f"{args.arxiv_id}: no section {want!r}. Available: {avail}")
|
|
403
|
+
body = hit["text"]
|
|
404
|
+
_print_chunk(body, args.start, args.max_chars)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main(argv: list[str]) -> None:
|
|
408
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
409
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
410
|
+
|
|
411
|
+
p_list = sub.add_parser("list", help="print <id>\\t<level>\\t<title> per section")
|
|
412
|
+
p_list.add_argument("arxiv_id")
|
|
413
|
+
p_list.set_defaults(func=cmd_list)
|
|
414
|
+
|
|
415
|
+
p_sec = sub.add_parser("section", help="print raw LaTeX of one section")
|
|
416
|
+
p_sec.add_argument("arxiv_id")
|
|
417
|
+
p_sec.add_argument("section_id", help="dotted id (3.2) or exact title")
|
|
418
|
+
p_sec.add_argument("--max-chars", type=int, default=0, help="0 = whole section")
|
|
419
|
+
p_sec.add_argument("--start", type=int, default=0)
|
|
420
|
+
p_sec.set_defaults(func=cmd_section)
|
|
421
|
+
|
|
422
|
+
p_read = sub.add_parser("read", help="print the document body, outline, or one section")
|
|
423
|
+
p_read.add_argument("arxiv_id")
|
|
424
|
+
mode = p_read.add_mutually_exclusive_group()
|
|
425
|
+
mode.add_argument("--outline", action="store_true")
|
|
426
|
+
mode.add_argument("--section", dest="section_id", help="dotted id (3.2) or exact title")
|
|
427
|
+
p_read.add_argument("--max-chars", type=int, default=0, help="0 = all remaining text")
|
|
428
|
+
p_read.add_argument("--start", type=int, default=0)
|
|
429
|
+
p_read.add_argument("--refresh", action="store_true", help="refetch even if cached")
|
|
430
|
+
p_read.set_defaults(func=cmd_read)
|
|
431
|
+
|
|
432
|
+
for p in (p_list, p_sec):
|
|
433
|
+
p.add_argument("--refresh", action="store_true", help="refetch even if cached")
|
|
434
|
+
|
|
435
|
+
args = ap.parse_args(argv)
|
|
436
|
+
args.func(args)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
if __name__ == "__main__":
|
|
440
|
+
main(sys.argv[1:])
|