fetch_util 0.5.2 → 0.6.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +3 -1
  4. data/Rakefile +0 -2
  5. data/lib/fetch_util/assets/extract.js +1 -1
  6. data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +12 -17
  7. data/lib/fetch_util/browser/interaction_helpers/timing_helpers.rb +42 -15
  8. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_commit_details_script.rb +48 -0
  9. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_product_state_script.rb +80 -0
  10. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_helpers_script.rb +53 -0
  11. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_state_script.rb +118 -0
  12. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_stabilization.rb +54 -0
  13. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_state.rb +27 -0
  14. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_product_state.rb +55 -0
  15. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_request_state.rb +100 -0
  16. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_state.rb +59 -0
  17. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_modules.rb +26 -0
  18. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_state.rb +93 -0
  19. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resources.rb +73 -0
  20. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_thread_state.rb +92 -0
  21. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_threads.rb +71 -0
  22. data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +9 -10
  23. data/lib/fetch_util/browser/site_stabilization/facebook_stabilization.rb +56 -0
  24. data/lib/fetch_util/browser/site_stabilization/gerrit_change_product_state_script.rb +97 -0
  25. data/lib/fetch_util/browser/site_stabilization/gerrit_change_request_state_script.rb +75 -0
  26. data/lib/fetch_util/browser/site_stabilization/gerrit_change_stabilization.rb +37 -0
  27. data/lib/fetch_util/browser/site_stabilization/gerrit_change_state.rb +39 -0
  28. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_fetch_state_script.rb +31 -0
  29. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_product_state_script.rb +76 -0
  30. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_request_state_script.rb +111 -0
  31. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_route_state_script.rb +71 -0
  32. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_stabilization.rb +37 -0
  33. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_state.rb +42 -0
  34. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_validation_state_script.rb +49 -0
  35. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_product_state.rb +88 -0
  36. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_state.rb +109 -0
  37. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resources.rb +30 -0
  38. data/lib/fetch_util/browser/site_stabilization/gitea_family_stabilization.rb +60 -0
  39. data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_product_state.rb +74 -0
  40. data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_timeline_state.rb +69 -0
  41. data/lib/fetch_util/browser/site_stabilization/gitea_family_threads.rb +28 -0
  42. data/lib/fetch_util/browser/site_stabilization/gitea_family_visibility_script.rb +28 -0
  43. data/lib/fetch_util/browser/site_stabilization/github_pull_resource_stabilization.rb +31 -0
  44. data/lib/fetch_util/browser/site_stabilization/github_pull_resources.rb +105 -0
  45. data/lib/fetch_util/browser/site_stabilization/github_threads.rb +82 -0
  46. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state.rb +17 -0
  47. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state_script.rb +98 -0
  48. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_visibility_script.rb +43 -0
  49. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resources.rb +28 -0
  50. data/lib/fetch_util/browser/site_stabilization/gitlab_repo.rb +3 -11
  51. data/lib/fetch_util/browser/site_stabilization/gitlab_stabilization.rb +33 -0
  52. data/lib/fetch_util/browser/site_stabilization/gitlab_threads.rb +92 -0
  53. data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +19 -38
  54. data/lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb +3 -3
  55. data/lib/fetch_util/browser/site_stabilization.rb +89 -0
  56. data/lib/fetch_util/browser/stabilization/anubis.rb +4 -5
  57. data/lib/fetch_util/browser/stabilization/page_flow.rb +71 -27
  58. data/lib/fetch_util/browser/stabilization/spa_hydration.rb +7 -5
  59. data/lib/fetch_util/browser.rb +88 -17
  60. data/lib/fetch_util/cli.rb +69 -23
  61. data/lib/fetch_util/extractor.rb +8 -2
  62. data/lib/fetch_util/fetcher.rb +144 -43
  63. data/lib/fetch_util/http_redirect_client.rb +161 -0
  64. data/lib/fetch_util/parallel_fetcher.rb +125 -28
  65. data/lib/fetch_util/raw_docs_fallback.rb +158 -21
  66. data/lib/fetch_util/regulatory/cache_store.rb +47 -14
  67. data/lib/fetch_util/regulatory/fetch_records.rb +10 -7
  68. data/lib/fetch_util/regulatory/headers.rb +8 -1
  69. data/lib/fetch_util/regulatory/http_client.rb +2 -97
  70. data/lib/fetch_util/regulatory/orchestration.rb +10 -6
  71. data/lib/fetch_util/regulatory/page.rb +8 -11
  72. data/lib/fetch_util/regulatory/robots.rb +2 -10
  73. data/lib/fetch_util/regulatory/source_selection.rb +13 -3
  74. data/lib/fetch_util/regulatory/tdm_page.rb +9 -4
  75. data/lib/fetch_util/regulatory/tdm_policy.rb +16 -9
  76. data/lib/fetch_util/regulatory/tdm_rep.rb +4 -3
  77. data/lib/fetch_util/regulatory/tdm_support.rb +10 -6
  78. data/lib/fetch_util/regulatory.rb +5 -1
  79. data/lib/fetch_util/request_log.rb +24 -3
  80. data/lib/fetch_util/result.rb +37 -89
  81. data/lib/fetch_util/search_transport.rb +102 -35
  82. data/lib/fetch_util/searcher.rb +15 -13
  83. data/lib/fetch_util/version.rb +1 -1
  84. data/lib/fetch_util.rb +4 -1
  85. metadata +66 -2
