grubby 1.2.1 → 3.0.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.
data/lib/grubby.rb CHANGED
@@ -1,13 +1,10 @@
1
- require "active_support/all"
2
- require "casual_support"
1
+ require "pathname"
2
+
3
3
  require "gorge"
4
4
  require "mechanize"
5
- require "mini_sanity"
6
- require "pleasant_path"
7
5
  require "ryoba"
8
6
 
9
7
  require_relative "grubby/version"
10
- require_relative "grubby/log"
11
8
 
12
9
  require_relative "grubby/core_ext/string"
13
10
  require_relative "grubby/core_ext/uri"
@@ -23,22 +20,37 @@ class Grubby < Mechanize
23
20
 
24
21
  VERSION = GRUBBY_VERSION
25
22
 
26
- # The enforced minimum amount of time to wait between requests, in
27
- # seconds. If the value is a Range, a random number within the Range
28
- # is chosen for each request.
23
+ class << self
24
+ # Logger used by Grubby.
25
+ #
26
+ # @return [Logger]
27
+ def logger
28
+ @logger ||= Logger.new($stderr).tap do |logger|
29
+ logger.formatter = -> (severity, time, progname, msg) do
30
+ "[#{time.strftime "%Y-%m-%d %H:%M:%S"}] #{severity} #{msg}\n"
31
+ end
32
+ end
33
+ end
34
+
35
+ attr_writer :logger
36
+ end
37
+
38
+ # The minimum amount of time enforced between requests, in seconds.
39
+ # If the value is a Range, a random number within the Range is chosen
40
+ # for each request.
29
41
  #
30
42
  # @return [Integer, Float, Range<Integer>, Range<Float>]
31
43
  attr_accessor :time_between_requests
32
44
 
33
45
  # Journal file used to ensure only-once processing of resources by
34
- # {singleton} across multiple program runs.
46
+ # {fulfill} across multiple program runs.
35
47
  #
36
48
  # @return [Pathname, nil]
37
49
  attr_reader :journal
38
50
 
39
51
  # @param journal [Pathname, String]
40
52
  # Optional journal file used to ensure only-once processing of
41
- # resources by {singleton} across multiple program runs.
53
+ # resources by {fulfill} across multiple program runs
42
54
  def initialize(journal = nil)
43
55
  super()
44
56
 
@@ -74,26 +86,32 @@ class Grubby < Mechanize
74
86
  end
75
87
 
76
88
  # Sets the journal file used to ensure only-once processing of
77
- # resources by {singleton} across multiple program runs. Setting the
89
+ # resources by {fulfill} across multiple program runs. Setting the
78
90
  # journal file will clear the in-memory list of previously-processed
79
91
  # resources, and, if the journal file exists, load the list from file.
80
92
  #
81
93
  # @param path [Pathname, String, nil]
82
94
  # @return [Pathname]
83
95
  def journal=(path)
84
- @journal = path&.to_pathname&.touch_file
85
- @seen = if @journal
86
- require "csv"
87
- CSV.read(@journal).map{|row| SingletonKey.new(*row) }.to_set
88
- else
89
- Set.new
96
+ if path
97
+ @journal = Pathname(path)
98
+ @journal.dirname.mkpath
99
+ require "csv"
100
+ @fulfilled = CSV.open(@journal, "a+") do |csv|
101
+ csv.each.map{|row| FulfilledEntry.new(*row) }.to_set
90
102
  end
103
+ else
104
+ @journal = nil
105
+ @fulfilled = Set.new
106
+ end
107
+
91
108
  @journal
92
109
  end
93
110
 
94
- # Calls +#head+ and returns true if the result has response code
95
- # "200". Unlike +#head+, error response codes (e.g. "404", "500")
96
- # do not cause a +Mechanize::ResponseCodeError+ to be raised.
111
+ # Calls +#head+ and returns true if a response code "200" is received,
112
+ # false otherwise. Unlike +#head+, error response codes (e.g. "404",
113
+ # "500") do not result in a +Mechanize::ResponseCodeError+ being
114
+ # raised.
97
115
  #
98
116
  # @param uri [URI, String]
99
117
  # @return [Boolean]
@@ -106,7 +124,7 @@ class Grubby < Mechanize
106
124
  end
107
125
 
108
126
  # Calls +#get+ with each of +mirror_uris+ until a successful
109
- # ("200 OK") response is recieved, and returns that +#get+ result.
127
+ # ("200 OK") response is received, and returns that +#get+ result.
110
128
  # Rescues and logs +Mechanize::ResponseCodeError+ failures for all but
111
129
  # the last mirror.
112
130
  #
@@ -114,13 +132,13 @@ class Grubby < Mechanize
114
132
  # grubby = Grubby.new
