ar2md 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.
ar2md/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """arXiv HTML to Markdown conversion."""
2
+
3
+ __all__ = []
ar2md/arxiv.lua ADDED
@@ -0,0 +1,277 @@
1
+ local referenced_ids = {}
2
+ local algorithm_sources = {}
3
+
4
+ local function has_class(element, class_name)
5
+ for _, class in ipairs(element.classes or {}) do
6
+ if class == class_name then
7
+ return true
8
+ end
9
+ end
10
+ return false
11
+ end
12
+
13
+ local function anchor_block(identifier)
14
+ return pandoc.RawBlock("html", '<a id="' .. identifier .. '"></a>')
15
+ end
16
+
17
+ local function anchor_inline(identifier)
18
+ return pandoc.RawInline("markdown", '<a id="' .. identifier .. '"></a>')
19
+ end
20
+
21
+ local function referenced_anchor_block(identifier)
22
+ if identifier ~= "" and referenced_ids[identifier] then
23
+ return anchor_block(identifier)
24
+ end
25
+ end
26
+
27
+ local function internal_identifier(target)
28
+ local fragment = target:match("^#(.+)$")
29
+ if fragment then
30
+ return fragment
31
+ end
32
+ if target:match("^%a[%w+%.%-]*:") or target:match("^//") then
33
+ return nil
34
+ end
35
+ return target:match("%.html#(.+)$")
36
+ end
37
+
38
+
39
+ local function rewrite_link(link)
40
+ if link.target:match("^data:") or link.target:match("^javascript:") then
41
+ return {}
42
+ end
43
+
44
+ local identifier = internal_identifier(link.target)
45
+ if identifier then
46
+ referenced_ids[identifier] = true
47
+ link.target = "#" .. identifier
48
+ end
49
+
50
+ link.title = ""
51
+ link.identifier = ""
52
+ link.classes = {}
53
+ link.attributes = {}
54
+ return link
55
+ end
56
+
57
+ local function collect_references(document)
58
+ document:walk({
59
+ Link = function(link)
60
+ local identifier = internal_identifier(link.target)
61
+ if identifier then
62
+ referenced_ids[identifier] = true
63
+ end
64
+ end,
65
+ })
66
+ end
67
+
68
+ local function collect_algorithm_sources(document)
69
+ document:walk({
70
+ Figure = function(figure)
71
+ if not figure.identifier:match("^alg%d+$") then
72
+ return
73
+ end
74
+
75
+ figure:walk({
76
+ Link = function(link)
77
+ if link.target:match("^data:text/plain") then
78
+ local _, contents = pandoc.mediabag.fetch(link.target)
79
+ if contents then
80
+ algorithm_sources[figure.identifier] = contents
81
+ end
82
+ end
83
+ end,
84
+ })
85
+ end,
86
+ })
87
+ end
88
+
89
+ local function clean_div(div)
90
+ local content = div.content
91
+ local anchor = referenced_anchor_block(div.identifier)
92
+ if anchor then
93
+ table.insert(content, 1, anchor)
94
+ end
95
+ return content
96
+ end
97
+
98
+ local function clean_span(span)
99
+ if has_class(span, "ltx_ERROR") or has_class(span, "ltx_bib_cited") then
100
+ return {}
101
+ end
102
+
103
+ if has_class(span, "ltx_role_footnotemark") then
104
+ local mark = pandoc.utils.stringify(span.content):match("%d")
105
+ return pandoc.Superscript({ pandoc.Str(mark or "*") })
106
+ end
107
+
108
+ if has_class(span, "ltx_role_footnote") then
109
+ local note_content = span.content
110
+ while note_content[1] and note_content[1].tag == "Superscript" do
111
+ table.remove(note_content, 1)
112
+ end
113
+ if note_content[1] and note_content[1].tag == "Str" and note_content[1].text:match("^%d+$") then
114
+ table.remove(note_content, 1)
115
+ end
116
+ return pandoc.Note({ pandoc.Para(note_content) })
117
+ end
118
+
119
+ local content = span.content
120
+ if has_class(span, "ltx_font_bold") then
121
+ content = { pandoc.Strong(content) }
122
+ elseif has_class(span, "ltx_font_italic") then
123
+ content = { pandoc.Emph(content) }
124
+ elseif has_class(span, "ltx_font_typewriter") then
125
+ content = { pandoc.Code(pandoc.utils.stringify(content)) }
126
+ end
127
+
128
+ if span.identifier ~= "" and referenced_ids[span.identifier] then
129
+ table.insert(content, 1, anchor_inline(span.identifier))
130
+ end
131
+ return content
132
+ end
133
+
134
+ local function clean_header(header)
135
+ local identifier = header.identifier
136
+ header.identifier = ""
137
+ header.classes = {}
138
+ header.attributes = {}
139
+
140
+ local anchor = referenced_anchor_block(identifier)
141
+ if anchor then
142
+ return { anchor, header }
143
+ end
144
+ return header
145
+ end
146
+
147
+ local function clean_code(code)
148
+ code.identifier = ""
149
+ code.classes = {}
150
+ code.attributes = {}
151
+ return code
152
+ end
153
+
154
+ local function clean_code_block(code_block)
155
+ code_block.identifier = ""
156
+ code_block.classes = {}
157
+ code_block.attributes = {}
158
+ return code_block
159
+ end
160
+
161
+ local function clean_figure(figure)
162
+ local blocks = {}
163
+ local caption_text = pandoc.utils.stringify(figure.caption.long)
164
+ if caption_text ~= "" then
165
+ figure = figure:walk({
166
+ Image = function(image)
167
+ local alt_text = pandoc.utils.stringify(image.caption)
168
+ if alt_text == "" or alt_text == "Refer to caption" then
169
+ image.caption = { pandoc.Str(caption_text) }
170
+ end
171
+ return image
172
+ end,
173
+ })
174
+ end
175
+ local anchor = referenced_anchor_block(figure.identifier)
176
+ if anchor then
177
+ table.insert(blocks, anchor)
178
+ end
179
+
180
+ local algorithm_source = algorithm_sources[figure.identifier]
181
+ if algorithm_source then
182
+ table.insert(blocks, pandoc.CodeBlock(algorithm_source))
183
+ else
184
+ for _, block in ipairs(figure.content) do
185
+ table.insert(blocks, block)
186
+ end
187
+ end
188
+ for _, block in ipairs(figure.caption.long) do
189
+ table.insert(blocks, block)
190
+ end
191
+ return blocks
192
+ end
193
+
194
+ local function clean_image(image)
195
+ image.identifier = ""
196
+ image.classes = {}
197
+ image.attributes = {}
198
+ image.title = ""
199
+
200
+ if image.src:match("^data:image/") then
201
+ return {}
202
+ end
203
+ return image
204
+ end
205
+
206
+ local function clean_table(table_element)
207
+ local identifier = table_element.identifier
208
+
209
+ if has_class(table_element, "ltx_equation") then
210
+ local equation
211
+ local equation_number
212
+
213
+ table_element:walk({
214
+ Math = function(math)
215
+ equation = equation or math
216
+ end,
217
+ Str = function(text)
218
+ equation_number = equation_number or text.text:match("^%((%d+)%)$")
219
+ end,
220
+ })
221
+
222
+ local blocks = {}
223
+ if identifier ~= "" then
224
+ table.insert(blocks, anchor_block(identifier))
225
+ end
226
+ if equation then
227
+ equation.mathtype = "DisplayMath"
228
+ table.insert(blocks, pandoc.Para({ equation }))
229
+ end
230
+ if equation_number then
231
+ table.insert(blocks, pandoc.Para({ pandoc.Str("Equation (" .. equation_number .. ")") }))
232
+ end
233
+ return blocks
234
+ end
235
+
236
+ table_element.identifier = ""
237
+ table_element.classes = {}
238
+ table_element.attributes = {}
239
+
240
+ local anchor = referenced_anchor_block(identifier)
241
+ if anchor then
242
+ return { anchor, table_element }
243
+ end
244
+ return table_element
245
+ end
246
+
247
+ function Pandoc(document)
248
+ local article_blocks
249
+
250
+ document:walk({
251
+ Div = function(div)
252
+ if has_class(div, "ltx_page_content") then
253
+ article_blocks = div.content
254
+ end
255
+ end,
256
+ })
257
+
258
+ if not article_blocks then
259
+ error("arXiv article container (.ltx_page_content) not found")
260
+ end
261
+
262
+ local article = pandoc.Pandoc(article_blocks, document.meta)
263
+ collect_references(article)
264
+ collect_algorithm_sources(article)
265
+
266
+ return article:walk({
267
+ Code = clean_code,
268
+ CodeBlock = clean_code_block,
269
+ Div = clean_div,
270
+ Header = clean_header,
271
+ Figure = clean_figure,
272
+ Image = clean_image,
273
+ Link = rewrite_link,
274
+ Span = clean_span,
275
+ Table = clean_table,
276
+ })
277
+ end
ar2md/cli.py ADDED
@@ -0,0 +1,380 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from importlib import resources
9
+ from importlib.metadata import version
10
+ from html.parser import HTMLParser
11
+ from pathlib import Path
12
+ from typing import Sequence
13
+
14
+
15
+ ARXIV_ID_PATTERN = re.compile(
16
+ r"^(?:https?://(?:www\.)?arxiv\.org/(?:abs|html|pdf)/)?"
17
+ r"(?P<base>\d{4}\.\d{4,5})\.?v(?P<version>\d+)"
18
+ r"(?:\.pdf)?/?$"
19
+ )
20
+
21
+
22
+ class _TitleParser(HTMLParser):
23
+ def __init__(self) -> None:
24
+ super().__init__()
25
+ self.in_title = False
26
+ self.parts: list[str] = []
27
+
28
+ def handle_starttag(
29
+ self,
30
+ tag: str,
31
+ attrs: list[tuple[str, str | None]],
32
+ ) -> None:
33
+ if tag == "title":
34
+ self.in_title = True
35
+
36
+ def handle_endtag(self, tag: str) -> None:
37
+ if tag == "title":
38
+ self.in_title = False
39
+
40
+ def handle_data(self, data: str) -> None:
41
+ if self.in_title:
42
+ self.parts.append(data)
43
+
44
+ def title(self) -> str:
45
+ return " ".join("".join(self.parts).split())
46
+
47
+
48
+ def parse_arxiv_id(value: str) -> str:
49
+ match = ARXIV_ID_PATTERN.fullmatch(value)
50
+ if not match:
51
+ raise argparse.ArgumentTypeError(
52
+ "expected a versioned arXiv ID or URL, for example 1706.03762v7"
53
+ )
54
+ return f"{match.group('base')}v{match.group('version')}"
55
+
56
+
57
+ def require_tool(name: str) -> str:
58
+ configured = os.environ.get(name.upper(), name)
59
+ executable = shutil.which(configured)
60
+ if not executable:
61
+ raise SystemExit(
62
+ f"{name} is required but was not found; "
63
+ f"install it or set {name.upper()}=/path/to/{name}"
64
+ )
65
+ return executable
66
+
67
+
68
+ def run(command: list[str], *, cwd: Path) -> None:
69
+ subprocess.run(command, cwd=cwd, check=True)
70
+
71
+
72
+ def _download_html(arxiv_id: str, host: str, wget: str, output_dir: Path) -> Path:
73
+ html_path = output_dir / host / "html" / f"{arxiv_id}.html"
74
+ url = f"https://{host}/html/{arxiv_id}"
75
+ completed = subprocess.run(
76
+ [
77
+ wget,
78
+ "--page-requisites",
79
+ "--convert-links",
80
+ "--adjust-extension",
81
+ "--no-parent",
82
+ url,
83
+ ],
84
+ cwd=output_dir,
85
+ text=True,
86
+ capture_output=True,
87
+ )
88
+ if completed.returncode:
89
+ raise subprocess.CalledProcessError(
90
+ completed.returncode,
91
+ completed.args,
92
+ output=completed.stdout,
93
+ stderr=completed.stderr,
94
+ )
95
+ if not html_path.is_file():
96
+ raise SystemExit(f"wget completed without creating {html_path}")
97
+
98
+ sys.stdout.write(completed.stdout)
99
+ sys.stderr.write(completed.stderr)
100
+ return html_path
101
+
102
+
103
+ def _show_download_error(error: subprocess.CalledProcessError) -> None:
104
+ if error.stdout:
105
+ sys.stdout.write(error.stdout)
106
+ if error.stderr:
107
+ sys.stderr.write(error.stderr)
108
+
109
+
110
+ def fetch_html(arxiv_id: str, wget: str, output_dir: Path) -> Path:
111
+ arxiv_path = output_dir / "arxiv.org" / "html" / f"{arxiv_id}.html"
112
+ ar5iv_path = output_dir / "ar5iv.labs.arxiv.org" / "html" / f"{arxiv_id}.html"
113
+ for html_path in (arxiv_path, ar5iv_path):
114
+ if html_path.exists():
115
+ print(f"Using existing HTML: {html_path}")
116
+ return html_path
117
+
118
+ try:
119
+ return _download_html(arxiv_id, "arxiv.org", wget, output_dir)
120
+ except subprocess.CalledProcessError as error:
121
+ if error.returncode != 8 or not re.search(r"(?:ERROR\s+|HTTP/\S+\s+)404\b", error.stderr or ""):
122
+ _show_download_error(error)
123
+ raise
124
+
125
+ print(
126
+ f"arXiv HTML is unavailable for {arxiv_id}; falling back to ar5iv.",
127
+ file=sys.stderr,
128
+ )
129
+ try:
130
+ return _download_html(arxiv_id, "ar5iv.labs.arxiv.org", wget, output_dir)
131
+ except subprocess.CalledProcessError as error:
132
+ _show_download_error(error)
133
+ raise
134
+
135
+
136
+ def recover_foreign_object_content(source: str) -> tuple[str, int]:
137
+ svg_pattern = re.compile(
138
+ r'<svg\b[^>]*\bltx_picture\b[^>]*>.*?</svg>',
139
+ re.IGNORECASE | re.DOTALL,
140
+ )
141
+ foreign_object_pattern = re.compile(
142
+ r"<foreignobject\b[^>]*>(.*?)</foreignobject>",
143
+ re.IGNORECASE | re.DOTALL,
144
+ )
145
+ recovered_count = 0
146
+
147
+ def recover(svg_match: re.Match[str]) -> str:
148
+ nonlocal recovered_count
149
+ fragments = foreign_object_pattern.findall(svg_match.group(0))
150
+ if not fragments:
151
+ return svg_match.group(0)
152
+ recovered_count += 1
153
+ return "\n".join(fragments)
154
+
155
+ return svg_pattern.sub(recover, source), recovered_count
156
+
157
+
158
+ def convert_html(html_path: Path, pandoc: str) -> Path:
159
+ output_path = html_path.with_suffix(".md")
160
+ temporary_path = output_path.with_suffix(".md.tmp")
161
+ prepared_path = html_path.with_suffix(".pandoc.html.tmp")
162
+ temporary_path.unlink(missing_ok=True)
163
+ prepared_path.unlink(missing_ok=True)
164
+
165
+ source_path = html_path
166
+ source = html_path.read_text(encoding="utf-8", errors="replace")
167
+ recovered_source, recovered_count = recover_foreign_object_content(source)
168
+ if recovered_count:
169
+ prepared_path.write_text(recovered_source, encoding="utf-8")
170
+ source_path = prepared_path
171
+
172
+ filter_resource = resources.files("ar2md").joinpath("arxiv.lua")
173
+ try:
174
+ with resources.as_file(filter_resource) as filter_path:
175
+ run(
176
+ [
177
+ pandoc,
178
+ source_path.name,
179
+ "--from=html",
180
+ "--to=markdown+pipe_tables+tex_math_dollars",
181
+ "--wrap=none",
182
+ f"--lua-filter={filter_path}",
183
+ f"--output={temporary_path.name}",
184
+ ],
185
+ cwd=html_path.parent,
186
+ )
187
+ if not temporary_path.is_file() or temporary_path.stat().st_size == 0:
188
+ raise SystemExit("pandoc did not produce a non-empty Markdown file")
189
+ temporary_path.replace(output_path)
190
+ finally:
191
+ temporary_path.unlink(missing_ok=True)
192
+ prepared_path.unlink(missing_ok=True)
193
+
194
+ return output_path
195
+
196
+
197
+ def extract_title(html_path: Path, markdown_path: Path) -> str:
198
+ parser = _TitleParser()
199
+ parser.feed(html_path.read_text(encoding="utf-8", errors="replace"))
200
+ title = re.sub(r"^\[[^\]]+\]\s*", "", parser.title())
201
+ if title:
202
+ return title
203
+
204
+ markdown = markdown_path.read_text(encoding="utf-8")
205
+ heading = re.search(r"^#\s+(.+?)\s*$", markdown, re.MULTILINE)
206
+ if heading:
207
+ return heading.group(1)
208
+ raise SystemExit(f"could not determine the paper title from {html_path}")
209
+
210
+
211
+ def inspect_markdown(markdown_path: Path) -> list[dict[str, int]]:
212
+ markdown = markdown_path.read_text(encoding="utf-8")
213
+ warnings: list[dict[str, int]] = []
214
+
215
+ citation_placeholders = len(re.findall(r"\*{1,3}\?\*{1,3}", markdown))
216
+ if citation_placeholders:
217
+ warnings.append(
218
+ {
219
+ "code": "unresolved_citation_placeholders",
220
+ "count": citation_placeholders,
221
+ }
222
+ )
223
+
224
+ internal_targets = set(re.findall(r"\]\(#([^)]+)\)", markdown))
225
+ explicit_anchors = set(re.findall(r'<a id="([^"]+)"></a>', markdown))
226
+ unresolved_targets = internal_targets - explicit_anchors
227
+ if unresolved_targets:
228
+ warnings.append(
229
+ {
230
+ "code": "unresolved_internal_links",
231
+ "count": len(unresolved_targets),
232
+ }
233
+ )
234
+
235
+ nested_math = len(re.findall(r"\$\$\s*\$", markdown))
236
+ if nested_math:
237
+ warnings.append(
238
+ {
239
+ "code": "nested_math_delimiters",
240
+ "count": nested_math,
241
+ }
242
+ )
243
+
244
+ image_targets = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", markdown)
245
+ missing_images = {
246
+ target
247
+ for target in image_targets
248
+ if not re.match(r"^(?:https?://|data:)", target)
249
+ and not (markdown_path.parent / target).is_file()
250
+ }
251
+ if missing_images:
252
+ warnings.append(
253
+ {
254
+ "code": "missing_local_images",
255
+ "count": len(missing_images),
256
+ }
257
+ )
258
+ return warnings
259
+
260
+
261
+ def report_warnings(warnings: list[dict[str, int]]) -> None:
262
+ messages = {
263
+ "unresolved_citation_placeholders": (
264
+ "unresolved citation placeholders; the source HTML likely "
265
+ "contains damaged citations"
266
+ ),
267
+ "unresolved_internal_links": "unresolved internal link targets",
268
+ "nested_math_delimiters": "nested display-math delimiters",
269
+ "missing_local_images": "missing local image targets",
270
+ }
271
+ for warning in warnings:
272
+ print(
273
+ f"Warning: {warning['count']} {messages[warning['code']]}.",
274
+ file=sys.stderr,
275
+ )
276
+
277
+
278
+ def update_index(
279
+ output_dir: Path,
280
+ arxiv_id: str,
281
+ title: str,
282
+ html_path: Path,
283
+ markdown_path: Path,
284
+ warnings: list[dict[str, int]],
285
+ ) -> Path:
286
+ index_path = output_dir / "index.jsonl"
287
+ records: dict[str, dict[str, object]] = {}
288
+ if index_path.exists():
289
+ for line_number, line in enumerate(
290
+ index_path.read_text(encoding="utf-8").splitlines(),
291
+ start=1,
292
+ ):
293
+ if not line.strip():
294
+ continue
295
+ try:
296
+ record = json.loads(line)
297
+ except json.JSONDecodeError as error:
298
+ raise SystemExit(
299
+ f"invalid JSON in {index_path} at line {line_number}: {error.msg}"
300
+ ) from error
301
+ if not isinstance(record, dict) or not isinstance(record.get("id"), str):
302
+ raise SystemExit(
303
+ f"invalid record in {index_path} at line {line_number}: "
304
+ 'expected an object with a string "id"'
305
+ )
306
+ records[record["id"]] = record
307
+
308
+ relative_html = html_path.relative_to(output_dir)
309
+ relative_markdown = markdown_path.relative_to(output_dir)
310
+ host = relative_html.parts[0]
311
+ records[arxiv_id] = {
312
+ "id": arxiv_id,
313
+ "title": title,
314
+ "source": f"https://{host}/html/{arxiv_id}",
315
+ "html": relative_html.as_posix(),
316
+ "markdown": relative_markdown.as_posix(),
317
+ "warnings": warnings,
318
+ }
319
+
320
+ temporary_path = index_path.with_suffix(".jsonl.tmp")
321
+ temporary_path.unlink(missing_ok=True)
322
+ try:
323
+ content = "".join(
324
+ json.dumps(records[record_id], ensure_ascii=False) + "\n"
325
+ for record_id in sorted(records)
326
+ )
327
+ temporary_path.write_text(content, encoding="utf-8")
328
+ temporary_path.replace(index_path)
329
+ finally:
330
+ temporary_path.unlink(missing_ok=True)
331
+ return index_path
332
+
333
+
334
+ def build_parser() -> argparse.ArgumentParser:
335
+ parser = argparse.ArgumentParser(
336
+ prog="ar2md",
337
+ description="Download versioned arXiv HTML (with ar5iv fallback) and convert it to Markdown.",
338
+ )
339
+ parser.add_argument("paper", type=parse_arxiv_id, help="versioned arXiv ID or URL")
340
+ parser.add_argument(
341
+ "-o",
342
+ "--output-dir",
343
+ type=Path,
344
+ default=Path("ar2md"),
345
+ help="mirror root (default: ./ar2md)",
346
+ )
347
+ parser.add_argument("--version", action="version", version=f"%(prog)s {version('ar2md')}")
348
+ return parser
349
+
350
+
351
+ def main(argv: Sequence[str] | None = None) -> None:
352
+ parser = build_parser()
353
+ args = parser.parse_args(argv)
354
+
355
+ wget = require_tool("wget")
356
+ pandoc = require_tool("pandoc")
357
+ output_dir = args.output_dir.expanduser().resolve()
358
+ output_dir.mkdir(parents=True, exist_ok=True)
359
+
360
+ try:
361
+ html_path = fetch_html(args.paper, wget, output_dir)
362
+ markdown_path = convert_html(html_path, pandoc)
363
+ title = extract_title(html_path, markdown_path)
364
+ warnings = inspect_markdown(markdown_path)
365
+ report_warnings(warnings)
366
+ index_path = update_index(
367
+ output_dir,
368
+ args.paper,
369
+ title,
370
+ html_path,
371
+ markdown_path,
372
+ warnings,
373
+ )
374
+ except subprocess.CalledProcessError as error:
375
+ command = Path(error.cmd[0]).name
376
+ parser.exit(error.returncode or 1, f"ar2md: {command} failed\n")
377
+
378
+ print(f"HTML: {html_path}")
379
+ print(f"Markdown: {markdown_path}")
380
+ print(f"Index: {index_path}")
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: ar2md
3
+ Version: 0.1.0
4
+ Summary: Download arXiv HTML papers and convert them to LLM-ready Markdown
5
+ Project-URL: Homepage, https://github.com/cs-giung/ar2md
6
+ Project-URL: Repository, https://github.com/cs-giung/ar2md
7
+ Project-URL: Issues, https://github.com/cs-giung/ar2md/issues
8
+ Author: cs-giung
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: arxiv,llm,markdown,pandoc
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Text Processing :: Markup
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+
26
+ # ar2md
27
+
28
+ Download a versioned arXiv paper as HTML and convert it to LLM-ready Markdown with Pandoc. Native arXiv HTML is preferred; papers without it fall back to ar5iv.
29
+
30
+ ## Requirements
31
+
32
+ - GNU Wget
33
+ - Pandoc 3.x
34
+
35
+ Set `WGET` or `PANDOC` to an executable path when either tool is not on `PATH`.
36
+
37
+ ## Run without installing
38
+
39
+ ```bash
40
+ uvx ar2md 1706.03762v7
41
+ ```
42
+
43
+ ## Install as a uv tool
44
+
45
+ ```bash
46
+ uv tool install ar2md
47
+ ar2md 1706.03762v7
48
+ ```
49
+
50
+ The PyPI distribution and installed executable are both named `ar2md`.
51
+
52
+ To run an unreleased commit directly from GitHub:
53
+
54
+ ```bash
55
+ uvx --from git+https://github.com/cs-giung/ar2md ar2md 1706.03762v7
56
+ uv tool install git+https://github.com/cs-giung/ar2md
57
+ ```
58
+
59
+ A version is required so that the local reference is reproducible. For convenience, the common extra-dot spelling `1706.03762.v7` is accepted and normalized to `1706.03762v7`. Versioned arXiv `abs`, `html`, and `pdf` URLs are also accepted.
60
+
61
+ ## Output
62
+
63
+ The default mirror root is `./ar2md`. Files retain their source hostname:
64
+
65
+ ```text
66
+ ar2md/
67
+ ├── index.jsonl
68
+ ├── arxiv.org/ # preferred source
69
+ │ ├── html/
70
+ │ │ ├── 1706.03762v7.html
71
+ │ │ ├── 1706.03762v7.md
72
+ │ │ └── 1706.03762v7/
73
+ │ └── static/
74
+ └── ar5iv.labs.arxiv.org/ # created only for fallback papers
75
+ ├── html/
76
+ │ ├── 1503.02531v1.html
77
+ │ └── 1503.02531v1.md
78
+ └── assets/
79
+ ```
80
+
81
+ Choose another mirror root with `--output-dir`:
82
+
83
+ ```bash
84
+ ar2md 1706.03762v7 --output-dir papers
85
+ ```
86
+
87
+ An existing HTML snapshot is reused, while Markdown is regenerated atomically on each invocation.
88
+
89
+ `index.jsonl` contains one JSON object per versioned paper ID. Records are sorted by ID and replaced, rather than duplicated, when a paper is regenerated. The index is written atomically after successful conversion:
90
+
91
+ ```json
92
+ {"id": "1207.0580v1", "title": "Improving neural networks by preventing co-adaptation of feature detectors", "source": "https://ar5iv.labs.arxiv.org/html/1207.0580v1", "html": "ar5iv.labs.arxiv.org/html/1207.0580v1.html", "markdown": "ar5iv.labs.arxiv.org/html/1207.0580v1.md", "warnings": [{"code": "unresolved_citation_placeholders", "count": 24}]}
93
+ ```
94
+
95
+ The `html` and `markdown` paths are relative to the mirror root. The `source` field records whether the HTML came from arXiv or ar5iv. `warnings` is empty for a clean conversion.
96
+
97
+ After conversion, `ar2md` checks for unresolved citation placeholders, unresolved internal links, nested display-math delimiters, and missing local images. Findings are printed to stderr and stored as structured warning counts in the index. The tool does not fabricate content that is already missing from the source HTML.
98
+
99
+ ## ar5iv fallback
100
+
101
+ The downloader first requests `https://arxiv.org/html/<versioned-id>`. An HTTP 404 triggers a request to `https://ar5iv.labs.arxiv.org/html/<versioned-id>`; network failures and other HTTP errors do not silently switch sources. For example:
102
+
103
+ ```bash
104
+ ar2md 1503.02531v1
105
+ ```
106
+
107
+ The resulting HTML and Markdown are stored under `ar2md/ar5iv.labs.arxiv.org/`, making the alternate provenance explicit. A cached fallback snapshot is reused on later runs. ar5iv is a LaTeXML conversion service rather than an arXiv archival endpoint, so retain the downloaded snapshot when exact reproducibility matters.
108
+
109
+ ## Local development
110
+
111
+ ```bash
112
+ uvx --from . ar2md 1706.03762v7
113
+ uv tool install .
114
+ ```
115
+
116
+ ## Conversion
117
+
118
+ The bundled Lua filter keeps the paper body while removing arXiv UI, preserves TeX math and figures, uses figure captions as descriptive image alt text, reconstructs algorithm code blocks, normalizes footnotes and equation blocks, and rewrites internal citations to valid local anchors.
119
+
120
+ ## Publishing
121
+
122
+ Build and validate the distributions locally:
123
+
124
+ ```bash
125
+ uv lock --check
126
+ uv run python -m unittest discover -s tests -v
127
+ uv build
128
+ uvx twine check dist/*
129
+ ```
130
+
131
+ Publishing uses PyPI Trusted Publishing. In the PyPI project or pending-publisher settings, configure:
132
+
133
+ - PyPI project: `ar2md`
134
+ - GitHub owner: `cs-giung`
135
+ - GitHub repository: `ar2md`
136
+ - workflow: `publish.yml`
137
+ - environment: `pypi`
138
+
139
+ Then create a GitHub release from a tag matching `v<project.version>`, such as `v0.1.0`. The release workflow checks the tag against `pyproject.toml`, runs the test suite, validates both distributions, and publishes without a long-lived API token.
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,8 @@
1
+ ar2md/__init__.py,sha256=efZmQZqQLvUSTvkAKod-2zChpchSEOILMCkAYFsSp6g,55
2
+ ar2md/arxiv.lua,sha256=QR9zhbilUJ6AZpiHlf9X_nLsHVtKlBIb95o2UxrPF_4,6844
3
+ ar2md/cli.py,sha256=hns0amP7pDIbUF4Ytfa9lFiRqBcB-Xzmoi2TkCck_FU,12273
4
+ ar2md-0.1.0.dist-info/METADATA,sha256=Xtf5SQczIzoaoe5zH52QY50-RNVPO8Ue4GX9dBJ-E94,5427
5
+ ar2md-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ ar2md-0.1.0.dist-info/entry_points.txt,sha256=XEYdoLssJscmaljoga_jp6hJh3rFvHskNa-6qqKZQV8,41
7
+ ar2md-0.1.0.dist-info/licenses/LICENSE,sha256=WvkD_s0T9J_on1SO6LvImJe3ZwDYHZtoBop-feLusac,1065
8
+ ar2md-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ar2md = ar2md.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cs-giung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.