@@ -13,86 +13,32 @@ module FetchUtil
13
13
 
14
14
  class << self
15
15
  def from_payload(url:, final_url:, payload:, canonical_url:, content_type:, warnings:, suspect:)
16
- content_completeness_ratio = payload["contentCompletenessRatio"]&.to_f || 1.0
17
- content_format = payload["contentFormat"]
18
- paywall_state = payload["paywallState"]
19
16
  metadata = payload_metadata(
20
17
  payload,
21
18
  canonical_url: canonical_url,
22
19
  final_url: final_url,
23
20
  content_type: content_type,
24
21
  suspect: suspect,
25
- warnings: warnings,
26
- content_completeness_ratio: content_completeness_ratio,
27
- content_format: content_format,
28
- paywall_state: paywall_state,
29
- name: payload["name"],
30
- company: payload["company"],
31
- location: payload["location"],
32
- description: payload["description"],
33
- ingredients: payload["ingredients"],
34
- instructions: payload["instructions"],
35
- bedrooms: payload["bedrooms"],
36
- bathrooms: payload["bathrooms"],
37
- area_sqft: payload["areaSqft"],
38
- price: payload["price"],
39
- rating: payload["rating"],
40
- address: payload["address"],
41
- social_kind: payload["socialKind"],
42
- platform: payload["platform"],
43
- handle: payload["handle"],
44
- reply_count: payload["replyCount"],
45
- community: payload["community"],
46
- score: payload["score"]
22
+ warnings: warnings
47
23
  )
48
24
 
49
25
  new(
50
26
  url: url,
51
27
  final_url: final_url,
52
- title: payload["title"],
53
- byline: payload["byline"],
54
- excerpt: payload["excerpt"],
55
- site_name: payload["siteName"],
56
- published_time: payload["publishedTime"],
57
- canonical_url: canonical_url,
58
- language: payload["language"],
59
- name: payload["name"],
60
- company: payload["company"],
61
- location: payload["location"],
62
- description: payload["description"],
63
- ingredients: payload["ingredients"],
64
- instructions: payload["instructions"],
65
- bedrooms: payload["bedrooms"],
66
- bathrooms: payload["bathrooms"],
67
- area_sqft: payload["areaSqft"],
68
28
  html: payload["html"],
69
29
  markdown: payload["markdown"],
70
30
  metadata: metadata,
71
- reader_mode: payload["readerMode"],
72
- content_type: content_type,
73
- suspect: suspect,
74
- warnings: warnings,
75
- content_completeness_ratio: content_completeness_ratio,
76
- content_format: content_format,
77
- paywall_state: paywall_state,
78
- price: payload["price"],
79
- rating: payload["rating"],
80
- address: payload["address"],
81
- social_kind: payload["socialKind"],
82
- platform: payload["platform"],
83
- handle: payload["handle"],
84
- reply_count: payload["replyCount"],
85
- community: payload["community"],
86
- score: payload["score"]
31
+ **metadata.except(:content_url)
87
32
  )