115
133
  #
116
134
  # urls = [
117
- # "http://httpstat.us/404",
118
- # "http://httpstat.us/500",
119
- # "http://httpstat.us/200#foo",
120
- # "http://httpstat.us/200#bar",
135
+ # "https://httpstat.us/404",
136
+ # "https://httpstat.us/500",
137
+ # "https://httpstat.us/200?foo",
138
+ # "https://httpstat.us/200?bar",
121
139
  # ]
122
140
  #
123
- # grubby.get_mirrored(urls).uri # == URI("http://httpstat.us/200#foo")
141
+ # grubby.get_mirrored(urls).uri # == URI("https://httpstat.us/200?foo")
124
142
  #
125
143
  # grubby.get_mirrored(urls.take(2)) # raise Mechanize::ResponseCodeError
126
144
  #
@@ -137,84 +155,101 @@ class Grubby < Mechanize
137
155
  if i >= mirror_uris.length
138
156
  raise
139
157
  else
140
- $log.debug("Mirror failed (code #{e.response_code}): #{mirror_uris[i - 1]}")
141
- $log.debug("Try mirror: #{mirror_uris[i]}")
158
+ Grubby.logger.debug("Mirror failed (code #{e.response_code}): #{mirror_uris[i - 1]}")
159
+ Grubby.logger.debug("Try mirror: #{mirror_uris[i]}")
142
160
  retry
143
161
  end
144
162
  end
145
163
  end
146
164
 
147
165
  # Ensures only-once processing of the resource indicated by +uri+ for
148
- # the specified +purpose+. A list of previously-processed resource
149
- # URIs and content hashes is maintained in the Grubby instance. The
150
- # given block is called with the fetched resource only if the
151
- # resource's URI and the resource's content hash have not been
152
- # previously processed under the specified +purpose+.
166
+ # the specified +purpose+. The given block is executed and the result
167
+ # is returned if and only if the Grubby instance has not recorded a
168
+ # previous call to +fulfill+ for the same resource and purpose.
169
+ #
170
+ # Note that the resource is identified by both its URI and its content
171
+ # hash. The latter prevents superfluous and rearranged URI query
172
+ # string parameters from interfering with only-once processing.
173
+ #
174
+ # If {journal} is set, and if the block does not raise an exception,
175
+ # the resource and purpose are logged to the journal file. This
176
+ # enables only-once processing across multiple program runs. It also
177
+ # provides a means to resume batch processing after an unexpected
178
+ # termination.
153
179
  #
154
180
  # @example
155
181
  # grubby = Grubby.new
156
182
  #
157
- # grubby.singleton("https://example.com/foo") do |page|
158
- # # will be executed (first time "/foo")
183
+ # grubby.fulfill("https://example.com/posts") do |page|
184
+ # "first time"
159
185
  # end
186
+ # # == "first time"
160
187
  #
161
- # grubby.singleton("https://example.com/foo#bar") do |page|
162
- # # will be skipped (already seen "/foo")
188
+ # grubby.fulfill("https://example.com/posts") do |page|
189
+ # "already seen" # not evaluated
163
190
  # end
191
+ # # == nil
164
192
  #
165
- # grubby.singleton("https://example.com/foo", "again!") do |page|
166
- # # will be executed (new purpose for "/foo")
193
+ # grubby.fulfill("https://example.com/posts?page=1") do |page|
194
+ # "already seen content hash" # not evaluated
167
195
  # end
196
+ # # == nil
197
+ #
198
+ # grubby.fulfill("https://example.com/posts", "again!") do |page|
199
+ # "already seen, but new purpose"
200
+ # end
201
+ # # == "already seen, but new purpose"
168
202
  #
169
203
  # @param uri [URI, String]
170
204
  # @param purpose [String]
171
- # @yield [resource]
172
205
  # @yieldparam resource [Mechanize::Page, Mechanize::File, Mechanize::Download, ...]
173
- # @return [Boolean]
174
- # whether the given block was called
206
+ # @yieldreturn [Object]
207
+ # @return [Object, nil]
175
208
  # @raise [Mechanize::ResponseCodeError]
176
209
  # if fetching the resource results in error (see +Mechanize#get+)
177
- def singleton(uri, purpose = "")
210
+ def fulfill(uri, purpose = "")
178
211
  series = []
179
212
 
180
213
  uri = uri.to_absolute_uri
181
- return if try_skip_singleton(uri, purpose, series)
214
+ return unless add_fulfilled(uri, purpose, series)
182
215
 
183
216
  normalized_uri = normalize_uri(uri)
184
- return if try_skip_singleton(normalized_uri, purpose, series)
217
+ return unless add_fulfilled(normalized_uri, purpose, series)
185
218
 
