jstor-dl 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd0503a0a9da1a8298c012fab0dd19f866e907a87a58537f6f1b26174b3bb1ab
4
- data.tar.gz: 20779888e79ac989cc10b14ac96877b9200641f7e4cc3fe679f252b4602b1570
3
+ metadata.gz: 7711d17b180cc39c3938b9da490dab3f6a13d4ee692321d1ddda127e154c0453
4
+ data.tar.gz: f3374bc83297a607be90e3423b22602ec59ac9bf014fcaf27c85eff6553e7a14
5
5
  SHA512:
6
- metadata.gz: 6839ae6451e76bfe92b282b4829a7dcea3b117c103bf68d345ad8e91d7a922d56d30bd27007a3be8c0920ac219c67c0b04ef98afa0e0206e0e9e7e8b3ebdf758
7
- data.tar.gz: d0a458946ce663a24638a25b4fbc23d8579352c101105cb371e764b086524fef22723080e93314512cdfcdd0a8fd061cfb929edaac067f1c06087e24cef0d392
6
+ metadata.gz: 54e430f87d37a570476b5c3063f5b81f878721c384e23c21157a2ef96a9103a6fec9a04b17df18ba6cf29d389c200d4048df247753f7314bc48dc81878f71661
7
+ data.tar.gz: 9843468654206d7ded8033c86c943006ff042d97c036dc300020bd8007df27674fe18467b6436c1020036a9fef146156b2be41bdc6413e7ee348b3dd280fbe69
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## [0.1.1]
2
+
3
+ - Built on [dl-core](https://github.com/xoengineering/dl-core) 0.1: the HTTP client, errors, `Author`, `Slug`, sidecar writers, and CLI now come from it instead of copies. No change in behavior or output. `Jstor::Downloader::Client`, `HTTPError`, `Author`, `Slug`, and `Metadata::YAML`/`JSON` still work; they are now `dl-core`'s classes. `Jstor::Downloader::Error` now subclasses `DL::Core::Error`.
4
+
1
5
  ## [0.1.0]
2
6
 
3
7
  First version. Per-article offline archive of JSTOR's public-domain Early Journal Content, fetched only from the Internet Archive's copy, never from jstor.org.
@@ -1,11 +1,5 @@
1
1
  module Jstor
2
2
  module Downloader
3
- Author = Data.define :name, :affiliations do
4
- def initialize name:, affiliations: []
5
- super
6
- end
7
-
8
- def to_s = name
9
- end
3
+ Author = DL::Core::Author
10
4
  end
11
5
  end
@@ -1,106 +1,24 @@
1
- require 'optparse'
2
-
3
1
  module Jstor
4
2
  module Downloader
5
- class CLI
6
- DEFAULT_DOWNLOAD_PATH = File.join Dir.home, 'Downloads', 'JSTOR_Papers'
7
- USAGE = 'Usage: jstor-dl [options] <JSTOR_ID_OR_URL> [<JSTOR_ID_OR_URL>...]'.freeze
8
-
9
- def initialize argv, stderr: $stderr, stdin: $stdin, stdout: $stdout
10
- @argv = argv
11
- @stderr = stderr
12
- @stdin = stdin
13
- @stdout = stdout
14
- end
15
-
16
- def run
17
- options = parse
18
- return options.fetch(:exit_status) if options.key? :exit_status
19
-
20
- return error_with USAGE if options[:targets].empty?
21
- return error_with conflict if options[:verbose] && options[:quiet]
22
-
23
- failures = download_each options
24
- failures.zero? ? 0 : 1
25
- end
26
-
27
- private
28
-
29
- def conflict
30
- '-v and -q are mutually exclusive'
31
- end
32
-
33
- def parse
34
- options = { targets: [], verbose: false, quiet: false }
35
- parser = build_parser options
36
-
37
- begin
38
- parser.parse! @argv
39
- options[:targets] = @argv + input_targets(options[:input])
40
- rescue OptionParser::ParseError, SystemCallError => e
41
- @stderr.puts e.message
42
- return { exit_status: 1 }
43
- end
44
-
45
- options[:path] ||= ENV['JSTOR_DOWNLOAD_PATH'] || DEFAULT_DOWNLOAD_PATH
46
- options[:rate_limit] ||= (ENV['JSTOR_RATE_LIMIT'] || Client::DEFAULT_RATE_LIMIT).to_i
47
- options
48
- end
3
+ class CLI < DL::Core::CLI
4
+ def program = 'jstor-dl'
49
5
 
50
- def build_parser options
51
- OptionParser.new do |parser|
52
- parser.banner = USAGE
53
- parser.on('-i FILE', '--input FILE') { |value| options[:input] = value }
54
- parser.on('-p PATH', '--path PATH') { |value| options[:path] = value }
55
- parser.on('--rate-limit SECONDS', Integer) { |value| options[:rate_limit] = value }
56
- parser.on('-v', '--verbose') { options[:verbose] = true }
57
- parser.on('-q', '--quiet') { options[:quiet] = true }
58
- parser.on('--version') do
59
- @stdout.puts VERSION
60
- options[:exit_status] = 0
61
- end
62
- parser.on('-h', '--help') do
63
- @stdout.puts parser.help
64
- options[:exit_status] = 0
65
- end
66
- end
67
- end
6
+ def target_name = 'JSTOR_ID_OR_URL'
68
7
 
69
- # one target per line from FILE, or stdin for "-"; blank lines and # comments skipped
70
- def input_targets input
71
- return [] if input.nil?
8
+ # JSTOR_DOWNLOAD_PATH, JSTOR_RATE_LIMIT
9
+ def env_prefix = 'JSTOR'
72
10
 
73
- text = input == '-' ? @stdin.read : File.read(input)
74
- text.lines.map(&:strip).reject { it.empty? || it.start_with?('#') }
75
- end
11
+ def default_path = File.join(Dir.home, 'Downloads', 'JSTOR_Papers')
76
12
 
77
- def error_with message
78
- @stderr.puts message
79
- 1
80
- end
13
+ def version = VERSION
81
14
 
82
- def download_each options
83
- client = Client.new rate_limit: options[:rate_limit], log: (options[:verbose] ? @stdout : nil)
15
+ def user_agent = Client::USER_AGENT
84
16
 
85
- failures = 0
86
- options[:targets].each do |target|
87
- failures += 1 unless download_one(target, client:, options:)
88
- end
89
- failures
90
- end
17
+ def client_for(rate_limit:, log:) = Client.new(rate_limit:, log:)
91
18
 
92
- # true on success; reports the failure and returns false otherwise
93
- def download_one target, client:, options:
94
- identifier = Identifier.new target
95
- @stdout.puts "==> Downloading #{identifier.id}" if options[:verbose]
19
+ def identifier_for(target) = Identifier.new(target)
96
20
 
97
- path = Archive.new(identifier, root: options[:path], client: client).run
98
- @stdout.puts path unless options[:quiet]
99
- true
100
- rescue Error, HTTP::Error => e
101
- @stderr.puts "#{target}: #{e.message}"
102
- false
103
- end
21
+ def archive_for(identifier, root:, client:) = Archive.new(identifier, root:, client:)
104
22
  end
105
23
  end
106
24
  end
@@ -1,85 +1,12 @@
1
- require 'http'
2
-
3
1
  module Jstor
4
2
  module Downloader
5
- class Client
6
- SOURCE_URL = 'https://github.com/xoengineering/jstor-dl'.freeze
7
- DEFAULT_RATE_LIMIT = 3
8
- TIMEOUTS = { connect: 10, read: 60, write: 10 }.freeze # seconds, per operation
9
- MAX_RETRIES = 3
10
- RETRY_BACKOFF = 10 # seconds before the first retry. doubles on each retry.
11
- RETRYABLE_STATUSES = [429, 503].freeze
12
-
13
- attr_reader :rate_limit
14
-
15
- def initialize rate_limit: DEFAULT_RATE_LIMIT, log: nil
16
- @rate_limit = rate_limit
17
- @log = log
18
- end
19
-
20
- def user_agent
21
- "jstor-dl/#{VERSION} (+#{SOURCE_URL})"
22
- end
23
-
24
- def get url
25
- retries = 0
26
-
27
- loop do
28
- response = request url
29
- return response if response.status.success?
30
- raise http_error(url, response) unless retryable? response, retries
31
-
32
- retries += 1
33
- wait_before_retry response, retries
34
- end
35
- end
36
-
37
- private
38
-
39
- def request url
40
- throttle
41
- response = HTTP.timeout(TIMEOUTS).headers('User-Agent' => user_agent).follow.get(url)
42
- @last_request_at = Time.now
43
- log_request url, response
44
- response
45
- end
46
-
47
- def http_error url, response
48
- HTTPError.new status: response.status.code, url: url, reason: response.status.reason
49
- end
50
-
51
- def retryable? response, retries
52
- RETRYABLE_STATUSES.include?(response.status.code) && retries < MAX_RETRIES
53
- end
54
-
55
- def wait_before_retry response, retries
56
- seconds = retry_after(response) || (RETRY_BACKOFF * (2**(retries - 1)))
57
- @log&.puts "==> #{response.status}. Retrying in #{seconds}s"
58
- sleep seconds
59
- end
60
-
61
- # Retry-After in delay-seconds form. The HTTP-date form falls back to backoff.
62
- def retry_after response
63
- value = response.headers['Retry-After']
64
- return if value.nil?
65
-
66
- Integer(value, exception: false)
67
- end
68
-
69
- def log_request url, response
70
- return if @log.nil?
71
-
72
- @log.puts "==> GET #{url} (#{response.body.to_s.bytesize} bytes)"
73
- end
74
-
75
- def throttle
76
- return if @rate_limit.zero?
77
- return if @last_request_at.nil?
78
-
79
- elapsed = Time.now - @last_request_at
80
- return if elapsed >= @rate_limit
3
+ # DL::Core::Client with jstor-dl's User-Agent filled in
4
+ class Client < DL::Core::Client
5
+ SOURCE_URL = 'https://github.com/xoengineering/jstor-dl'.freeze
6
+ USER_AGENT = "jstor-dl/#{VERSION} (+#{SOURCE_URL})".freeze
81
7
 
82
- sleep(@rate_limit - elapsed)
8
+ def initialize log: nil, rate_limit: DEFAULT_RATE_LIMIT
9
+ super(user_agent: USER_AGENT, log:, rate_limit:)
83
10
  end
84
11
  end
85
12
  end
@@ -1,5 +1,5 @@
1
1
  module Jstor
2
2
  module Downloader
3
- class Error < StandardError; end
3
+ class Error < DL::Core::Error; end
4
4
  end
5
5
  end
@@ -1,14 +1,5 @@
1
1
  module Jstor
2
2
  module Downloader
3
- class HTTPError < Error
4
- attr_reader :status, :url
5
-
6
- def initialize status:, url:, reason: nil
7
- @status = status
8
- @url = url
9
-
10
- super("GET #{url} failed: #{[status, reason].compact.join ' '}")
11
- end
12
- end
3
+ HTTPError = DL::Core::HTTPError
13
4
  end
14
5
  end
@@ -1,33 +1,7 @@
1
- require 'fileutils'
2
- require 'json'
3
-
4
1
  module Jstor
5
2
  module Downloader
6
3
  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 Data then serialize object.to_h
26
- when Date, Time then object.iso8601
27
- else object
28
- end
29
- end
30
- end
4
+ JSON = DL::Core::Sidecar::JSON
31
5
  end
32
6
  end
33
7
  end
@@ -1,44 +1,22 @@
1
- require 'fileutils'
2
- require 'yaml'
3
-
4
1
  module Jstor
5
2
  module Downloader
6
3
  class Metadata
4
+ # metadata.md: dl-core writes the frontmatter; the body is JSTOR-specific
7
5
  class Markdown
8
- FILENAME = 'metadata.md'.freeze
9
-
10
6
  def initialize metadata
11
7
  @metadata = metadata
12
8
  end
13
9
 
14
10
  def write to:
15
- FileUtils.mkdir_p to
16
- File.write File.join(to, FILENAME), "#{frontmatter}\n#{body}"
11
+ DL::Core::Sidecar::Markdown.new(@metadata, body:, extras: { bibtex_key: }).write to:
17
12
  end
18
13
 
19
14
  private
20
15
 
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
16
  def bibtex_key
30
17
  Downloader::Bibtex.new(@metadata).key
31
18
  end
32
19
 
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
- when Data then stringify object.to_h
38
- else object
39
- end
40
- end
41
-
42
20
  # JSTOR asks for acknowledgement as the source of Early Journal Content
43
21
  def body
44
22
  <<~MARKDOWN
@@ -1,32 +1,7 @@
1
- require 'fileutils'
2
- require 'yaml'
3
-
4
1
  module Jstor
5
2
  module Downloader
6
3
  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
- when Data then stringify object.to_h
26
- else object
27
- end
28
- end
29
- end
4
+ YAML = DL::Core::Sidecar::YAML
30
5
  end
31
6
  end
32
7
  end
@@ -1,44 +1,5 @@
1
- require 'stringex'
2
-
3
1
  module Jstor
4
2
  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
3
+ Slug = DL::Core::Slug
43
4
  end
44
5
  end
@@ -1,5 +1,5 @@
1
1
  module Jstor
2
2
  module Downloader
3
- VERSION = '0.1.0'.freeze
3
+ VERSION = '0.1.1'.freeze
4
4
  end
5
5
  end
@@ -1,10 +1,14 @@
1
+ require 'dl/core'
2
+
3
+ require_relative 'downloader/version' # before client: Client::USER_AGENT uses VERSION
4
+
1
5
  require_relative 'downloader/archive'
2
6
  require_relative 'downloader/author'
3
7
  require_relative 'downloader/bibtex'
4
8
  require_relative 'downloader/cli'
5
- require_relative 'downloader/client'
9
+ require_relative 'downloader/client' # after version
6
10
  require_relative 'downloader/error' # before errors below that subclass Error
7
- require_relative 'downloader/http_error' # after error
11
+ require_relative 'downloader/http_error'
8
12
  require_relative 'downloader/identifier' # after error
9
13
  require_relative 'downloader/item_file'
10
14
  require_relative 'downloader/item_not_found' # after error
@@ -16,7 +20,6 @@ require_relative 'downloader/metadata/yaml' # after metadata
16
20
  require_relative 'downloader/metadata_parser'
17
21
  require_relative 'downloader/path'
18
22
  require_relative 'downloader/slug'
19
- require_relative 'downloader/version'
20
23
 
21
24
  module Jstor
22
25
  module Downloader
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jstor-dl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shane Becker
@@ -10,47 +10,19 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: http
13
+ name: dl-core
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
18
+ version: '0.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '6.0'
26
- - !ruby/object:Gem::Dependency
27
- name: ostruct
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '0.6'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '0.6'
40
- - !ruby/object:Gem::Dependency
41
- name: stringex
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '2.8'
47
- type: :runtime
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '2.8'
25
+ version: '0.1'
54
26
  description: |
55
27
  Command line tool and Ruby library for archiving JSTOR Early Journal Content articles
56
28
  as PDFs and OCR plaintext with sidecar metadata. Fetches only from the Internet Archive's copy,