88
33
  end
89
34
 
90
35
  def error(url:, warning:, message:)
36
+ warnings = [warning]
91
37
  metadata = {
92
38
  content_url: url,
93
39
  content_type: "error",
94
40
  suspect: true,
95
- warnings: [warning],
41
+ warnings: warnings,
96
42
  error_message: message,
97
43
  social_kind: nil,
98
44
  platform: nil,
@@ -127,18 +73,14 @@ module FetchUtil
127
73
  reader_mode: nil,
128
74
  content_type: "error",
129
75
  suspect: true,
130
- warnings: [warning],
76
+ warnings: warnings,
131
77
  error_message: message
132
78
  )
133
79
  end
134
80
 
135
81
  private
136
82
 
137
- def payload_metadata(payload, canonical_url:, final_url:, content_type:, suspect:, warnings:,
138
- content_completeness_ratio:, content_format:, paywall_state:,
139
- name:, company:, location:, description:, ingredients:, instructions:,
140
- bedrooms:, bathrooms:, area_sqft:, price:, rating:, address:,
141
- social_kind:, platform:, handle:, reply_count:, community:, score:)
83
+ def payload_metadata(payload, canonical_url:, final_url:, content_type:, suspect:, warnings:)
142
84
  {
143
85
  title: payload["title"],
144
86
  byline: payload["byline"],
@@ -147,32 +89,32 @@ module FetchUtil
147
89
  published_time: payload["publishedTime"],
148
90
  canonical_url: canonical_url,
149
91
  language: payload["language"],
150
- name: name,
151
- company: company,
152
- location: location,
153
- description: description,
154
- ingredients: ingredients,
155
- instructions: instructions,
156
- bedrooms: bedrooms,
157
- bathrooms: bathrooms,
158
- area_sqft: area_sqft,
92
+ name: payload["name"],
93
+ company: payload["company"],
94
+ location: payload["location"],
95
+ description: payload["description"],
96
+ ingredients: payload["ingredients"],
97
+ instructions: payload["instructions"],
98
+ bedrooms: payload["bedrooms"],
99
+ bathrooms: payload["bathrooms"],
100
+ area_sqft: payload["areaSqft"],
159
101
  content_url: final_url,
160
102
  reader_mode: payload["readerMode"],
161
103
  content_type: content_type,
162
104
  suspect: suspect,
163
105
  warnings: warnings,
164
- content_completeness_ratio: content_completeness_ratio,
165
- content_format: content_format,
166
- paywall_state: paywall_state,
167
- price: price,
168
- rating: rating,
169
- address: address,
170
- social_kind: social_kind,
171
- platform: platform,
172
- handle: handle,
173
- reply_count: reply_count,
174
- community: community,
175
- score: score
106
+ content_completeness_ratio: payload["contentCompletenessRatio"]&.to_f || 1.0,
107
+ content_format: payload["contentFormat"],
108
+ paywall_state: payload["paywallState"],
109
+ price: payload["price"],
110
+ rating: payload["rating"],
111
+ address: payload["address"],
112
+ social_kind: payload["socialKind"],
113
+ platform: payload["platform"],
114
+ handle: payload["handle"],
115
+ reply_count: payload["replyCount"],
116
+ community: payload["community"],
117
+ score: payload["score"]
176
118
  }.freeze
177
119
  end