186
- $log.info("Fetch #{normalized_uri}")
219
+ Grubby.logger.info("Fetch #{normalized_uri}")
187
220
  resource = get(normalized_uri)
188
- skip = try_skip_singleton(resource.uri, purpose, series) |
189
- try_skip_singleton("content hash: #{resource.content_hash}", purpose, series)
221
+ unprocessed = add_fulfilled(resource.uri, purpose, series) &
222
+ add_fulfilled("content hash: #{resource.content_hash}", purpose, series)
190
223
 
191
- yield resource unless skip
224
+ result = yield resource if unprocessed
192
225
 
193
226
  CSV.open(journal, "a") do |csv|
194
- series.each{|singleton_key| csv << singleton_key }
227
+ series.each{|entry| csv << entry }
195
228
  end if journal
196
229
 
197
- !skip
230
+ result
198
231
  end
199
232
 
200
233
 
201
234
  private
202
235
 
203
236
  # @!visibility private
204
- SingletonKey = Struct.new(:purpose, :target)
237
+ FulfilledEntry = Struct.new(:purpose, :target)
205
238
 
206
- def try_skip_singleton(target, purpose, series)
207
- series << SingletonKey.new(purpose, target.to_s)
208
- if series.uniq!.nil? && !@seen.add?(series.last)
209
- seen_info = series.length > 1 ? "seen #{series.last.target}" : "seen"
210
- $log.info("Skip #{series.first.target} (#{seen_info})")
239
+ def add_fulfilled(target, purpose, series)
240
+ series << FulfilledEntry.new(purpose, target.to_s)
241
+ if (series.uniq!) || @fulfilled.add?(series.last)
211
242
  true
