fetch_util 0.4.0 → 0.5.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.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module Stabilization
6
+ module Anubis
7
+ ANUBIS_POLL = 0.25
8
+
9
+ private
10
+
11
+ def wait_for_anubis_challenge(page)
12
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
13
+ state = anubis_page_state(page)
14
+ if !valid_anubis_state?(state) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
15
+ state = anubis_page_state(page)
16
+ end
17
+ return false unless valid_anubis_state?(state) && state["challenge"] == true
18
+
19
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ return false unless remaining.positive?
21
+
22
+ retry_until_timeout(remaining, interval: ANUBIS_POLL) do
23
+ state = anubis_page_state(page)
24
+ next false unless valid_anubis_state?(state)
25
+ next false unless state["challenge"] == false
26
+
27
+ state["document_ready"] == true && state["body_present"] == true && state["body_text_present"] == true
28
+ end
29
+ end
30
+
31
+ def valid_anubis_state?(state)
32
+ state.is_a?(Hash) &&
33
+ [true, false].include?(state["challenge"]) &&
34
+ [true, false].include?(state["document_ready"]) &&
35
+ [true, false].include?(state["body_present"]) &&
36
+ [true, false].include?(state["body_text_present"]) &&
37
+ state["url"].is_a?(String) && !state["url"].empty?
38
+ end
39
+
40
+ def anubis_page_state(page)
41
+ safe_evaluate(page, <<~JS, default: {})
42
+ (() => {
43
+ const body = document.body;
44
+ const bodyText = body ? (body.innerText || '').replace(/\s+/g, ' ').trim() : '';
45
+ const anubisRoot = document.querySelector('#anubis_challenge');
46
+ const anubisScript = document.querySelector('script[src*="/.within.website/x/cmd/anubis/"]');
47
+ const visibleRoot = anubisRoot && (() => {
48
+ const style = window.getComputedStyle(anubisRoot);
49
+ const rect = anubisRoot.getBoundingClientRect();
50
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
51
+ })();
52
+ const makingSure = /making sure you're not a bot/i.test((document.title || '') + ' ' + bodyText);
53
+ const protectedChallenge = /protected by anubis/i.test(bodyText) &&
54
+ /(enable javascript to get past this challenge|please wait a moment while we ensure the security of your connection|loading|calculating|challenge:\s*anubis)/i.test(bodyText);
55
+ const challenge = Boolean((visibleRoot || anubisScript) && (makingSure || protectedChallenge));
56
+ return {
57
+ challenge,
58
+ document_ready: document.readyState === 'complete',
59
+ body_present: Boolean(body),
60
+ body_text_present: bodyText.length > 0,
61
+ url: location.href
62
+ };
63
+ })()
64
+ JS
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -38,6 +38,8 @@ module FetchUtil
38
38
  private
39
39
 
40
40
  def stabilize_page(page, url)
41
+ wait_for_anubis_challenge(page)
42
+
41
43
  if (profile = matching_stabilization_profile(url, PAGE_FLOW_STABILIZATION_PROFILES))
42
44
  return send(profile.fetch(:strategy), page)
43
45
  end
@@ -5,9 +5,11 @@ module FetchUtil
5
5
  module Stabilization
6
6
  autoload :PageFlow, "fetch_util/browser/stabilization/page_flow"
7
7
  autoload :SpaHydration, "fetch_util/browser/stabilization/spa_hydration"
8
+ autoload :Anubis, "fetch_util/browser/stabilization/anubis"
8
9
 
9
10
  include PageFlow
10
11
  include SpaHydration
12
+ include Anubis
11
13
  end
12
14
  end
13
15
  end
@@ -71,7 +71,7 @@ module FetchUtil
71
71
  end
72
72
 
73
73
  desc "search QUERY", "Search across configured engines and aggregate results"
74
- option :source, type: :array, default: FetchUtil::Searcher::DEFAULT_SOURCES, desc: "Search sources"
74
+ option :source, type: :array, default: nil, desc: "Search sources"
75
75
  option :limit, type: :numeric, default: nil, desc: "Maximum results; omit to return every result in the fetched response"