178
120
  end
@@ -184,6 +126,12 @@ module FetchUtil
184
126
  content_completeness_ratio: 1.0, content_format: nil, paywall_state: nil,
185
127
  price: nil, rating: nil, address: nil, social_kind: nil, platform: nil,
186
128
  handle: nil, reply_count: nil, community: nil, score: nil, error_message: nil)
129
+ owned_warnings = warnings.map { |warning| warning.to_s.dup.freeze }.freeze
130
+ owned_content_format = content_format&.dup&.freeze
131
+ owned_paywall_state = paywall_state&.dup&.freeze
132
+ owned_metadata = metadata.merge(warnings: owned_warnings)
133
+ owned_metadata[:content_format] = owned_content_format if owned_metadata.key?(:content_format)
134
+ owned_metadata[:paywall_state] = owned_paywall_state if owned_metadata.key?(:paywall_state)
187
135
  @url = url
188
136
  @final_url = final_url
189
137
  @title = title
@@ -204,14 +152,14 @@ module FetchUtil
204
152
  @area_sqft = area_sqft
205
153
  @html = html
206
154
  @markdown = markdown
207
- @metadata = metadata.freeze
155
+ @metadata = owned_metadata.freeze
208
156
  @reader_mode = reader_mode
209
157
  @content_type = content_type
210
158
  @suspect = suspect
211
- @warnings = warnings.freeze
159
+ @warnings = owned_warnings
212
160
  @content_completeness_ratio = content_completeness_ratio
213
- @content_format = content_format&.freeze
214
- @paywall_state = paywall_state&.freeze
161
+ @content_format = owned_content_format
162
+ @paywall_state = owned_paywall_state
215
163
  @price = price
216
164
  @rating = rating
217
165
  @address = address
@@ -13,22 +13,47 @@ module FetchUtil
13
13
  class SearchTransport
14
14
  Candidate = Data.define(:source, :title, :url, :snippet, :source_rank) do
15
15
  def initialize(source:, title:, url:, source_rank:, snippet: nil)
16
- super(source: source.to_s.freeze, title: title.to_s.freeze, url: url.to_s.freeze,
17
- snippet: snippet&.to_s&.freeze, source_rank: Integer(source_rank))
16
+ super(source: source.to_s.dup.freeze, title: title.to_s.dup.freeze, url: url.to_s.dup.freeze,
17
+ snippet: snippet.nil? ? nil : snippet.to_s.dup.freeze, source_rank: Integer(source_rank))
18
18
  end
19
19
  end
20
20
 
21
21
  SourceResponse = Data.define(:source, :transport, :status, :candidates, :elapsed_ms, :final_url, :reason) do
22
22
  def initialize(source:, status:, elapsed_ms:, transport: "http", candidates: [], final_url: nil, reason: nil)
23
- super(source: source.to_s.freeze, transport: transport.to_s.freeze, status: status.to_s.freeze,
24
- candidates: candidates.freeze, elapsed_ms: Integer(elapsed_ms), final_url: final_url&.to_s&.freeze,
25
- reason: reason&.to_s&.freeze)
23
+ super(source: source.to_s.dup.freeze, transport: transport.to_s.dup.freeze, status: status.to_s.dup.freeze,
24
+ candidates: candidates.dup.freeze, elapsed_ms: Integer(elapsed_ms),
25
+ final_url: final_url.nil? ? nil : final_url.to_s.dup.freeze,
26
+ reason: reason.nil? ? nil : reason.to_s.dup.freeze)
26
27
  end
27
28
  end
28
29
 
29
30
  HttpResponse = Data.define(:status, :headers, :body, :final_url)
30
31
  HttpFailure = Data.define(:reason, :final_url)
31
32
 
