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 +7 -0
- data/CHANGELOG.md +38 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.md +21 -0
- data/README.md +141 -0
- data/exe/arxiv-dl +5 -0
- data/lib/arxiv/downloader/abstract_page.rb +20 -0
- data/lib/arxiv/downloader/archive.rb +68 -0
- data/lib/arxiv/downloader/assets_cache.rb +32 -0
- data/lib/arxiv/downloader/bibtex.rb +55 -0
- data/lib/arxiv/downloader/categories.rb +20 -0
- data/lib/arxiv/downloader/categories.yaml +624 -0
- data/lib/arxiv/downloader/cli.rb +85 -0
- data/lib/arxiv/downloader/client.rb +47 -0
- data/lib/arxiv/downloader/error.rb +5 -0
- data/lib/arxiv/downloader/feed_parser.rb +61 -0
- data/lib/arxiv/downloader/html_archive.rb +69 -0
- data/lib/arxiv/downloader/identifier.rb +135 -0
- data/lib/arxiv/downloader/metadata/bibtex.rb +21 -0
- data/lib/arxiv/downloader/metadata/json.rb +32 -0
- data/lib/arxiv/downloader/metadata/markdown.rb +65 -0
- data/lib/arxiv/downloader/metadata/yaml.rb +31 -0
- data/lib/arxiv/downloader/metadata.rb +19 -0
- data/lib/arxiv/downloader/path.rb +31 -0
- data/lib/arxiv/downloader/pdf.rb +20 -0
- data/lib/arxiv/downloader/slug.rb +44 -0
- data/lib/arxiv/downloader/source_archive.rb +43 -0
- data/lib/arxiv/downloader/version.rb +5 -0
- data/lib/arxiv/downloader.rb +26 -0
- data/sig/arxiv/downloader.rbs +5 -0
- metadata +146 -0
|
@@ -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,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
|