fetch_util 0.3.0 → 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 = {
@@ -20,6 +21,7 @@ module FetchUtil
20
21
  "nav",
21
22
  "aside",
22
23
  "footer",
24
+ ".discord",
23
25
  ".toc",
24
26
  ".sidebar",
25
27
  ".breadcrumbs",
@@ -29,17 +31,26 @@ module FetchUtil
29
31
  "button"
30
32
  ].freeze
31
33
  DOCS_ROOT_SELECTORS = [
34
+ "main .sl-markdown-content",
35
+ "main [data-pagefind-body]",
36
+ ".sl-markdown-content",
37
+ "[data-pagefind-body]",
32
38
  "main article",
33
39
  "main",
34
40
  "article",
35
41
  "[role='main']",
42
+ "[class*='content']",
43
+ "[id*='content']",
44
+ "[class*='article']",
45
+ "[id*='article']",
36
46
  ".content",
37
47
  ".resource-container"
38
48
  ].freeze
39
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
40
50
 
41
- def initialize(timeout: 20)
51
+ def initialize(timeout: 20, http_client: nil)
42
52
  @timeout = timeout.to_i
53
+ @http_client = http_client || FetchUtil::HttpRedirectClient.new(timeout: @timeout, headers: DEFAULT_HEADERS)
43
54
  end
44
55
 
45
56
  def fetch(url)
@@ -58,7 +69,9 @@ module FetchUtil
58
69
  return nil unless root
59
70
 
60
71
  prune!(root)
61
- title = clean_text(fragment_title(document, final_url) || first_heading(root) || meta_title(document) || document.title)
72
+ title = [fragment_title(document, final_url), first_heading(root), meta_title(document), document.title]
73
+ .map { |candidate| clean_text(candidate) }
74
+ .find { |candidate| !candidate.empty? }
62
75
  markdown = markdown_from_root(root, title)
63
76
  return nil if clean_text(markdown).length < 40
64
77
 
@@ -149,23 +162,49 @@ module FetchUtil
149
162
  end
150
163
 
151
164
  def docs_root(document)
165
+ candidates = []
152
166
  DOCS_ROOT_SELECTORS.each do |selector|
153
- node = document.at_css(selector)
154
- return node.dup if node && clean_text(node.text).length >= 120
167
+ document.css(selector).each do |node|
168
+ text = clean_text(node.text)
169
+ next if text.length < 120
170
+
171
+ candidates << [node, text.length + (node.css("p").length * 200)]
172
+ end
155
173
  end
156
174
 
175
+ best = candidates.max_by { |_node, score| score }
176
+ return best.first.dup if best
177
+
157
178
  body = document.at_css("body")
158
179
  body&.dup
159
180
  end
160
181
 
161
182
  def prune!(root)
162
183
  DROP_SELECTORS.each { |selector| root.css(selector).remove }
184
+ prune_leading_promo_cards!(root)
163
185
  root.css("*").each do |node|
164
186
  text = clean_text(node.text)
165
187
  node.remove if text.match?(PRUNED_TEXT_PATTERN)
166
188
  end
167
189
  end
168
190
 
191
+ def prune_leading_promo_cards!(root)
192
+ return unless root.at_css("h1, h2")
193
+
194
+ root.css("article.card, .card").each do |node|
195
+ text = clean_text(node.text)
196
+ next if node.ancestors.any? { |ancestor| class_list(ancestor).any? { |klass| %w[landing-card card--fullwidth].include?(klass) } }
197
+ next if node.at_css(".title, .body")
198
+ next if text.empty? || text.length > 600 || node.at_css("h1, h2, h3, h4, h5, h6")
199
+
200
+ node.remove
201
+ end
202
+ end
203
+
204
+ def class_list(node)
205
+ node["class"].to_s.split
206
+ end
207
+
169
208
  def meta_title(document)
170
209
  meta_value(document, "og:title", attr: "property") || document.title
171
210
  end