33
+ class MonotonicClock
34
+ def initialize(source)
35
+ @source = source
36
+ @mutex = Mutex.new
37
+ @latest = nil
38
+ end
39
+
40
+ def call
41
+ @mutex.synchronize do
42
+ current = @source.call
43
+ begin
44
+ current = Float(current)
45
+ rescue ArgumentError, TypeError
46
+ raise ArgumentError, "clock must return a finite number"
47
+ end
48
+ raise ArgumentError, "clock must return a finite number" unless current.finite?
49
+
50
+ @latest = current if @latest.nil? || current > @latest
51
+ @latest
52
+ end
53
+ end
54
+ end
55
+ private_constant :MonotonicClock
56
+
32
57
  SOURCES = {
33
58
  "brave" => { url: "https://search.brave.com/search?q=%{query}", hosts: %w[search.brave.com] },
34
59
  "bing" => { url: "https://www.bing.com/search?q=%{query}&setlang=en-US&cc=US", hosts: %w[www.bing.com cn.bing.com] },
@@ -55,15 +80,18 @@ module FetchUtil
55
80
  }.freeze
56
81
 
57
82
  def initialize(sources: SOURCES.keys, timeout: DEFAULT_TIMEOUT, clock: nil, http_client: nil, html_parser: nil)
58
- @sources = sources.map(&:to_s).freeze
83
+ @sources = sources.map { |source| source.to_s.dup.freeze }.freeze
59
84
  unknown = @sources - SOURCES.keys
60
- raise ArgumentError, "unknown search sources: #{unknown.join(", ")}" if unknown.any?
85
+ raise InputError, "unknown search sources: #{unknown.join(", ")}" if unknown.any?
61
86
 
62
- @timeout = Float(timeout)
63
- raise ArgumentError, "timeout must be positive" unless @timeout.positive?
87
+ @timeout = validated_timeout(timeout)
64
88
 