76
76
  option :verbose_search, type: :boolean, default: false, desc: "Include per-result search provenance"
77
77
  def search(*terms)
@@ -82,9 +82,8 @@ module FetchUtil
82
82
  request_log: request_log,
83
83
  sources: options[:source],
84
84
  limit: options[:limit],
85
- concurrency: [options[:concurrency], options[:source].length].min,
86
85
  verbose: options[:verbose_search],
87
- **fetch_options
86
+ timeout: options[:timeout]
88
87
  ).search(query)
89
88
 
90
89
  emit(payload)
@@ -0,0 +1,579 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "nokogiri"
5
+ require "stringio"
6
+ require "timeout"
7
+ require "uri"
8
+ require "zlib"
9
+ require "base64"
10
+
11
+ module FetchUtil
12
+ # Fetches and parses supported search result pages without involving the browser fetcher.
13
+ class SearchTransport
14
+ Candidate = Data.define(:source, :title, :url, :snippet, :source_rank) do
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))
18
+ end
19
+ end
20
+
21
+ SourceResponse = Data.define(:source, :transport, :status, :candidates, :elapsed_ms, :final_url, :reason) do
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)
26
+ end
27
+ end
28
+
29
+ HttpResponse = Data.define(:status, :headers, :body, :final_url)
30
+ HttpFailure = Data.define(:reason, :final_url)
31
+
32
+ SOURCES = {
33
+ "brave" => { url: "https://search.brave.com/search?q=%{query}", hosts: %w[search.brave.com] },
34
+ "bing" => { url: "https://www.bing.com/search?q=%{query}&setlang=en-US&cc=US", hosts: %w[www.bing.com cn.bing.com] },
35
+ "duckduckgo" => { url: "https://html.duckduckgo.com/html/?q=%{query}", hosts: %w[html.duckduckgo.com] },
36
+ "google" => { url: "https://www.google.com/search?q=%{query}", hosts: %w[www.google.com] },
37
+ "ecosia" => { url: "https://www.ecosia.org/search?q=%{query}", hosts: %w[www.ecosia.org] },
38
+ "yahoo" => { url: "https://search.yahoo.com/search?p=%{query}", hosts: %w[search.yahoo.com] }
39
+ }.freeze
40
+ DEFAULT_TIMEOUT = 10.0
41
+ YAHOO_RECOVERY_ATTEMPTS = 2
42
+ FAILURE_REASONS = %w[challenge failed host http_status parse query_mismatch redirect size timeout].freeze
43
+ RELEVANCE_HEALTH_CANDIDATE_COUNT = 3
44
+ QUERY_FUNCTION_WORDS = %w[
45
+ a an and are at be been being but by can could did do does for from had has have how i if in is it me my no not of on or our shall
46
+ should that the their them then there these they this to was we were what when where which who why will with would you your
47
+ ].freeze
48
+ SCOPED_QUERY_TERM = /(?:\A|\s)[+-]?[[:alpha:]][[:alnum:]_-]*:(?:"[^"]*"|'[^']*'|\S+)/
49
+ NEGATED_QUERY_TERM = /(?:\A|\s)-\S+/
50
+ WRAPPER_HOSTS = {
51
+ "bing" => %w[bing.com www.bing.com cn.bing.com],
52
+ "duckduckgo" => %w[duckduckgo.com www.duckduckgo.com html.duckduckgo.com],
53
+ "google" => %w[google.com www.google.com],
54
+ "yahoo" => %w[r.search.yahoo.com]
55
+ }.freeze
56
+
57
+ def initialize(sources: SOURCES.keys, timeout: DEFAULT_TIMEOUT, clock: nil, http_client: nil, html_parser: nil)
58
+ @sources = sources.map(&:to_s).freeze
59
+ unknown = @sources - SOURCES.keys
60
+ raise ArgumentError, "unknown search sources: #{unknown.join(", ")}" if unknown.any?
61
+
62
+ @timeout = Float(timeout)
63
+ raise ArgumentError, "timeout must be positive" unless @timeout.positive?
64
+
65
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
66
+ @http_client = http_client || HttpClient.new(clock: @clock)
67
+ @html_parser = html_parser || ->(body) { Nokogiri::HTML(body) }
68
+ end
69
+
70
+ def self.candidates_match_query?(query, candidates)
71
+ candidates.first(RELEVANCE_HEALTH_CANDIDATE_COUNT).any? do |candidate|
72
+ candidate_matches_query?(query, candidate)
73
+ end
74
+ end
75
+
76
+ def self.candidate_matches_query?(query, candidate)
77
+ query_terms = meaningful_query_terms(normalized_query_text(query))
78
+ return true if query_terms.length < 3
79
+
80
+ candidate_terms = normalized_terms("#{candidate.title} #{candidate.snippet}")
81
+ (query_terms & candidate_terms).length >= 2
82
+ end
83
+
84
+ def search(query, timeout: @timeout)
85
+ query = query.to_s.strip
86
+ raise ArgumentError, "query must not be empty" if query.empty?
87
+
88
+ request_timeout = Float(timeout)
89
+ raise ArgumentError, "timeout must be positive" unless request_timeout.positive?
90
+
91
+ deadline = clock.call + request_timeout
92
+ 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) }
95
+ end
96
+ threads.each(&:join)
97
+ responses
98
+ end
99
+
100
+ private
101
+
102
+ attr_reader :clock, :html_parser, :http_client, :sources, :timeout
103
+
104
+ def search_source(source, query, deadline)
105
+ started_at = clock.call
106
+ url = build_url(source, query)
107
+ result = yahoo_response(source, url, deadline)
108
+ if result.is_a?(HttpFailure)
109
+ return failure(source, result.reason, elapsed_since(started_at), result.final_url)
110
+ end
111
+ outcome = within_deadline(deadline) do
112
+ document = html_parser.call(result.body)
113
+ next [:failed, "challenge"] if challenge?(source, result, document)
114
+ next [:failed, "http_status"] unless result.status.between?(200, 299)
115
+ next [:empty] if no_results?(source, document)
116
+
117
+ candidates = parse_candidates(source, document)
118
+ next [:ok, candidates] if candidates.empty?
119
+ next [:suspect, candidates] unless self.class.candidates_match_query?(query, candidates)
120
+
121
+ [:ok, candidates]
122
+ end
123
+ elapsed_ms = elapsed_since(started_at)
124
+ return failure(source, outcome[1], elapsed_ms, result.final_url, candidates: outcome[2] || []) if outcome.first == :failed
125
+ return empty(source, elapsed_ms, result.final_url) if outcome.first == :empty
126
+ if outcome.first == :suspect
127
+ return SourceResponse.new(
128
+ source: source,
129
+ status: "ok",
130
+ candidates: outcome.last,
131
+ elapsed_ms: elapsed_ms,
132
+ final_url: result.final_url,
133
+ reason: "query_mismatch"
134
+ )
135
+ end
136
+
137
+ candidates = outcome.last
138
+ return failure(source, "parse", elapsed_ms, result.final_url) if candidates.empty?
139
+
140
+ SourceResponse.new(source: source, status: "ok", candidates: candidates, elapsed_ms: elapsed_ms, final_url: result.final_url)
141
+ rescue HttpClient::DeadlineExceeded
142
+ failure(source, "timeout", elapsed_since(started_at), url)
143
+ rescue Nokogiri::XML::SyntaxError
144
+ failure(source, "parse", elapsed_since(started_at), url)
145
+ rescue StandardError
146
+ # The public boundary intentionally maps implementation failures to a finite diagnostic code.
147
+ failure(source, "failed", elapsed_since(started_at), url)
148
+ end
149
+
150
+ def yahoo_response(source, url, deadline)
151
+ return http_client.get(url, deadline: deadline, allowed_hosts: SOURCES.fetch(source).fetch(:hosts)) unless source == "yahoo"
152
+
153
+ result = http_client.get(url, deadline: deadline, allowed_hosts: SOURCES.fetch(source).fetch(:hosts))
154
+ YAHOO_RECOVERY_ATTEMPTS.times do
155
+ break unless yahoo_retryable?(result) && deadline > clock.call
156
+
157
+ result = http_client.get(url, deadline: deadline, allowed_hosts: SOURCES.fetch(source).fetch(:hosts))
158
+ end
159
+ result
160
+ end
161
+
162
+ def yahoo_retryable?(result)
163
+ return result.reason == "failed" if result.is_a?(HttpFailure)
164
+
165
+ result.is_a?(HttpResponse) && (result.status == 429 || result.status.between?(500, 599))
166
+ end
167
+
168
+ def build_url(source, query)
169
+ SOURCES.fetch(source).fetch(:url) % { query: URI.encode_www_form_component(query) }
170
+ end
171
+
172
+ def parse_candidates(source, document)
173
+ result_nodes(source, document).filter_map do |node|
174
+ next if excluded_node?(source, node)
175
+
176
+ anchor = result_anchor(source, node)
177
+ next unless anchor
178
+
179
+ url = destination(source, anchor["href"])
180
+ title = normalized_text(title_node(source, node, anchor))
181
+ next if url.nil? || title.empty? || engine_url?(source, url)
182
+
183
+ snippet = normalized_text(node.at_css(snippet_selector(source)))
184
+ [title, url, snippet]
185
+ end.each_with_index.map do |(title, url, snippet), index|
186
+ Candidate.new(source: source, title: title, url: url, snippet: snippet.empty? ? nil : snippet, source_rank: index + 1)
187
+ end
188
+ end
189
+
190
+ def self.meaningful_query_terms(query)
191
+ semantic_query = query.gsub(SCOPED_QUERY_TERM, " ").gsub(NEGATED_QUERY_TERM, " ")
192
+ normalized_terms(semantic_query).reject { |term| QUERY_FUNCTION_WORDS.include?(term) }
193
+ end
194
+
195
+ def self.normalized_terms(text)
196
+ normalized = normalized_query_text(text)
197
+ normalized = normalized.gsub(/([[:upper:]]+)([[:upper:]][[:lower:]])/, '\1 \2')
198
+ normalized = normalized.gsub(/([[:lower:]\d])([[:upper:]])/, '\1 \2')
199
+ normalized.downcase.scan(/[[:alnum:]]+/).uniq
200
+ end
201
+
202
+ def self.normalized_query_text(text)
203
+ text.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: " ").unicode_normalize(:nfc)
204
+ end
205
+
206
+ private_class_method :meaningful_query_terms, :normalized_terms, :normalized_query_text
207
+
208
+ def result_nodes(source, document)
209
+ return brave_result_nodes(document) if source == "brave"
210
+ return yahoo_result_nodes(document) if source == "yahoo"
211
+
212
+ selector = {
213
+ "bing" => "#b_results li.b_algo",
214
+ "duckduckgo" => ".results .result, .result.results_links",
215
+ "google" => "#search .g, #search .MjjYud, #search [data-snhf]",
216
+ "ecosia" => ".result, article.result"
217
+ }.fetch(source)
218
+ nodes = document.css(selector).uniq
219
+ nodes.reject { |node| nodes.any? { |other| other != node && other.ancestors.include?(node) } }
220
+ end
221
+
222
+ def brave_result_nodes(document)
223
+ current = document.css(".snippet[data-type='web']")
224
+ legacy = document.css("#results > .snippet:not([id]):not([data-type])").select { |node| legacy_brave_result?(node) }
225
+ (current.to_a + legacy).uniq
226
+ end
227
+
228
+ def yahoo_result_nodes(document)
229
+ nodes = document.css("#web .algo, #web .algo-sr, #web .dd.algo").uniq
230
+ nodes.reject { |node| nodes.any? { |other| other != node && other.ancestors.include?(node) } }
231
+ end
232
+
233
+ def legacy_brave_result?(node)
234
+ node.at_css("h2, h3, .title.search-snippet-title") && result_anchor("brave", node)
235
+ end
236
+
237
+ def result_anchor(source, node)
238
+ case source
239
+ when "brave"
240
+ node.css("a[href]").find do |candidate|
241
+ url = destination(source, candidate["href"])
242
+ url && !engine_url?(source, url)
243
+ end
244
+ when "bing" then node.at_css("h2 a[href]")
245
+ when "duckduckgo" then node.at_css("a.result__a[href], h2 a[href]")
246
+ when "google" then node.at_css("a[href]:has(h3)") || node.at_css("h3")&.ancestors("a[href]")&.first
247
+ when "yahoo" then node.at_css("h3 a[href], .compTitle a[href]")
248
+ else node.at_css("h2 a[href], h3 a[href], a[href]:has(h3)")
249
+ end
250
+ end
251
+
252
+ def title_node(source, node, anchor)
253
+ return node.at_css(".title.search-snippet-title") || anchor.at_css("h2, h3") || anchor if source == "brave"
254
+
255
+ anchor.at_css("h2, h3") || anchor
256
+ end
257
+
258
+ def snippet_selector(source)
259
+ {
260
+ "brave" => ".content, .snippet-description, .snippet-content, .description",
261
+ "bing" => ".b_caption p, .b_paractl",
262
+ "duckduckgo" => ".result__snippet",
263
+ "google" => ".VwiC3b, .aCOpRe, [data-sncf]",
264
+ "ecosia" => ".result-snippet, .result__description",
265
+ "yahoo" => ".compText, .compText p"
266
+ }.fetch(source)
267
+ end
268
+
269
+ def excluded_node?(source, node)
270
+ return yahoo_excluded_node?(node) if source == "yahoo"
271
+
272
+ node.xpath("ancestor-or-self::*").any? do |ancestor|
273
+ value = [ancestor["class"], ancestor["id"], ancestor["data-testid"]].compact.join(" ").downcase
274
+ value.match?(/\b(ad|ads|advert|enrichment|knowledge|llm|nav|pagination|related|answer)\b/)
275
+ end
276
+ end
277
+
278
+ def yahoo_excluded_node?(node)
279
+ node.xpath("ancestor-or-self::*").any? do |ancestor|
280
+ value = [ancestor["class"], ancestor["id"], ancestor["data-testid"]].compact.join(" ").downcase
281
+ value.match?(/\b(ad|ads|advert|control|controls|enrichment|knowledge|llm|lookalike|nav|navigation|pagination|related|right[-_ ]?rail|assist)\b/)
282
+ end
283
+ end
284
+
285
+ def destination(source, href)
286
+ value = href.to_s.strip
287
+ value = decode_bing(value) if source == "bing"
288
+ value = decode_wrapper(source, value) if %w[google duckduckgo yahoo].include?(source)
289
+ uri = URI.parse(value)
290
+ return unless uri.is_a?(URI::HTTP) && uri.host
291
+
292
+ uri.to_s
293
+ rescue URI::InvalidURIError
294
+ nil
295
+ end
296
+
297
+ def decode_bing(value)
298
+ uri = URI.parse(value)
299
+ return value unless uri.path == "/ck/a" && (uri.host.nil? || wrapper_host?("bing", uri.host))
300
+
301
+ encoded = URI.decode_www_form(uri.query.to_s).assoc("u")&.last.to_s
302
+ return value unless encoded.start_with?("a1")
303
+
304
+ decoded = encoded.delete_prefix("a1").tr("-_,", "+/=")
305
+ decoded += "=" * ((4 - decoded.length % 4) % 4)
306
+ Base64.strict_decode64(decoded).force_encoding("UTF-8").scrub
307
+ rescue ArgumentError, URI::InvalidURIError
308
+ value
309
+ end
310
+
311
+ def decode_wrapper(source, value)
312
+ uri = URI.parse(value)
313
+ return decode_yahoo_wrapper(uri, value) if source == "yahoo"
314
+
315
+ key = source == "google" ? %w[q url] : %w[uddg]
316
+ path = wrapper_path(source)
317
+ return value unless uri.path == path && (uri.host.nil? || wrapper_host?(source, uri.host))
318
+
319
+ URI.decode_www_form(uri.query.to_s).to_h.values_at(*key).compact.first || value
320
+ rescue URI::InvalidURIError
321
+ value
322
+ end
323
+
324
+ def decode_yahoo_wrapper(uri, value)
325
+ return value unless wrapper_host?("yahoo", uri.host)
326
+
327
+ segment = uri.path.split("/").find { |part| part.start_with?("RU=") }
328
+ return value unless segment
329
+
330
+ decoded = URI::DEFAULT_PARSER.unescape(segment.delete_prefix("RU="))
331
+ destination = URI.parse(decoded)
332
+ return nil unless destination.is_a?(URI::HTTP) && destination.host
333
+
334
+ destination.to_s
335
+ rescue URI::InvalidURIError
336
+ nil
337
+ end
338
+
339
+ def engine_url?(source, value)
340
+ host = URI.parse(value).host
341
+ same_engine_host?(source, host) || wrapper_host?(source, host)
342
+ rescue URI::InvalidURIError
343
+ true
344
+ end
345
+
346
+ def same_engine_host?(source, host)
347
+ SOURCES.fetch(source).fetch(:hosts).include?(host.to_s.downcase)
348
+ end
349
+
350
+ def wrapper_host?(source, host)
351
+ WRAPPER_HOSTS.fetch(source, []).include?(host.to_s.downcase)
352
+ end
353
+
354
+ def wrapper_path(source)
355
+ { "bing" => "/ck/a", "google" => "/url", "duckduckgo" => "/l/" }.fetch(source)
356
+ end
357
+
358
+ def normalized_text(node)
359
+ node ? node.text.encode("UTF-8", invalid: :replace, undef: :replace, replace: " ").gsub(/\s+/, " ").strip : ""
360
+ end
361
+
362
+ def challenge?(source, response, document)
363
+ return true if source == "google" && URI.parse(response.final_url).path.start_with?("/sorry")
364
+ return true if source == "duckduckgo" && response.status == 202
365
+
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/)
369
+ rescue URI::InvalidURIError
370
+ true
371
+ end
372
+
373
+ def no_results?(source, document)
374
+ text = visible_document_text(document)
375
+ patterns = {
376
+ "brave" => /no results|did not match any documents/,
377
+ "bing" => /there are no results|no results found/,
378
+ "duckduckgo" => /no results|no more results/,
379
+ "google" => /did not match any documents|no results found/,
380
+ "ecosia" => /no results found|we couldn't find/,
381
+ "yahoo" => /no results found|we couldn't find|did not match any results/
382
+ }
383
+ text.match?(patterns.fetch(source))
384
+ end
385
+
386
+ def visible_document_text(document)
387
+ visible = document.dup
388
+ visible.css("script, style, template, noscript").remove
389
+ normalized_text(visible).downcase
390
+ end
391
+
392
+ def failure(source, reason, elapsed_ms, final_url, candidates: [])
393
+ reason = reason.to_s
394
+ reason = "failed" unless FAILURE_REASONS.include?(reason)
395
+ SourceResponse.new(
396
+ source: source,
397
+ status: "failed",
398
+ candidates: candidates,
399
+ elapsed_ms: elapsed_ms,
400
+ final_url: final_url,
401
+ reason: reason
402
+ )
403
+ end
404
+
405
+ def empty(source, elapsed_ms, final_url)
406
+ SourceResponse.new(source: source, status: "empty", elapsed_ms: elapsed_ms, final_url: final_url)
407
+ end
408
+
409
+ def elapsed_since(started_at)
410
+ ((clock.call - started_at) * 1000).round
411
+ end
412
+
413
+ def within_deadline(deadline, &block)
414
+ seconds = deadline - clock.call
415
+ raise HttpClient::DeadlineExceeded unless seconds.positive?
416
+
417
+ Timeout.timeout(seconds, HttpClient::DeadlineExceeded, &block)
418
+ ensure
419
+ raise HttpClient::DeadlineExceeded unless deadline - clock.call > 0
420
+ end
421
+
422
+ # Separate from the regulatory HTTP client: this enforces the SERP host and deadline contract.
423
+ class HttpClient
424
+ REDIRECT_LIMIT = 4
425
+ MAX_RESPONSE_BYTES = 2 * 1024 * 1024
426
+ INFLATE_CHUNK_BYTES = 16 * 1024
427
+
428
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, max_response_bytes: MAX_RESPONSE_BYTES,
429
+ net_http: nil)
430
+ @clock = clock
431
+ @max_response_bytes = Integer(max_response_bytes)
432
+ @net_http = net_http || ->(uri) { Net::HTTP.new(uri.host, uri.port) }
433
+ end
434
+
435
+ def get(url, deadline:, allowed_hosts:)
436
+ fetch(URI.parse(url), deadline, allowed_hosts.map(&:downcase), REDIRECT_LIMIT)
437
+ rescue URI::InvalidURIError
438
+ HttpFailure.new(reason: "host", final_url: url)
439
+ end
440
+
441
+ private
442
+
443
+ attr_reader :clock, :max_response_bytes, :net_http
444
+
445
+ def fetch(uri, deadline, allowed_hosts, redirects_left)
446
+ return HttpFailure.new(reason: "host", final_url: uri.to_s) unless allowed_uri?(uri, allowed_hosts)
447
+ return HttpFailure.new(reason: "timeout", final_url: uri.to_s) unless remaining(deadline).positive?
448
+
449
+ response, body = request(uri, deadline)
450
+ ensure_remaining!(deadline)
451
+ if response.is_a?(Net::HTTPRedirection)
452
+ return HttpFailure.new(reason: "redirect", final_url: uri.to_s) if redirects_left.zero? || response["location"].to_s.empty?
453
+
454
+ target = begin
455
+ uri.merge(response["location"])
456
+ rescue URI::InvalidURIError
457
+ return HttpFailure.new(reason: "redirect", final_url: uri.to_s)
458
+ end
459
+ return HttpFailure.new(reason: "host", final_url: target.to_s) unless allowed_uri?(target, allowed_hosts)
460
+
461
+ return fetch(target, deadline, allowed_hosts, redirects_left - 1)
462
+ end
463
+ decoded = decode(body, response["content-encoding"], response["content-type"], deadline)
464
+ ensure_remaining!(deadline)
465
+
466
+ HttpResponse.new(status: response.code.to_i, headers: response.to_hash, body: decoded, final_url: uri.to_s)
467
+ rescue DeadlineExceeded, Timeout::Error
468
+ HttpFailure.new(reason: "timeout", final_url: uri.to_s)
469
+ rescue ResponseTooLarge
470
+ HttpFailure.new(reason: "size", final_url: uri.to_s)
471
+ rescue Zlib::Error
472
+ HttpFailure.new(reason: "parse", final_url: uri.to_s)
473
+ rescue SystemCallError, IOError
474
+ HttpFailure.new(reason: "failed", final_url: uri.to_s)
475
+ end
476
+
477
+ def request(uri, deadline)
478
+ seconds = remaining(deadline)
479
+ raise DeadlineExceeded unless seconds.positive?
480
+
481
+ http = net_http.call(uri)
482
+ http.use_ssl = uri.scheme == "https"
483
+ http.open_timeout = seconds
484
+ http.read_timeout = seconds
485
+ http.write_timeout = seconds
486
+ request = Net::HTTP::Get.new(
487
+ uri.request_uri.empty? ? "/" : uri.request_uri,
488
+ "Accept-Encoding" => "gzip, deflate",
489
+ "Accept-Language" => "en-US,en;q=0.9",
490
+ "User-Agent" => "fetch_util"
491
+ )
492
+ body = +""
493
+ response = within_wall_clock_timeout(seconds) do
494
+ http.request(request) do |incoming|
495
+ incoming.read_body do |chunk|
496
+ ensure_remaining!(deadline)
497
+ body << chunk
498
+ raise ResponseTooLarge if body.bytesize > max_response_bytes
499
+ end
500
+ end
501
+ end
502
+ [response, body]
503
+ end
504
+
505
+ def decode(body, encoding, content_type, deadline)
506
+ decoded = within_deadline(deadline) do
507
+ decoded = inflate(body, encoding, deadline)
508
+ charset = charset_from(content_type, decoded)
509
+ begin
510
+ decoded.force_encoding(charset).encode("UTF-8", invalid: :replace, undef: :replace, replace: " ")
511
+ rescue ArgumentError, Encoding::ConverterNotFoundError
512
+ decoded.force_encoding("UTF-8").scrub(" ")
513
+ end
514
+ end
515
+ ensure_remaining!(deadline)
516
+ decoded
517
+ end
518
+
519
+ def inflate(body, encoding, deadline)
520
+ return body unless %w[gzip deflate].include?(encoding.to_s.downcase)
521
+
522
+ window_bits = encoding.to_s.downcase == "gzip" ? Zlib::MAX_WBITS + 16 : Zlib::MAX_WBITS
523
+ inflater = Zlib::Inflate.new(window_bits)
524
+ decoded = +""
525
+ offset = 0
526
+ while offset < body.bytesize
527
+ ensure_remaining!(deadline)
528
+ chunk = body.byteslice(offset, INFLATE_CHUNK_BYTES)
529
+ append_decoded(decoded, inflater.inflate(chunk))
530
+ offset += chunk.bytesize
531
+ end
532
+ append_decoded(decoded, inflater.finish)
533
+ decoded
534
+ ensure
535
+ inflater&.close
536
+ end
537
+
538
+ def append_decoded(decoded, chunk)
539
+ decoded << chunk
540
+ raise ResponseTooLarge if decoded.bytesize > max_response_bytes
541
+ end
542
+
543
+ def charset_from(content_type, body)
544
+ header_charset = content_type.to_s[/charset\s*=\s*['"]?([^;'"\s]+)/i, 1]
545
+ return header_charset if header_charset
546
+
547
+ body.byteslice(0, 4096).to_s[/<meta\b[^>]*\bcharset\s*=\s*['"]?([^\s'";>]+)/i, 1] || "UTF-8"
548
+ end
549
+
550
+ def allowed_uri?(uri, allowed_hosts)
551
+ uri.is_a?(URI::HTTP) && uri.host && allowed_hosts.include?(uri.host.downcase)
552
+ end
553
+
554
+ def remaining(deadline)
555
+ deadline - clock.call
556
+ end
557
+
558
+ def ensure_remaining!(deadline)
559
+ raise DeadlineExceeded unless remaining(deadline).positive?
560
+ end
561
+
562
+ def within_wall_clock_timeout(seconds, &block)
563
+ return block.call unless seconds.finite?
564
+
565
+ Timeout.timeout(seconds, DeadlineExceeded, &block)
566
+ end
567
+
568
+ def within_deadline(deadline, &block)
569
+ seconds = remaining(deadline)
570
+ raise DeadlineExceeded unless seconds.positive?
571
+
572
+ within_wall_clock_timeout(seconds, &block)
573
+ end
574
+
575
+ ResponseTooLarge = Class.new(StandardError)
576
+ DeadlineExceeded = Class.new(StandardError)
577
+ end
578
+ end
579
+ end