arxiv-dl 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5b80906292c193199cba698da4d7e85ff927e41af088c2452b1b3c8ee3234997
4
+ data.tar.gz: bd732114a37495687cd23d62bbdc67cf645db682c8b3302cc1eb283c64ebd356
5
+ SHA512:
6
+ metadata.gz: 76153d238bfc2dddf1d9343e130e10beac24f508193c7429c8bbbe42a603a4451996bfb88dc1b592b2d7e34e31055e313d69c2f09235f28c714f03fa25288189
7
+ data.tar.gz: 7c508e83063cbf8d6832d34b9d55a89c7fa8ba55c60f97cbfb1aaab08a5c0d4489a2af359bf55d0a483416ec390ed8402ee7f42114cf15365c9ed647bb53028f
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ ## [0.1.0]
2
+
3
+ First release. Per-paper offline archive of arxiv.org papers, with PDF, abstract HTML, full HTML version (with relative-path images and a deduplicated cross-paper shared assets cache), TeX source, and four metadata sidecar files.
4
+
5
+ ### CLI
6
+
7
+ - `arxiv-dl <ARXIV_ID_OR_URL>...` accepts bare IDs, prefixed IDs, legacy IDs (`cs/0002001`), versioned IDs (`2508.16190v1`), and `/abs`, `/pdf`, `/html` URLs.
8
+ - Flags: `-p/--path PATH`, `--rate-limit SECONDS`, `-v/--verbose`, `-q/--quiet`, `--version`, `-h/--help`. `-v` and `-q` are mutually exclusive.
9
+ - ENV fallbacks: `ARXIV_DOWNLOAD_PATH`, `ARXIV_RATE_LIMIT`. Precedence: CLI flag > ENV > default.
10
+ - Default download path: `$HOME/Downloads/ArXiv_Papers`. Default rate limit: 3 seconds (arxiv etiquette).
11
+ - Verbose mode prints `==> Downloading <id>` step lines and `==> GET <url> (<bytes> bytes)` per HTTP request.
12
+
13
+ ### Per-paper output layout
14
+
15
+ ```txt
16
+ $ARXIV_DOWNLOAD_PATH/
17
+ _shared/<host>/<path> # deduplicated cross-paper static assets (CSS, JS, fonts)
18
+ YYYY/MM/DD/<primary_category>/<arxiv-id>-<slug>/
19
+ <arxiv-id>.pdf
20
+ <arxiv-id>-abstract.html
21
+ metadata.{md,yaml,json,bib}
22
+ html/<arxiv-id>.html + x1.png, x2.png, ...
23
+ src/*.tex, *.bbl, ...
24
+ ```
25
+
26
+ ### Library API
27
+
28
+ - `Arxiv::Downloader::Identifier.new(input).id` / `.version` parses every supported input form.
29
+ - `Arxiv::Downloader::Client.new(rate_limit:, log:).get(url)` is the rate-limited HTTP wrapper.
30
+ - `Arxiv::Downloader::FeedParser.new(xml).metadata` parses the arxiv Atom feed into a `Metadata` value object.
31
+ - `Arxiv::Downloader::Bibtex.new(metadata, client:)` produces synthesized or fetched BibTeX.
32
+ - `Arxiv::Downloader::PDF`, `AbstractPage`, `HTMLArchive`, `SourceArchive`, `AssetsCache` download individual artifacts.
33
+ - `Arxiv::Downloader::Metadata::{Markdown,YAML,JSON,Bibtex}.new(metadata).write(to: dir)` produce the four sidecar files.
34
+ - `Arxiv::Downloader::Archive.new(identifier, root:, client:).run` orchestrates the full per-paper pipeline and returns the paper directory path.
35
+
36
+ ### Dependencies
37
+
38
+ Runtime: `feedjira`, `http`, `nokogiri`, `ostruct`, `stringex`. Stdlib only for the rest (`optparse`, `fileutils`, `pathname`, `yaml`, `json`, `rubygems/package`, `zlib`).
@@ -0,0 +1,10 @@
1
+ Code of Conduct
2
+
3
+ "arxiv-dl" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ - Participants will be tolerant of opposing views.
6
+ - Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ - When interpreting the words and actions of others, participants should always assume good intentions.
8
+ - Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["veganstraightedge@gmail.com"](mailto:"veganstraightedge@gmail.com").
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Shane Becker
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # arxiv-dl
2
+
3
+ Ruby gem to download papers and metadata from [arxiv.org](https://arxiv.org) for offline archives.
4
+
5
+ For each paper it produces a self-contained per-paper folder with:
6
+
7
+ - The PDF
8
+ - The single-page abstract HTML
9
+ - The full HTML version (with images and a deduplicated, cross-paper shared assets cache)
10
+ - The TeX/LaTeX source tarball, extracted
11
+ - Four sidecar metadata files: `metadata.md`, `metadata.yaml`, `metadata.json`, `metadata.bib`
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ gem install arxiv-dl
17
+ ```
18
+
19
+ Or in a `Gemfile`:
20
+
21
+ ```ruby
22
+ gem 'arxiv-dl'
23
+ ```
24
+
25
+ ## CLI usage
26
+
27
+ ```sh
28
+ arxiv-dl <ARXIV_ID_OR_URL> [<ARXIV_ID_OR_URL>...]
29
+ ```
30
+
31
+ Accepted input forms:
32
+
33
+ | Form | Example |
34
+ | ------------ | -------------------------------------- |
35
+ | Bare ID | `2508.16190` |
36
+ | Prefixed ID | `arXiv:2508.16190` |
37
+ | Legacy ID | `cs/0002001`, `alg-geom/9708001` |
38
+ | Versioned ID | `2508.16190v1` |
39
+ | Abstract URL | `https://arxiv.org/abs/2508.16190` |
40
+ | PDF URL | `https://arxiv.org/pdf/2508.16190.pdf` |
41
+ | HTML URL | `https://arxiv.org/html/2508.16190` |
42
+
43
+ ### Flags
44
+
45
+ | Flag | Description |
46
+ | ------------------------ | -------------------------------------------------------- |
47
+ | `-p PATH`, `--path PATH` | Root download directory |
48
+ | `--rate-limit SECONDS` | Seconds between HTTP requests; `0` disables throttling |
49
+ | `-v`, `--verbose` | Print step lines and per-request URL/byte logs to stdout |
50
+ | `-q`, `--quiet` | Print nothing; success/failure conveyed via exit code |
51
+ | `--version` | Print the gem version and exit |
52
+ | `-h`, `--help` | Print help and exit |
53
+
54
+ `-v` and `-q` are mutually exclusive.
55
+
56
+ ### Environment variables
57
+
58
+ | Variable | Effect |
59
+ | --------------------- | ------------------------------------------------------------------------------- |
60
+ | `ARXIV_DOWNLOAD_PATH` | Root download directory (default: `$HOME/Downloads/ArXiv_Papers`) |
61
+ | `ARXIV_RATE_LIMIT` | Seconds between HTTP requests (default: `3`, per arxiv etiquette; `0` disables) |
62
+
63
+ Precedence: CLI flag > ENV var > default.
64
+
65
+ ### Examples
66
+
67
+ Download a single paper to the default location:
68
+
69
+ ```sh
70
+ arxiv-dl 2508.16190
71
+ ```
72
+
73
+ Download to a custom directory:
74
+
75
+ ```sh
76
+ arxiv-dl --path ~/Archives/arxiv 2508.16190
77
+ ```
78
+
79
+ Download multiple papers, verbose:
80
+
81
+ ```sh
82
+ arxiv-dl -v 2508.16190 1207.7214 cs/0002001
83
+ ```
84
+
85
+ Disable rate limiting (when running against a local mirror, etc):
86
+
87
+ ```sh
88
+ arxiv-dl --rate-limit 0 2508.16190
89
+ ```
90
+
91
+ ## Output layout
92
+
93
+ ```txt
94
+ $ARXIV_DOWNLOAD_PATH/ # default: $HOME/Downloads/ArXiv_Papers
95
+ _shared/ # cross-paper deduplicated static assets
96
+ arxiv.org/static/...
97
+ cdn.jsdelivr.net/...
98
+ YYYY/MM/DD/<primary_category>/<arxiv-id>-<slug>/
99
+ <arxiv-id>.pdf
100
+ <arxiv-id>-abstract.html
101
+ metadata.md # YAML frontmatter + Markdown body
102
+ metadata.yaml
103
+ metadata.json
104
+ metadata.bib # upstream BibTeX, falls back to synthesized
105
+ html/
106
+ <arxiv-id>.html # path-rewritten to local assets
107
+ x1.png, x2.png, ... # paper-specific images
108
+ src/
109
+ *.tex, *.bbl, ... # extracted from /src/<id> tarball
110
+ ```
111
+
112
+ `YYYY/MM/DD` is the original submission date. `<primary_category>` is from the paper's metadata (`cs.CL`, `math.NT`, etc). `<slug>` is derived from the paper title (Unicode → ASCII, hyphenated, truncated to 80 chars at a word boundary).
113
+
114
+ For legacy IDs containing `/` (e.g. `cs/0002001`), the slash is replaced with `-` in the directory name (`cs-0002001-...`).
115
+
116
+ ## Library usage
117
+
118
+ ```ruby
119
+ require 'arxiv/downloader'
120
+
121
+ identifier = Arxiv::Downloader::Identifier.new '2508.16190'
122
+ client = Arxiv::Downloader::Client.new # 3-second rate limit by default
123
+ path = Arxiv::Downloader::Archive.new(identifier, root: '/tmp/papers', client: client).run
124
+ # => "/tmp/papers/2025/08/22/cs.CL/2508.16190-comicscene154-a-scene-dataset-for-comic-analysis"
125
+ ```
126
+
127
+ ## Development
128
+
129
+ ```sh
130
+ script/setup # install dependencies
131
+ script/test # run specs and rubocop
132
+ script/console # interactive prompt
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT — see [LICENSE.md](LICENSE.md).
138
+
139
+ ## Code of Conduct
140
+
141
+ See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
data/exe/arxiv-dl ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'arxiv/downloader'
4
+
5
+ exit Arxiv::Downloader::CLI.new(ARGV).run
@@ -0,0 +1,20 @@
1
+ module Arxiv
2
+ module Downloader
3
+ class AbstractPage
4
+ def initialize identifier, client:
5
+ @identifier = identifier
6
+ @client = client
7
+ end
8
+
9
+ def download to:
10
+ File.write to, @client.get(url).to_s
11
+ end
12
+
13
+ private
14
+
15
+ def url
16
+ "https://arxiv.org/abs/#{@identifier.id}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,68 @@
1
+ require 'fileutils'
2
+
3
+ module Arxiv
4
+ module Downloader
5
+ class Archive
6
+ def initialize identifier, root:, client: Client.new
7
+ @identifier = identifier
8
+ @root = root
9
+ @client = client
10
+ end
11
+
12
+ def run
13
+ FileUtils.mkdir_p paper_dir
14
+
15
+ download_pdf
16
+ download_abstract
17
+ download_html_archive
18
+ download_source_archive
19
+ write_sidecars
20
+
21
+ paper_dir
22
+ end
23
+
24
+ private
25
+
26
+ def metadata
27
+ @metadata ||= FeedParser.new(@client.get(atom_url).to_s).metadata
28
+ end
29
+
30
+ def atom_url
31
+ "https://export.arxiv.org/api/query?id_list=#{@identifier.id}"
32
+ end
33
+
34
+ def paper_dir
35
+ @paper_dir ||= File.join @root, Path.new(metadata).to_s
36
+ end
37
+
38
+ def download_pdf
39
+ PDF.new(@identifier, client: @client).download to: File.join(paper_dir, "#{@identifier.id}.pdf")
40
+ end
41
+
42
+ def download_abstract
43
+ AbstractPage.new(@identifier, client: @client)
44
+ .download to: File.join(paper_dir, "#{@identifier.id}-abstract.html")
45
+ end
46
+
47
+ def download_html_archive
48
+ HTMLArchive.new(@identifier, client: @client, assets_cache: assets_cache)
49
+ .download to: File.join(paper_dir, 'html')
50
+ end
51
+
52
+ def download_source_archive
53
+ SourceArchive.new(@identifier, client: @client).download to: File.join(paper_dir, 'src')
54
+ end
55
+
56
+ def assets_cache
57
+ @assets_cache ||= AssetsCache.new root: @root, client: @client
58
+ end
59
+
60
+ def write_sidecars
61
+ Metadata::Markdown.new(metadata).write to: paper_dir
62
+ Metadata::YAML.new(metadata).write to: paper_dir
63
+ Metadata::JSON.new(metadata).write to: paper_dir
64
+ Metadata::Bibtex.new(metadata, client: @client).write to: paper_dir
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,32 @@
1
+ require 'fileutils'
2
+ require 'uri'
3
+
4
+ module Arxiv
5
+ module Downloader
6
+ class AssetsCache
7
+ SHARED_DIR = '_shared'.freeze
8
+
9
+ def initialize root:, client:
10
+ @root = root
11
+ @client = client
12
+ end
13
+
14
+ def fetch url
15
+ path = local_path_for url
16
+ return path if File.exist? path
17
+
18
+ FileUtils.mkdir_p File.dirname(path)
19
+ File.binwrite path, @client.get(url).to_s
20
+
21
+ path
22
+ end
23
+
24
+ private
25
+
26
+ def local_path_for url
27
+ uri = URI.parse url
28
+ File.join @root, SHARED_DIR, uri.host, uri.path.delete_prefix('/')
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ module Arxiv
2
+ module Downloader
3
+ class Bibtex
4
+ def initialize metadata, client: nil
5
+ @metadata = metadata
6
+ @client = client
7
+ end
8
+
9
+ def synthesize
10
+ <<~BIBTEX
11
+ @misc{#{key},
12
+ title={#{@metadata.title}},
13
+ author={#{authors}},
14
+ year={#{@metadata.published.year}},
15
+ eprint={#{@metadata.arxiv_id}},
16
+ archivePrefix={arXiv},
17
+ primaryClass={#{@metadata.primary_category[:id]}},
18
+ url={#{@metadata.arxiv_url}},
19
+ }
20
+ BIBTEX
21
+ end
22
+
23
+ def fetch
24
+ return nil if @client.nil?
25
+
26
+ response = @client.get url
27
+ return nil unless response.status.success?
28
+
29
+ response.to_s
30
+ end
31
+
32
+ def to_s
33
+ fetch || synthesize
34
+ end
35
+
36
+ def key
37
+ last_name = @metadata.authors.first.split.last.downcase.gsub(/[^a-z]/, '')
38
+ year = @metadata.published.year
39
+ title_word = Slug.new(@metadata.title).to_s.split('-').first
40
+
41
+ "#{last_name}#{year}#{title_word}"
42
+ end
43
+
44
+ private
45
+
46
+ def url
47
+ "https://arxiv.org/bibtex/#{@metadata.arxiv_id}"
48
+ end
49
+
50
+ def authors
51
+ @metadata.authors.join ' and '
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,20 @@
1
+ require 'yaml'
2
+
3
+ module Arxiv
4
+ module Downloader
5
+ class Categories
6
+ DATA_PATH = File.expand_path('categories.yaml', __dir__).freeze
7
+
8
+ def initialize
9
+ @data = YAML.load_file(DATA_PATH)
10
+ end
11
+
12
+ def lookup id
13
+ entry = @data[id]
14
+ return nil if entry.nil?
15
+
16
+ { id: id, name: entry['name'], group: entry['group'], description: entry['description'] }
17
+ end
18
+ end
19
+ end
20
+ end