65
- @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
66
- @http_client = http_client || HttpClient.new(clock: @clock)
89
+ @clock = MonotonicClock.new(clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
90
+ @http_client = if http_client.is_a?(HttpClient)
91
+ http_client.send(:with_clock, @clock)
92
+ else
93
+ http_client || HttpClient.new(clock: @clock)
94
+ end
67
95
  @html_parser = html_parser || ->(body) { Nokogiri::HTML(body) }
68
96
  end
69
97
 
@@ -83,15 +111,20 @@ module FetchUtil
83
111
 
84
112
  def search(query, timeout: @timeout)
85
113
  query = query.to_s.strip
86
- raise ArgumentError, "query must not be empty" if query.empty?
114
+ raise InputError, "query must not be empty" if query.empty?
87
115
 
88
- request_timeout = Float(timeout)
89
- raise ArgumentError, "timeout must be positive" unless request_timeout.positive?
116
+ request_timeout = validated_timeout(timeout)
90
117
 
91
118
  deadline = clock.call + request_timeout
92
119
  responses = Array.new(sources.length)
93
- threads = sources.each_with_index.map do |source, index|
94
- Thread.new { responses[index] = search_source(source, query, deadline) }
120
+ threads = []
121
+ begin
122
+ sources.each_with_index do |source, index|
123
+ threads << Thread.new { responses[index] = search_source(source, query, deadline) }
124
+ end
125
+ rescue ThreadError
126
+ threads.each(&:join)
127
+ raise
95
128
  end
96
129
  threads.each(&:join)
97
130
  responses
@@ -101,6 +134,13 @@ module FetchUtil
101
134
 
102
135
  attr_reader :clock, :html_parser, :http_client, :sources, :timeout
103
136
 
137
+ def validated_timeout(value)
138
+ timeout = Float(value)
139
+ raise InputError, "timeout must be positive" unless timeout.finite? && timeout.positive?
140
+
141
+ timeout
142
+ end
143
+
104
144
  def search_source(source, query, deadline)
105
145
  started_at = clock.call
106
146
  url = build_url(source, query)
@@ -110,9 +150,10 @@ module FetchUtil
110
150
  end
111
151
  outcome = within_deadline(deadline) do
112
152
  document = html_parser.call(result.body)
113
- next [:failed, "challenge"] if challenge?(source, result, document)
153
+ shell_text = search_shell_text(source, document)
154
+ next [:failed, "challenge"] if challenge?(source, result, shell_text)
114
155
  next [:failed, "http_status"] unless result.status.between?(200, 299)
115
- next [:empty] if no_results?(source, document)
156
+ next [:empty] if no_results?(source, shell_text)
116
157
 
117
158
  candidates = parse_candidates(source, document)
118
159
  next [:ok, candidates] if candidates.empty?
@@ -359,19 +400,16 @@ module FetchUtil
359
400
  node ? node.text.encode("UTF-8", invalid: :replace, undef: :replace, replace: " ").gsub(/\s+/, " ").strip : ""
360
401
  end
361
402
 
362
- def challenge?(source, response, document)
403
+ def challenge?(source, response, shell_text)
363
404
  return true if source == "google" && URI.parse(response.final_url).path.start_with?("/sorry")
364
405
  return true if source == "duckduckgo" && response.status == 202
365
406
 
366
- title = normalized_text(document.at_css("title")).downcase
367
- visible_text = visible_document_text(document)
368
- "#{title} #{visible_text}".match?(/captcha|unusual traffic|verify you are human|consent/)
407
+ shell_text.match?(/captcha|unusual traffic|verify you are human|consent/)
369
408
  rescue URI::InvalidURIError
370
409
  true
371
410
  end
372
411
 
373
- def no_results?(source, document)
374
- text = visible_document_text(document)
412
+ def no_results?(source, text)
375
413
  patterns = {
376
414
  "brave" => /no results|did not match any documents/,
377
415
  "bing" => /there are no results|no results found/,
@@ -383,9 +421,10 @@ module FetchUtil
383
421
  text.match?(patterns.fetch(source))
384
422
  end
385
423
 
386
- def visible_document_text(document)
424
+ def search_shell_text(source, document)
387
425
  visible = document.dup
388
- visible.css("script, style, template, noscript").remove
426
+ result_nodes(source, visible).each(&:remove)
427
+ visible.css("title, script, style, template, noscript").remove
389
428
  normalized_text(visible).downcase
390
429
  end
391
430
 
@@ -427,8 +466,8 @@ module FetchUtil
427
466
 
428
467
  def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, max_response_bytes: MAX_RESPONSE_BYTES,
429
468
  net_http: nil)
430
- @clock = clock
431
- @max_response_bytes = Integer(max_response_bytes)
469
+ @clock = clock.is_a?(MonotonicClock) ? clock : MonotonicClock.new(clock)
470
+ @max_response_bytes = positive_max_response_bytes(max_response_bytes)
432
471
  @net_http = net_http || ->(uri) { Net::HTTP.new(uri.host, uri.port) }
433
472
  end
434
473
 
@@ -442,6 +481,21 @@ module FetchUtil
442
481
 
443
482
  attr_reader :clock, :max_response_bytes, :net_http
444
483
 
484
+ def with_clock(shared_clock)
485
+ copy = dup
486
+ copy.instance_variable_set(:@clock, shared_clock)
487
+ copy
488
+ end
489
+
490
+ def positive_max_response_bytes(value)
491
+ bytes = Integer(value)
492
+ return bytes if bytes.positive?
493
+
494
+ raise ArgumentError
495
+ rescue ArgumentError, TypeError, FloatDomainError
496
+ raise InputError, "max_response_bytes must be positive"
497
+ end
498
+
445
499
  def fetch(uri, deadline, allowed_hosts, redirects_left)
446
500
  return HttpFailure.new(reason: "host", final_url: uri.to_s) unless allowed_uri?(uri, allowed_hosts)
447
501
  return HttpFailure.new(reason: "timeout", final_url: uri.to_s) unless remaining(deadline).positive?
@@ -456,6 +510,9 @@ module FetchUtil
456
510
  rescue URI::InvalidURIError
457
511
  return HttpFailure.new(reason: "redirect", final_url: uri.to_s)
458
512
  end
513
+ if uri.scheme == "https" && target.scheme != "https"
514
+ return HttpFailure.new(reason: "redirect", final_url: target.to_s)
515
+ end
459
516
  return HttpFailure.new(reason: "host", final_url: target.to_s) unless allowed_uri?(target, allowed_hosts)
460
517
 
461
518
  return fetch(target, deadline, allowed_hosts, redirects_left - 1)
@@ -470,7 +527,7 @@ module FetchUtil
470
527
  HttpFailure.new(reason: "size", final_url: uri.to_s)
471
528
  rescue Zlib::Error
472
529
  HttpFailure.new(reason: "parse", final_url: uri.to_s)
473
- rescue SystemCallError, IOError
530
+ rescue IOError, SocketError, SystemCallError
474
531
  HttpFailure.new(reason: "failed", final_url: uri.to_s)
475
532
  end
476
533
 
@@ -494,8 +551,7 @@ module FetchUtil
494
551
  http.request(request) do |incoming|
495
552
  incoming.read_body do |chunk|
496
553
  ensure_remaining!(deadline)
497
- body << chunk
498
- raise ResponseTooLarge if body.bytesize > max_response_bytes
554
+ append_bounded(body, chunk)
499
555
  end
500
556
  end
501
557
  end
@@ -526,18 +582,29 @@ module FetchUtil
526
582
  while offset < body.bytesize
527
583
  ensure_remaining!(deadline)
528
584
  chunk = body.byteslice(offset, INFLATE_CHUNK_BYTES)
529
- append_decoded(decoded, inflater.inflate(chunk))
585
+ inflater.inflate(chunk) do |piece|
586
+ ensure_remaining!(deadline)
587
+ append_decoded(decoded, piece)
588
+ end
530
589
  offset += chunk.bytesize
531
590
  end
532
- append_decoded(decoded, inflater.finish)
591
+ inflater.finish do |piece|
592
+ ensure_remaining!(deadline)
593
+ append_decoded(decoded, piece)
594
+ end
533
595
  decoded
534
596
  ensure
535
597
  inflater&.close
536
598
  end
537
599
 
538
600
  def append_decoded(decoded, chunk)
539
- decoded << chunk
540
- raise ResponseTooLarge if decoded.bytesize > max_response_bytes
601
+ append_bounded(decoded, chunk)
602
+ end
603
+
604
+ def append_bounded(buffer, chunk)
605
+ raise ResponseTooLarge if buffer.bytesize + chunk.bytesize > max_response_bytes
606
+
607
+ buffer << chunk
541
608
  end
542
609
 
543
610
  def charset_from(content_type, body)
@@ -16,9 +16,9 @@ module FetchUtil
16
16
  timeout: SearchTransport::DEFAULT_TIMEOUT)
