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.
@@ -0,0 +1,85 @@
1
+ require 'optparse'
2
+
3
+ module Arxiv
4
+ module Downloader
5
+ class CLI
6
+ DEFAULT_DOWNLOAD_PATH = File.join Dir.home, 'Downloads', 'ArXiv_Papers'
7
+ USAGE = 'Usage: arxiv-dl [options] <ARXIV_ID_OR_URL> [<ARXIV_ID_OR_URL>...]'.freeze
8
+
9
+ def initialize argv, stdout: $stdout, stderr: $stderr
10
+ @argv = argv
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ end
14
+
15
+ def run
16
+ options = parse
17
+ return options.fetch(:exit_status) if options.key? :exit_status
18
+
19
+ return error_with USAGE if options[:targets].empty?
20
+ return error_with conflict if options[:verbose] && options[:quiet]
21
+
22
+ download_each options
23
+ 0
24
+ end
25
+
26
+ private
27
+
28
+ def conflict
29
+ '-v and -q are mutually exclusive'
30
+ end
31
+
32
+ def parse
33
+ options = { targets: [], verbose: false, quiet: false }
34
+ parser = build_parser options
35
+
36
+ begin
37
+ parser.parse! @argv
38
+ rescue OptionParser::ParseError => e
39
+ @stderr.puts e.message
40
+ return { exit_status: 1 }
41
+ end
42
+
43
+ options[:targets] = @argv
44
+ options[:path] ||= ENV['ARXIV_DOWNLOAD_PATH'] || DEFAULT_DOWNLOAD_PATH
45
+ options[:rate_limit] ||= (ENV['ARXIV_RATE_LIMIT'] || Client::DEFAULT_RATE_LIMIT).to_i
46
+ options
47
+ end
48
+
49
+ def build_parser options
50
+ OptionParser.new do |parser|
51
+ parser.banner = USAGE
52
+ parser.on('-p PATH', '--path PATH') { |value| options[:path] = value }
53
+ parser.on('--rate-limit SECONDS', Integer) { |value| options[:rate_limit] = value }
54
+ parser.on('-v', '--verbose') { options[:verbose] = true }
55
+ parser.on('-q', '--quiet') { options[:quiet] = true }
56
+ parser.on('--version') do
57
+ @stdout.puts VERSION
58
+ options[:exit_status] = 0
59
+ end
60
+ parser.on('-h', '--help') do
61
+ @stdout.puts parser.help
62
+ options[:exit_status] = 0
63
+ end
64
+ end
65
+ end
66
+
67
+ def error_with message
68
+ @stderr.puts message
69
+ 1
70
+ end
71
+
72
+ def download_each options
73
+ client = Client.new rate_limit: options[:rate_limit], log: (options[:verbose] ? @stdout : nil)
74
+
75
+ options[:targets].each do |target|
76
+ identifier = Identifier.new target
77
+ @stdout.puts "==> Downloading #{identifier.id}" if options[:verbose]
78
+
79
+ path = Archive.new(identifier, root: options[:path], client: client).run
80
+ @stdout.puts path unless options[:quiet]
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,47 @@
1
+ require 'http'
2
+
3
+ module Arxiv
4
+ module Downloader
5
+ class Client
6
+ SOURCE_URL = 'https://github.com/xoengineering/arxiv-dl'.freeze
7
+ DEFAULT_RATE_LIMIT = 3
8
+
9
+ attr_reader :rate_limit
10
+
11
+ def initialize rate_limit: DEFAULT_RATE_LIMIT, log: nil
12
+ @rate_limit = rate_limit
13
+ @log = log
14
+ end
15
+
16
+ def user_agent
17
+ "arxiv-dl/#{VERSION} (+#{SOURCE_URL})"
18
+ end
19
+
20
+ def get url
21
+ throttle
22
+ response = HTTP.headers('User-Agent' => user_agent).follow.get(url)
23
+ @last_request_at = Time.now
24
+ log_request url, response
25
+ response
26
+ end
27
+
28
+ private
29
+
30
+ def log_request url, response
31
+ return if @log.nil?
32
+
33
+ @log.puts "==> GET #{url} (#{response.body.to_s.bytesize} bytes)"
34
+ end
35
+
36
+ def throttle
37
+ return if @rate_limit.zero?
38
+ return if @last_request_at.nil?
39
+
40
+ elapsed = Time.now - @last_request_at
41
+ return if elapsed >= @rate_limit
42
+
43
+ sleep(@rate_limit - elapsed)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ module Arxiv
2
+ module Downloader
3
+ class Error < StandardError; end
4
+ end
5
+ end
@@ -0,0 +1,61 @@
1
+ require 'feedjira'
2
+
3
+ module Arxiv
4
+ module Downloader
5
+ class FeedParser
6
+ class Author
7
+ include SAXMachine
8
+
9
+ element :name
10
+ end
11
+
12
+ class AtomEntry < Feedjira::Parser::AtomEntry
13
+ elements :author, as: :authors, class: Author
14
+
15
+ element 'arxiv:primary_category', as: :primary_category_id, value: :term
16
+ element 'arxiv:comment', as: :comment
17
+ element 'arxiv:doi', as: :doi
18
+ element 'arxiv:journal_ref', as: :journal_ref
19
+ end
20
+
21
+ class AtomFeed
22
+ include SAXMachine
23
+ include Feedjira::FeedUtilities
24
+
25
+ elements :entry, as: :entries, class: AtomEntry
26
+
27
+ def self.able_to_parse? xml
28
+ xml.include? 'http://www.w3.org/2005/Atom'
29
+ end
30
+ end
31
+
32
+ Feedjira.configure { |config| config.parsers = [AtomFeed] + config.parsers }
33
+
34
+ def initialize xml
35
+ @feed = Feedjira.parse xml
36
+ @categories = Categories.new
37
+ end
38
+
39
+ def metadata
40
+ entry = @feed.entries.first
41
+ arxiv_id = Identifier.new(entry.entry_id).id
42
+
43
+ Metadata.new(
44
+ arxiv_id: arxiv_id,
45
+ arxiv_url: "https://arxiv.org/abs/#{arxiv_id}",
46
+ pdf_url: "https://arxiv.org/pdf/#{arxiv_id}.pdf",
47
+ title: entry.title,
48
+ authors: entry.authors.map(&:name),
49
+ abstract: entry.summary.strip,
50
+ published: entry.published.to_date,
51
+ updated: entry.updated.to_date,
52
+ primary_category: @categories.lookup(entry.primary_category_id),
53
+ categories: entry.categories.map { |id| @categories.lookup id },
54
+ comment: entry.comment,
55
+ doi: entry.doi,
56
+ journal_ref: entry.journal_ref
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,69 @@
1
+ require 'fileutils'
2
+ require 'nokogiri'
3
+ require 'uri'
4
+
5
+ module Arxiv
6
+ module Downloader
7
+ class HTMLArchive
8
+ ASSET_SELECTORS = {
9
+ 'img[src]' => 'src',
10
+ 'script[src]' => 'src',
11
+ 'link[rel="stylesheet"]' => 'href'
12
+ }.freeze
13
+
14
+ def initialize identifier, client:, assets_cache:
15
+ @identifier = identifier
16
+ @client = client
17
+ @assets_cache = assets_cache
18
+ end
19
+
20
+ def download to:
21
+ FileUtils.mkdir_p to
22
+
23
+ document = Nokogiri::HTML @client.get(html_url).to_s
24
+ ASSET_SELECTORS.each do |selector, attribute|
25
+ document.css(selector).each { |node| process node, attribute, to }
26
+ end
27
+
28
+ File.write File.join(to, "#{@identifier.id}.html"), document.to_html
29
+ end
30
+
31
+ private
32
+
33
+ def html_url
34
+ "https://arxiv.org/html/#{@identifier.id}"
35
+ end
36
+
37
+ def process node, attribute, html_dir
38
+ reference = node[attribute]
39
+ return if reference.nil? || reference.empty?
40
+
41
+ if relative? reference
42
+ download_relative reference, html_dir
43
+ else
44
+ cache_remote node, attribute, reference, html_dir
45
+ end
46
+ end
47
+
48
+ def relative? reference
49
+ URI.parse(reference).scheme.nil?
50
+ rescue URI::InvalidURIError
51
+ false
52
+ end
53
+
54
+ def download_relative reference, html_dir
55
+ File.binwrite File.join(html_dir, reference), @client.get(absolute_for(reference)).to_s
56
+ end
57
+
58
+ def absolute_for reference
59
+ "https://arxiv.org/html/#{@identifier.id}/#{reference}"
60
+ end
61
+
62
+ def cache_remote node, attribute, reference, html_dir
63
+ cached_path = @assets_cache.fetch reference
64
+ relative_ref = Pathname.new(cached_path).relative_path_from(Pathname.new(html_dir)).to_s
65
+ node[attribute] = relative_ref
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,135 @@
1
+ module Arxiv
2
+ module Downloader
3
+ class Identifier
4
+ class Invalid < Error; end
5
+
6
+ attr_reader :id, :version, :input
7
+
8
+ def initialize input
9
+ @input = input
10
+ @cleaned_input = input.dup
11
+
12
+ validate_input
13
+ set_id_and_version
14
+ end
15
+
16
+ private
17
+
18
+ # validations
19
+
20
+ def validate_input
21
+ invalidate_nil_input
22
+ invalidate_blank_input
23
+ invalidate_wrong_domain_input
24
+ invalidate_no_dot_no_slash_input
25
+ invalidate_pathless_arxiv_url_input
26
+ invalidate_apex_arxiv_url_input
27
+ invalidate_incomplete_arxiv_url_input
28
+ end
29
+
30
+ def invalidate_nil_input
31
+ raise Invalid, 'blank input is invalid' if @input.nil?
32
+ end
33
+
34
+ def invalidate_blank_input
35
+ raise Invalid, 'blank input is invalid' if @input.empty?
36
+ end
37
+
38
+ def invalidate_wrong_domain_input
39
+ invalid = true if @cleaned_input.include?('http') && !@cleaned_input.include?('arxiv.org')
40
+
41
+ raise Invalid, 'domains other than arxiv.org are invalid' if invalid
42
+ end
43
+
44
+ def invalidate_no_dot_no_slash_input
45
+ invalid = true if !@cleaned_input.include?('.') && !@cleaned_input.include?('/')
46
+
47
+ raise Invalid, "not a recognizable arXiv identifier: #{input}" if invalid
48
+ end
49
+
50
+ def invalidate_pathless_arxiv_url_input
51
+ invalid = true if @cleaned_input.split('arxiv.org/').last.nil?
52
+
53
+ raise Invalid, "not a recognizable arXiv identifier URL: #{input}" if invalid
54
+ end
55
+
56
+ def invalidate_apex_arxiv_url_input
57
+ invalid = true if @cleaned_input.split('arxiv.org').last.nil?
58
+
59
+ raise Invalid, "not a recognizable arXiv identifier URL: #{input}" if invalid
60
+ end
61
+
62
+ def invalidate_incomplete_arxiv_url_input
63
+ path = @cleaned_input.split('arxiv.org/').last
64
+ invalid = true if !path.include?('.') && !path.include?('/')
65
+
66
+ raise Invalid, "not a recognizable arXiv identifier URL: #{input}" if invalid
67
+ end
68
+
69
+ # setter
70
+
71
+ def set_id_and_version
72
+ normalize_input
73
+ @id = @cleaned_input
74
+ end
75
+
76
+ # mutaters
77
+
78
+ def normalize_input
79
+ delete_spaces!
80
+ strip_slashes!
81
+ delete_protocols!
82
+ delete_domain!
83
+ delete_format_namespaces!
84
+ delete_arxiv_namespace!
85
+ delete_file_extensions!
86
+ extract_version!
87
+ end
88
+
89
+ def delete_spaces!
90
+ @cleaned_input.chomp!
91
+ @cleaned_input.strip!
92
+ @cleaned_input.squeeze! ' '
93
+ end
94
+
95
+ def strip_slashes!
96
+ loop do
97
+ @cleaned_input.delete_suffix! '/'
98
+ break unless @cleaned_input.end_with? '/'
99
+ end
100
+ end
101
+
102
+ def delete_protocols!
103
+ @cleaned_input.delete_prefix! 'http://'
104
+ @cleaned_input.delete_prefix! 'https://'
105
+ end
106
+
107
+ def delete_domain!
108
+ @cleaned_input.sub! 'arxiv.org', ''
109
+ end
110
+
111
+ def delete_format_namespaces!
112
+ %w[/abs/ /html/ /pdf/].each do |format_namespace|
113
+ @cleaned_input.sub! format_namespace, ''
114
+ end
115
+ end
116
+
117
+ def delete_arxiv_namespace!
118
+ @cleaned_input.sub!(/(arxiv:)/i, '')
119
+ end
120
+
121
+ def delete_file_extensions!
122
+ @cleaned_input.delete_suffix! '.pdf'
123
+ end
124
+
125
+ def extract_version!
126
+ dot_parts = @cleaned_input.split('.')
127
+ slash_parts = dot_parts.last.split('/')
128
+ v_parts = slash_parts.last.split('v')
129
+
130
+ @version = Integer(v_parts.last) if v_parts.length > 1
131
+ @cleaned_input.delete_suffix! "v#{@version}" unless @version.nil?
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,21 @@
1
+ require 'fileutils'
2
+
3
+ module Arxiv
4
+ module Downloader
5
+ class Metadata
6
+ class Bibtex
7
+ FILENAME = 'metadata.bib'.freeze
8
+
9
+ def initialize metadata, client: nil
10
+ @metadata = metadata
11
+ @client = client
12
+ end
13
+
14
+ def write to:
15
+ FileUtils.mkdir_p to
16
+ File.write File.join(to, FILENAME), Downloader::Bibtex.new(@metadata, client: @client).to_s
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+
4
+ module Arxiv
5
+ module Downloader
6
+ class Metadata
7
+ class JSON
8
+ FILENAME = 'metadata.json'.freeze
9
+
10
+ def initialize metadata
11
+ @metadata = metadata
12
+ end
13
+
14
+ def write to:
15
+ FileUtils.mkdir_p to
16
+ File.write File.join(to, FILENAME), "#{::JSON.pretty_generate(serialize(@metadata.to_h))}\n"
17
+ end
18
+
19
+ private
20
+
21
+ def serialize object
22
+ case object
23
+ when Hash then object.to_h { |key, value| [key.to_s, serialize(value)] }
24
+ when Array then object.map { |item| serialize item }
25
+ when Date, Time then object.iso8601
26
+ else object
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,65 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ module Arxiv
5
+ module Downloader
6
+ class Metadata
7
+ class Markdown
8
+ FILENAME = 'metadata.md'.freeze
9
+
10
+ def initialize metadata
11
+ @metadata = metadata
12
+ end
13
+
14
+ def write to:
15
+ FileUtils.mkdir_p to
16
+ File.write File.join(to, FILENAME), "#{frontmatter}\n#{body}"
17
+ end
18
+
19
+ private
20
+
21
+ def frontmatter
22
+ "---\n#{::YAML.dump(stringify(frontmatter_hash)).delete_prefix("---\n")}---"
23
+ end
24
+
25
+ def frontmatter_hash
26
+ @metadata.to_h.merge bibtex_key: bibtex_key
27
+ end
28
+
29
+ def bibtex_key
30
+ Downloader::Bibtex.new(@metadata).key
31
+ end
32
+
33
+ def stringify object
34
+ case object
35
+ when Hash then object.to_h { |key, value| [key.to_s, stringify(value)] }
36
+ when Array then object.map { |item| stringify item }
37
+ else object
38
+ end
39
+ end
40
+
41
+ def body
42
+ <<~MARKDOWN
43
+
44
+ # #{@metadata.title}
45
+
46
+ #{authors_list}
47
+
48
+ - Published: #{@metadata.published.iso8601}
49
+ - Primary category: #{@metadata.primary_category[:id]} — #{@metadata.primary_category[:name]} (#{@metadata.primary_category[:group]})
50
+ - arXiv: [#{@metadata.arxiv_id}](#{@metadata.arxiv_url})
51
+ - [PDF](#{@metadata.pdf_url})
52
+
53
+ ## Abstract
54
+
55
+ #{@metadata.abstract}
56
+ MARKDOWN
57
+ end
58
+
59
+ def authors_list
60
+ @metadata.authors.map { |author| "- #{author}" }.join "\n"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,31 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ module Arxiv
5
+ module Downloader
6
+ class Metadata
7
+ class YAML
8
+ FILENAME = 'metadata.yaml'.freeze
9
+
10
+ def initialize metadata
11
+ @metadata = metadata
12
+ end
13
+
14
+ def write to:
15
+ FileUtils.mkdir_p to
16
+ File.write File.join(to, FILENAME), ::YAML.dump(stringify(@metadata.to_h))
17
+ end
18
+
19
+ private
20
+
21
+ def stringify object
22
+ case object
23
+ when Hash then object.to_h { |key, value| [key.to_s, stringify(value)] }
24
+ when Array then object.map { |item| stringify item }
25
+ else object
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ module Arxiv
2
+ module Downloader
3
+ Metadata = Data.define(
4
+ :arxiv_id,
5
+ :arxiv_url,
6
+ :pdf_url,
7
+ :title,
8
+ :authors,
9
+ :abstract,
10
+ :published,
11
+ :updated,
12
+ :primary_category,
13
+ :categories,
14
+ :comment,
15
+ :doi,
16
+ :journal_ref
17
+ )
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ module Arxiv
2
+ module Downloader
3
+ class Path
4
+ def initialize metadata
5
+ @metadata = metadata
6
+ end
7
+
8
+ def to_s
9
+ [date_dir, category, "#{filesystem_safe_id}-#{slug}"].join '/'
10
+ end
11
+
12
+ private
13
+
14
+ def date_dir
15
+ @metadata.published.strftime '%Y/%m/%d'
16
+ end
17
+
18
+ def category
19
+ @metadata.primary_category[:id]
20
+ end
21
+
22
+ def filesystem_safe_id
23
+ @metadata.arxiv_id.tr '/', '-'
24
+ end
25
+
26
+ def slug
27
+ Slug.new(@metadata.title).to_s
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ module Arxiv
2
+ module Downloader
3
+ class PDF
4
+ def initialize identifier, client:
5
+ @identifier = identifier
6
+ @client = client
7
+ end
8
+
9
+ def download to:
10
+ File.binwrite to, @client.get(url).to_s
11
+ end
12
+
13
+ private
14
+
15
+ def url
16
+ "https://arxiv.org/pdf/#{@identifier.id}.pdf"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,44 @@
1
+ require 'stringex'
2
+
3
+ module Arxiv
4
+ module Downloader
5
+ class Slug
6
+ MAX_LENGTH = 80
7
+
8
+ TEX_INLINE_MATH = /\$[^$]*\$/ # $...$
9
+ TEX_DISPLAY_MATH = /\\\(.*?\\\)|\\\[.*?\\\]/m # \(...\) or \[...\]
10
+ TEX_COMMAND = /\\[a-zA-Z]+\*?/ # \emph, \alpha, etc.
11
+
12
+ def initialize title
13
+ @title = title
14
+ end
15
+
16
+ def to_s
17
+ truncate strip_tex(@title).to_url
18
+ end
19
+
20
+ private
21
+
22
+ def strip_tex string
23
+ string
24
+ .gsub(TEX_INLINE_MATH, ' ')
25
+ .gsub(TEX_DISPLAY_MATH, ' ')
26
+ .gsub(TEX_COMMAND, ' ')
27
+ end
28
+
29
+ def truncate slug
30
+ return slug if slug.length <= MAX_LENGTH
31
+
32
+ words = slug.split '-'
33
+ result = +''
34
+ words.each do |word|
35
+ break if result.length + 1 + word.length > MAX_LENGTH
36
+
37
+ result << '-' unless result.empty?
38
+ result << word
39
+ end
40
+ result
41
+ end
42
+ end
43
+ end
44
+ end