@@ -235,26 +274,11 @@ module FetchUtil
235
274
  %(concat(#{parts.join(%q(, "'", ))}))
236
275
  end
237
276
 
238
- def fetch_html(url, limit: 5)
239
- raise URI::InvalidURIError, "too many redirects" if limit <= 0
240
-
241
- uri = URI.parse(url)
242
- request = Net::HTTP::Get.new(uri)
243
- DEFAULT_HEADERS.each { |key, value| request[key] = value }
244
-
245
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |http|
246
- http.request(request)
247
- end
277
+ def fetch_html(url)
278
+ response = @http_client.get(url)
279
+ raise Error, "HTTP #{response.status}" unless response.status.between?(200, 299)
248
280
 
249
- case response
250
- when Net::HTTPSuccess
251
- [uri.to_s, response.body]
252
- when Net::HTTPRedirection
253
- location = URI.join(uri, response["location"]).to_s
254
- fetch_html(location, limit: limit - 1)
255
- else
256
- raise Error, "HTTP #{response.code}"
257
- end
281
+ [response.url, response.body]
258
282
  end
259
283
  end
260
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
@@ -5,11 +5,106 @@ module FetchUtil
5
5
  attr_reader :url, :final_url, :title, :byline, :excerpt, :site_name,
6
6
  :published_time, :canonical_url, :language, :html, :markdown,
7
7
  :metadata, :reader_mode, :content_type, :suspect, :warnings,
8
- :content_completeness_ratio, :content_format, :paywall_state
8
+ :content_completeness_ratio, :content_format, :paywall_state,
9
+ :error_message
10
+
11
+ class << self
12
+ def from_payload(url:, final_url:, payload:, canonical_url:, content_type:, warnings:, suspect:)
13
+ content_completeness_ratio = payload["contentCompletenessRatio"]&.to_f || 1.0
14
+ content_format = payload["contentFormat"]
15
+ paywall_state = payload["paywallState"]
16
+ metadata = payload_metadata(
17
+ payload,
18
+ canonical_url: canonical_url,
19
+ final_url: final_url,
20
+ content_type: content_type,
21
+ suspect: suspect,
22
+ warnings: warnings,
23
+ content_completeness_ratio: content_completeness_ratio,
24
+ content_format: content_format,
25
+ paywall_state: paywall_state
26
+ )
27
+
28
+ new(
29
+ url: url,
30
+ final_url: final_url,
31
+ title: payload["title"],
32
+ byline: payload["byline"],
33
+ excerpt: payload["excerpt"],
34
+ site_name: payload["siteName"],
35
+ published_time: payload["publishedTime"],
36
+ canonical_url: canonical_url,
37
+ language: payload["language"],
38
+ html: payload["html"],
39
+ markdown: payload["markdown"],
40
+ metadata: metadata,
41
+ reader_mode: payload["readerMode"],
42
+ content_type: content_type,
43
+ suspect: suspect,
44
+ warnings: warnings,
45
+ content_completeness_ratio: content_completeness_ratio,
46
+ content_format: content_format,
47
+ paywall_state: paywall_state
48
+ )
49
+ end
50
+
51
+ def error(url:, warning:, message:)
52
+ metadata = {
53
+ content_url: url,
54
+ content_type: "error",
55
+ suspect: true,
56
+ warnings: [warning],
57
+ error_message: message
58
+ }.freeze
59
+
60
+ new(
61
+ url: url,
62
+ final_url: url,
63
+ title: nil,
64
+ byline: nil,
65
+ excerpt: nil,
66
+ site_name: nil,
67
+ published_time: nil,
68
+ canonical_url: nil,
69
+ language: nil,
70
+ html: nil,
71
+ markdown: "",
72
+ metadata: metadata,
73
+ reader_mode: nil,
74
+ content_type: "error",
75
+ suspect: true,
76
+ warnings: [warning],
77
+ error_message: message
78
+ )
79
+ end
80
+
81
+ private
82
+
83
+ def payload_metadata(payload, canonical_url:, final_url:, content_type:, suspect:, warnings:,
84
+ content_completeness_ratio:, content_format:, paywall_state:)
85
+ {
86
+ title: payload["title"],
87
+ byline: payload["byline"],
88
+ excerpt: payload["excerpt"],
89
+ site_name: payload["siteName"],
90
+ published_time: payload["publishedTime"],
91
+ canonical_url: canonical_url,
92
+ language: payload["language"],
93
+ content_url: final_url,
94
+ reader_mode: payload["readerMode"],
95
+ content_type: content_type,
96
+ suspect: suspect,
97
+ warnings: warnings,
98
+ content_completeness_ratio: content_completeness_ratio,
99
+ content_format: content_format,
100
+ paywall_state: paywall_state
101
+ }.freeze
102
+ end
103
+ end
9
104
 
10
105
  def initialize(url:, final_url:, title:, byline:, excerpt:, site_name:, published_time:,
11
106
  canonical_url:, language:, html:, markdown:, metadata:, reader_mode:, content_type:, suspect:, warnings:,
12
- content_completeness_ratio: 1.0, content_format: nil, paywall_state: nil)
107
+ content_completeness_ratio: 1.0, content_format: nil, paywall_state: nil, error_message: nil)
13
108
  @url = url
14
109
  @final_url = final_url
15
110
  @title = title
@@ -29,6 +124,7 @@ module FetchUtil
29
124
  @content_completeness_ratio = content_completeness_ratio
30
125
  @content_format = content_format&.freeze
31
126
  @paywall_state = paywall_state&.freeze
127
+ @error_message = error_message
32
128
  end
33
129
 
34
130
  def to_h
@@ -51,7 +147,8 @@ module FetchUtil
51
147
  warnings: warnings,
52
148
  content_completeness_ratio: content_completeness_ratio,
53
149
  content_format: content_format,
54
- paywall_state: paywall_state
150
+ paywall_state: paywall_state,
151
+ error_message: error_message
55
152
  }
56
153
  end
57
154
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetchUtil
4
- VERSION = "0.3.0"
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.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - hmdne
@@ -82,6 +82,7 @@ files:
82
82
  - lib/fetch_util/browser/navigation/navigator_patch.rb
83
83
  - lib/fetch_util/browser/site_stabilization.rb
84
84
  - lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb
85
+ - lib/fetch_util/browser/site_stabilization/gitlab_repo.rb
85
86
  - lib/fetch_util/browser/site_stabilization/social_platforms.rb
86
87
  - lib/fetch_util/browser/stabilization.rb
87
88
  - lib/fetch_util/browser/stabilization/page_flow.rb
@@ -139,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
139
140
  - !ruby/object:Gem::Version
140
141
  version: '0'
141
142
  requirements: []
142
- rubygems_version: 4.0.10
143
+ rubygems_version: 4.0.15
143
144
  specification_version: 4
144
145
  summary: AI for fetching in Ruby
145
146
  test_files: []