17
17
  @request_log = request_log
18
18
  @sources_explicit = !sources.nil?
19
- @sources = Array(sources || DEFAULT_SOURCES).map(&:to_s).uniq
19
+ @sources = Array(sources || DEFAULT_SOURCES).map { |source| source.to_s.dup.freeze }.uniq.freeze
20
20
  unknown = @sources - SearchTransport::SOURCES.keys
21
- raise ArgumentError, "unsupported search source: #{unknown.first}" if unknown.any?
21
+ raise InputError, "unsupported search source: #{unknown.first}" if unknown.any?
22
22
 
23
23
  validate_limit!(limit)
24
24
  @limit = limit
@@ -28,16 +28,18 @@ module FetchUtil
28
28
 
29
29
  def search(query)
30
30
  encoded_query = query.to_s.strip
31
- raise ArgumentError, "query must not be empty" if encoded_query.empty?
31
+ raise InputError, "query must not be empty" if encoded_query.empty?
32
32
 
33
33
  @request_log.append(search_request_uri(encoded_query))
34
34
  responses = @transport.search(encoded_query)
35
+ response_index = {}
36
+ responses.each { |response| response_index[response.source] ||= response }
35
37
 
36
38
  payload = {
37
39
  query: encoded_query,
38
- results: formatted_results(apply_limit(aggregate(responses, encoded_query)))
40
+ results: formatted_results(apply_limit(aggregate(response_index, encoded_query)))
39
41
  }