243
+ else
244
+ Grubby.logger.info("Skip #{series.first.target}" \
245
+ " (seen#{" #{series.last.target}" unless series.length == 1})")
246
+ false
212
247
  end
213
248
  end
214
249
 
215
250
  def normalize_uri(uri)
216
251
  uri = uri.dup
217
- $log.warn("Ignore ##{uri.fragment} in #{uri}") if uri.fragment
252
+ Grubby.logger.warn("Ignore ##{uri.fragment} in #{uri}") if uri.fragment
218
253
  uri.fragment = nil
219
254
  uri.path = uri.path.chomp("/")
220
255
  uri
metadata CHANGED
@@ -1,55 +1,54 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grubby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Hefner
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2019-08-18 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: activesupport
13
+ name: cgi
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '5.0'
18
+ version: '0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '5.0'
25
+ version: '0'
27
26
  - !ruby/object:Gem::Dependency
28
- name: casual_support
27
+ name: csv
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - "~>"
30
+ - - ">="
32
31
  - !ruby/object:Gem::Version
33
- version: '3.0'
32
+ version: '0'
34
33
  type: :runtime
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
- - - "~>"
37
+ - - ">="
39
38
  - !ruby/object:Gem::Version
40
- version: '3.0'
39
+ version: '0'
41
40
  - !ruby/object:Gem::Dependency
42
41
  name: gorge
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
- - - "~>"
44
+ - - ">="
46
45
  - !ruby/object:Gem::Version
47
46
  version: '1.0'
48
47
  type: :runtime
49
48
  prerelease: false
50
49
  version_requirements: !ruby/object:Gem::Requirement
51
50
  requirements:
52
- - - "~>"
51
+ - - ">="
53
52
  - !ruby/object:Gem::Version
54
53
  version: '1.0'
55
54
  - !ruby/object:Gem::Dependency
@@ -66,113 +65,26 @@ dependencies:
66
65
  - - "~>"
67
66
  - !ruby/object:Gem::Version
68
67
  version: '2.7'
69
- - !ruby/object:Gem::Dependency
70
- name: mini_sanity
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - "~>"
74
- - !ruby/object:Gem::Version
75
- version: '1.0'
76
- type: :runtime
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - "~>"
81
- - !ruby/object:Gem::Version
82
- version: '1.0'
83
- - !ruby/object:Gem::Dependency
84
- name: pleasant_path
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - "~>"
88
- - !ruby/object:Gem::Version
89
- version: '1.1'
90
- type: :runtime
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - "~>"
95
- - !ruby/object:Gem::Version
96
- version: '1.1'
97
68
  - !ruby/object:Gem::Dependency
98
69
  name: ryoba
99
70
  requirement: !ruby/object:Gem::Requirement
100
71
  requirements:
101
- - - "~>"
72
+ - - ">="
102
73
  - !ruby/object:Gem::Version
103
74
  version: '1.0'
104
75
  type: :runtime
105
76
  prerelease: false
106
77
  version_requirements: !ruby/object:Gem::Requirement
107
78
  requirements:
108
- - - "~>"
79
+ - - ">="
109
80
  - !ruby/object:Gem::Version
110
81
  version: '1.0'
111
- - !ruby/object:Gem::Dependency
112
- name: bundler
113
- requirement: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - "~>"
116
- - !ruby/object:Gem::Version
117
- version: '1.15'
118
- type: :development
119
- prerelease: false
120
- version_requirements: !ruby/object:Gem::Requirement
121
- requirements:
122
- - - "~>"
123
- - !ruby/object:Gem::Version
124
- version: '1.15'
125
- - !ruby/object:Gem::Dependency
126
- name: rake
127
- requirement: !ruby/object:Gem::Requirement
128
- requirements:
129
- - - "~>"
130
- - !ruby/object:Gem::Version
131
- version: '10.0'
132
- type: :development
133
- prerelease: false
134
- version_requirements: !ruby/object:Gem::Requirement
135
- requirements:
136
- - - "~>"
137
- - !ruby/object:Gem::Version
138
- version: '10.0'
139
- - !ruby/object:Gem::Dependency
140
- name: minitest
141
- requirement: !ruby/object:Gem::Requirement
142
- requirements:
143
- - - "~>"
144
- - !ruby/object:Gem::Version
145
- version: '5.0'
146
- type: :development
147
- prerelease: false
148
- version_requirements: !ruby/object:Gem::Requirement
149
- requirements:
150
- - - "~>"
151
- - !ruby/object:Gem::Version
152
- version: '5.0'
153
- - !ruby/object:Gem::Dependency
154
- name: yard
155
- requirement: !ruby/object:Gem::Requirement
156
- requirements:
157
- - - "~>"
158
- - !ruby/object:Gem::Version
159
- version: '0.9'
160
- type: :development
161
- prerelease: false
162
- version_requirements: !ruby/object:Gem::Requirement
163
- requirements:
164
- - - "~>"
165
- - !ruby/object:Gem::Version
166
- version: '0.9'
167
- description:
168
82
  email:
169
83
  - jonathan@hefner.pro
170
84
  executables: []
171
85
  extensions: []
172
86
  extra_rdoc_files: []
173
87
  files:
174
- - ".gitignore"
175
- - ".travis.yml"
176
88
  - CHANGELOG.md
177
89
  - Gemfile
178
90
  - LICENSE.txt
@@ -184,7 +96,6 @@ files:
184
96
  - lib/grubby/core_ext/uri.rb
185
97
  - lib/grubby/json_parser.rb
186
98
  - lib/grubby/json_scraper.rb
187
- - lib/grubby/log.rb
188
99
  - lib/grubby/mechanize/download.rb
189
100
  - lib/grubby/mechanize/fetch_with_retry.rb
190
101
  - lib/grubby/mechanize/file.rb
@@ -197,8 +108,9 @@ files:
197
108
  homepage: https://github.com/jonathanhefner/grubby
198
109
  licenses:
199
110
  - MIT
200
- metadata: {}
201
- post_install_message:
111
+ metadata:
112
+ source_code_uri: https://github.com/jonathanhefner/grubby
113
+ changelog_uri: https://github.com/jonathanhefner/grubby/blob/master/CHANGELOG.md
202
114
  rdoc_options: []
203
115
  require_paths:
204
116
  - lib
@@ -206,15 +118,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
206
118
  requirements:
207
119
  - - ">="
208
120
  - !ruby/object:Gem::Version
209
- version: '0'
121
+ version: '3.4'
210
122
  required_rubygems_version: !ruby/object:Gem::Requirement
211
123
  requirements:
212
124
  - - ">="
213
125
  - !ruby/object:Gem::Version
214
126
  version: '0'
215
127
  requirements: []
216
- rubygems_version: 3.0.1
217
- signing_key:
128
+ rubygems_version: 4.0.10
218
129
  specification_version: 4
219
130
  summary: Fail-fast web scraping
220
131
  test_files: []
data/.gitignore DELETED
@@ -1,9 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /Gemfile.lock
4
- /_yardoc/
5
- /coverage/
6
- /doc/
7
- /pkg/
8
- /spec/reports/
9
- /tmp/
data/.travis.yml DELETED
@@ -1,5 +0,0 @@
1
- sudo: false
2
- language: ruby
3
- rvm:
4
- - 2.2.5
5
- before_install: gem install bundler -v 1.15.1
data/lib/grubby/log.rb DELETED
@@ -1,5 +0,0 @@
1
- $log ||= Logger.new($stderr).tap do |logger|
2
- logger.formatter = ->(severity, datetime, progname, msg) do
3
- "[#{datetime.to_ymd} #{datetime.to_hms}] #{severity} #{msg}\n"
4
- end
5
- end