fetch_util 0.3.1 → 0.3.2

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.
@@ -38,11 +38,11 @@ module FetchUtil
38
38
  work = Array(urls).compact.map(&:to_s).reject(&:empty?)
39
39
  return [] if work.empty?
40
40
 
41
- jobs = Queue.new
42
- failures = Queue.new
43
- work.each_with_index { |url, index| jobs << [index, url] }
44
41
  results = Array.new(work.length)
45
42
  worker_count = [@concurrency, work.length].min
43
+ failures = []
44
+ next_index = 0
45
+ mutex = Mutex.new
46
46
 
47
47
  threads = Array.new(worker_count) do
48
48
  Thread.new do
@@ -50,44 +50,39 @@ module FetchUtil
50
50
 
51
51
  begin
52
52
  loop do
53
- begin
54
- index, url = jobs.pop(true)
55
- rescue ThreadError
56
- break
53
+ index = mutex.synchronize do
54
+ if next_index < work.length
55
+ current = next_index
56
+ next_index += 1
57
+ current
58
+ end
57
59
  end
60
+ break if index.nil?
61
+
62
+ url = work[index]
58
63
 
59
64
  begin
60
65
  results[index] = fetcher.fetch(url)
61
66
  rescue StandardError => e
62
- failures << Failure.new(index: index, url: url, error: e)
67
+ mutex.synchronize { failures << Failure.new(index: index, url: url, error: e) }
63
68
  end
64
69
  end
65
70
  ensure
66
71
  fetcher.quit if fetcher.respond_to?(:quit)
67
72
  end
68
73
  rescue StandardError => e
69
- failures << Failure.new(index: nil, url: nil, error: e)
74
+ mutex.synchronize { failures << Failure.new(index: nil, url: nil, error: e) }
70
75
  end
71
76
  end
72
77
 
73
78
  threads.each(&:join)
74
- raise_for_failures(drain_queue(failures), results)
79
+ raise_for_failures(failures, results)
75
80
 
76
81
  results
77
82
  end
78
83
 
79
84
  private
80
85
 
81
- def drain_queue(queue)
82
- items = []
83
- loop do
84
- items << queue.pop(true)
85
- rescue ThreadError
86
- break
87
- end
88
- items
89
- end
90
-
91
86
  def raise_for_failures(failures, results)
92
87
  return if failures.empty?
93
88
 
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
- require "net/http"
5
4
  require "nokogiri"
6
5
  require "uri"
7
6
 
7
+ require_relative "regulatory/http_client"
8
+
8
9
  module FetchUtil
9
10
  class RawDocsFallback
10
11
  DEFAULT_HEADERS = {
@@ -47,8 +48,9 @@ module FetchUtil
47
48
  ].freeze
48
49
  PRUNED_TEXT_PATTERN = /\A(?:on this page|table of contents|edit this page|copy page|copy item path|search|settings|help|expand description)\z/i
49
50
 
50
- def initialize(timeout: 20)
51
+ def initialize(timeout: 20, http_client: nil)
51
52
  @timeout = timeout.to_i
53
+ @http_client = http_client || FetchUtil::HttpRedirectClient.new(timeout: @timeout, headers: DEFAULT_HEADERS)
52
54
  end
53
55
 
54
56
  def fetch(url)