40
- payload[:diagnostics] = diagnostics(responses) if @verbose
42
+ payload[:diagnostics] = diagnostics(response_index) if @verbose
41
43
  payload
42
44
  end
43
45
 
@@ -53,23 +55,23 @@ module FetchUtil
53
55
  return if value.nil?
54
56
  return if value.is_a?(Integer) && value >= 0
55
57
 
56
- raise ArgumentError, "limit must be a nonnegative integer"
58
+ raise InputError, "limit must be a nonnegative integer"
57
59
  end
58
60
 
59
61
  def search_request_uri(query)
60
62
  "search://#{@sources.join(",")}?q=#{CGI.escape(query)}"
61
63
  end
62
64
 
63
- def aggregate(responses, query)
65
+ def aggregate(response_index, query)
64
66
  parsed = {}
65
67
  structured_query = query.match?(STRUCTURED_QUERY)
66
68
 
67
69
  @sources.each do |source|
68
- response = responses.find { |item| item.source == source }
70
+ response = response_index[source]
69
71
  parsed[source] = response ? response.candidates.filter_map { |candidate| normalized_candidate(candidate) } : []
70
72
  end
71
73
 
72
- apply_structured_query_authority!(parsed, responses) if structured_query
74
+ apply_structured_query_authority!(parsed, response_index) if structured_query
73
75
  max_size = parsed.values.map(&:length).max || 0
74
76
 
75
77
  items = []
@@ -95,11 +97,11 @@ module FetchUtil
95
97
  items
96
98
  end
97
99
 
98
- def apply_structured_query_authority!(parsed, responses)
100
+ def apply_structured_query_authority!(parsed, response_index)
99
101
  return if @sources_explicit
100
102
  return unless @sources.include?(STRUCTURED_QUERY_AUTHORITY)
101
103
 
102
- authority = responses.find { |response| response.source == STRUCTURED_QUERY_AUTHORITY }
104
+ authority = response_index[STRUCTURED_QUERY_AUTHORITY]
103
105
  return unless authority&.status == "ok" && authority.reason.nil?
104
106
 
105
107
  authority_urls = parsed.fetch(STRUCTURED_QUERY_AUTHORITY).map { |item| item[:url] }
@@ -146,9 +148,9 @@ module FetchUtil
146
148
  end
147
149
  end
148
150
 
149
- def diagnostics(responses)
151
+ def diagnostics(response_index)
150
152
  @sources.filter_map do |source|
151
- response = responses.find { |item| item.source == source }
153
+ response = response_index[source]
152
154
  next unless response
153
155
 
154
156
  diagnostic = {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetchUtil
4
- VERSION = "0.5.2"
4
+ VERSION = "0.6.1"
5
5
  end
data/lib/fetch_util.rb CHANGED
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "addressable/uri"
4
+ require "ferrum"
5
+ require "public_suffix"
4
6
  require "uri"
5
7
 
6
8
  require_relative "fetch_util/version"
7
9
 
8
10
  module FetchUtil
9
11
  class Error < StandardError; end
12
+ class InputError < ArgumentError; end
10
13
  class BrowserError < Error; end
11
14
  class ExtractionError < Error; end
12
15
 
@@ -69,7 +72,7 @@ module FetchUtil
69
72
  module_function
70
73
 
71
74
  def fetch(url, **options)
72
- Fetcher.new(**options).fetch(url)
75
+ Fetcher.fetch_once(url, **options)
73
76
  end
74
77
 
75
78
  def fetch_many(urls, **options)