@@ -272,26 +274,11 @@ module FetchUtil
272
274
  %(concat(#{parts.join(%q(, "'", ))}))
273
275
  end
274
276
 
275
- def fetch_html(url, limit: 5)
276
- raise URI::InvalidURIError, "too many redirects" if limit <= 0
277
-
278
- uri = URI.parse(url)
279
- request = Net::HTTP::Get.new(uri)
280
- DEFAULT_HEADERS.each { |key, value| request[key] = value }
281
-
282
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |http|
283
- http.request(request)
284
- end
277
+ def fetch_html(url)
278
+ response = @http_client.get(url)
279
+ raise Error, "HTTP #{response.status}" unless response.status.between?(200, 299)
285
280
 
286
- case response
287
- when Net::HTTPSuccess
288
- [uri.to_s, response.body]
289
- when Net::HTTPRedirection
290
- location = URI.join(uri, response["location"]).to_s
291
- fetch_html(location, limit: limit - 1)
292
- else
293
- raise Error, "HTTP #{response.code}"
294
- end
281
+ [response.url, response.body]
295
282
  end
296
283
  end
297
284
  end
@@ -1,70 +1,138 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "uri"
4
5
 
5
6
  module FetchUtil
6
- class Regulatory
7
- class HttpClient
8
- REDIRECT_LIMIT = 5
7
+ class HttpRedirectClient
8
+ REDIRECT_LIMIT = 5
9
+ TRANSIENT_ERRORS = [EOFError, IOError, SocketError, SystemCallError, Timeout::Error].freeze
10
+ Response = Struct.new(:url, :status, :headers, :body, :redirects, keyword_init: true)
9
11
 
10
- def initialize(timeout:, user_agent:)
11
- @timeout = timeout.to_f
12
- @user_agent = user_agent.to_s.strip
13
- end
12
+ def initialize(timeout:, headers: {})
13
+ @timeout = timeout.to_f
14
+ @headers = headers.reject { |_key, value| value.to_s.empty? }
15
+ end
14
16
 
15
- def get(url, limit: REDIRECT_LIMIT)
16
- uri = parse_http_uri(url)
17
- fetch(uri, limit, [])
18
- end
17
+ def get(url, limit: REDIRECT_LIMIT)
18
+ @connections = {}
19
+ fetch(parse_http_uri(url), limit, [])
20
+ ensure
21
+ close_connections
22
+ end
19
23
 
20
- private
24
+ private
21
25
 
22
- attr_reader :timeout, :user_agent
26
+ attr_reader :timeout, :headers
23
27
 
24
- def fetch(uri, limit, redirects)
25
- response = request(uri)
26
- return build_response(uri, response, redirects: redirects) unless response.is_a?(Net::HTTPRedirection)
28
+ def fetch(uri, limit, redirects)
29
+ response = request(uri)
30
+ return build_response(uri, response, redirects: redirects) unless response.is_a?(Net::HTTPRedirection)
27
31
 
28
- raise FetchUtil::Error, "too many redirects for #{uri}" if limit <= 0
32
+ raise FetchUtil::Error, "too many redirects for #{uri}" if limit <= 0
29
33
 
30
- location = response["location"].to_s.strip
31
- return build_response(uri, response, redirects: redirects) if location.empty?
34
+ location = response["location"].to_s.strip
35
+ return build_response(uri, response, redirects: redirects) if location.empty?
32
36
 
33
- redirect_response = build_response(uri, response)
34
- fetch(uri.merge(location), limit - 1, redirects + [redirect_response])
35
- end
37
+ redirect_response = build_response(uri, response)
38
+ fetch(uri.merge(location), limit - 1, redirects + [redirect_response])
39
+ end
36
40
 
37
- def request(uri)
38
- http = Net::HTTP.new(uri.host, uri.port)
39
- http.use_ssl = uri.scheme == "https"
40
- http.open_timeout = timeout
41
- http.read_timeout = timeout
41
+ def request(uri)
42
+ attempts = 0
43
+ begin
44
+ http = connection_for(uri)
42
45
  request = Net::HTTP::Get.new(uri.request_uri.empty? ? "/" : uri.request_uri)
43
- request["Accept"] = "text/html,application/json,text/plain,*/*"
44
- request["User-Agent"] = user_agent unless user_agent.empty?
46
+ headers.each { |key, value| request[key] = value }
45
47
  http.request(request)
48
+ rescue *TRANSIENT_ERRORS
49
+ close_connection(uri)
50
+ attempts += 1
51
+ retry if attempts <= 1
52
+ raise
46
53
  end
54
+ end
47
55
 
48
- def build_response(uri, response, redirects: [])
49
- FetchUtil::Regulatory::Response.new(
50
- url: uri.to_s,
51
- status: response.code.to_i,
52
- headers: response.to_hash.transform_keys(&:downcase),
53
- body: response.body.to_s,
54
- redirects: redirects
55
- )
56
+ def connection_for(uri)
57
+ key = [uri.scheme, uri.host, uri.port]
58
+ @connections[key] ||= Net::HTTP.start(
59
+ uri.host,
60
+ uri.port,
61
+ use_ssl: uri.scheme == "https",
62
+ open_timeout: timeout,
63
+ read_timeout: timeout
64
+ )
65
+ end
66
+
67
+ def close_connection(uri)
68
+ key = [uri.scheme, uri.host, uri.port]
69
+ @connections.delete(key)&.finish
70
+ rescue IOError
71
+ nil
72
+ end
73
+
74
+ def close_connections
75
+ @connections&.each_value do |http|
76
+ http.finish if http.started?
77
+ rescue IOError
78
+ nil
56
79
  end
80
+ @connections = nil
81
+ end
82
+
83
+ def build_response(uri, response, redirects: [])
84
+ Response.new(
85
+ url: uri.to_s,
86
+ status: response.code.to_i,
87
+ headers: response.to_hash.transform_keys(&:downcase),
88
+ body: response.body.to_s,
89
+ redirects: redirects
90
+ )
91
+ end
92
+
93
+ def parse_http_uri(url)
94
+ uri = URI.parse(url.to_s)
95
+ raise URI::InvalidURIError, "unsupported url: #{url}" unless uri.is_a?(URI::HTTP) && uri.host
96
+
97
+ uri
98
+ end
99
+ end
57
100
 
58
- def parse_http_uri(url)
59
- uri = URI.parse(url.to_s)
60
- unless uri.is_a?(URI::HTTP) && uri.host
61
- raise ArgumentError, "unsupported url: #{url}"
62
- end
101
+ class Regulatory
102
+ class HttpClient
103
+ DEFAULT_ACCEPT = "text/html,application/json,text/plain,*/*"
104
+
105
+ def initialize(timeout:, user_agent:, redirect_client: nil)
106
+ @user_agent = user_agent.to_s.strip
107
+ @redirect_client = redirect_client || FetchUtil::HttpRedirectClient.new(timeout: timeout, headers: request_headers)
108
+ end
63
109
 
64
- uri
110
+ def get(url, limit: FetchUtil::HttpRedirectClient::REDIRECT_LIMIT)
111
+ build_response(redirect_client.get(url, limit: limit))
65
112
  rescue URI::InvalidURIError
66
113
  raise ArgumentError, "unsupported url: #{url}"
67
114
  end
115
+
116
+ private
117
+
118
+ attr_reader :redirect_client, :user_agent
119
+
120
+ def build_response(response)
121
+ FetchUtil::Regulatory::Response.new(
122
+ url: response.url,
123
+ status: response.status,
124
+ headers: response.headers,
125
+ body: response.body,
126
+ redirects: response.redirects.map { |redirect| build_response(redirect) }
127
+ )
128
+ end
129
+
130
+ def request_headers
131
+ {
132
+ "Accept" => DEFAULT_ACCEPT,
133
+ "User-Agent" => user_agent
134
+ }
135
+ end
68
136
  end
69
137
  end
70
138
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetchUtil
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.2"
5
5
  end
data/lib/fetch_util.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "addressable/uri"
3
4
  require "uri"
4
5
 
5
6
  require_relative "fetch_util/version"
@@ -88,12 +89,21 @@ module FetchUtil
88
89
  text.gsub(/\u00A0/, " ").gsub(/\s+/, " ").strip
89
90
  end
90
91
 
92
+ def normalize_url(url)
93
+ return url if url.nil? || url.to_s.empty?
94
+
95
+ Addressable::URI.parse(url.to_s).normalize.to_s
96
+ rescue Addressable::URI::InvalidURIError, URI::InvalidURIError
97
+ url.to_s
98
+ end
99
+
91
100
  def strip_www_host(url)
92
- URI.parse(url.to_s).host.to_s.downcase.sub(/\Awww\./, "")
101
+ uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
102
+ uri.host.to_s.downcase.sub(/\Awww\./, "")
93
103
  end
94
104
 
95
105
  def docs_like_url?(value)
96
- uri = value.is_a?(URI::Generic) ? value : URI.parse(value.to_s.strip)
106
+ uri = value.is_a?(URI::Generic) ? value : URI.parse(normalize_url(value.to_s.strip))
97
107
  return false unless uri.is_a?(URI::HTTP) && uri.host
98
108
 
99
109
  host = strip_www_host(uri)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fetch_util
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - hmdne
@@ -140,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
140
  - !ruby/object:Gem::Version
141
141
  version: '0'
142
142
  requirements: []
143
- rubygems_version: 4.0.10
143
+ rubygems_version: 4.0.15
144
144
  specification_version: 4
145
145
  summary: AI for fetching in Ruby
146
146
  test_files: []