fetch_util 0.4.0 → 0.5.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.
@@ -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,496 @@
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
+ }.freeze
39
+ DEFAULT_TIMEOUT = 10.0
40
+ FAILURE_REASONS = %w[challenge failed host http_status parse query_mismatch redirect size timeout].freeze
41
+ RELEVANCE_HEALTH_CANDIDATE_COUNT = 3
42
+ QUERY_FUNCTION_WORDS = %w[
43
+ 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
44
+ 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
45
+ ].freeze
46
+ WRAPPER_HOSTS = {
47
+ "bing" => %w[bing.com www.bing.com cn.bing.com],
48
+ "duckduckgo" => %w[duckduckgo.com www.duckduckgo.com html.duckduckgo.com],
49
+ "google" => %w[google.com www.google.com]
50
+ }.freeze
51
+
52
+ def initialize(sources: SOURCES.keys, timeout: DEFAULT_TIMEOUT, clock: nil, http_client: nil, html_parser: nil)
53
+ @sources = sources.map(&:to_s).freeze
54
+ unknown = @sources - SOURCES.keys
55
+ raise ArgumentError, "unknown search sources: #{unknown.join(", ")}" if unknown.any?
56
+
57
+ @timeout = Float(timeout)
58
+ raise ArgumentError, "timeout must be positive" unless @timeout.positive?
59
+
60
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
61
+ @http_client = http_client || HttpClient.new(clock: @clock)
62
+ @html_parser = html_parser || ->(body) { Nokogiri::HTML(body) }
63
+ end
64
+
65
+ def search(query)
66
+ query = query.to_s.strip
67
+ raise ArgumentError, "query must not be empty" if query.empty?
68
+
69
+ deadline = clock.call + timeout
70
+ responses = Array.new(sources.length)
71
+ threads = sources.each_with_index.map do |source, index|
72
+ Thread.new { responses[index] = search_source(source, query, deadline) }
73
+ end
74
+ threads.each(&:join)
75
+ responses
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :clock, :html_parser, :http_client, :sources, :timeout
81
+
82
+ def search_source(source, query, deadline)
83
+ started_at = clock.call
84
+ url = build_url(source, query)
85
+ result = http_client.get(url, deadline: deadline, allowed_hosts: SOURCES.fetch(source).fetch(:hosts))
86
+ return failure(source, result.reason, elapsed_since(started_at), result.final_url) if result.is_a?(HttpFailure)
87
+ outcome = within_deadline(deadline) do
88
+ document = html_parser.call(result.body)
89
+ next [:failed, "challenge"] if challenge?(source, result, document)
90
+ next [:failed, "http_status"] unless result.status.between?(200, 299)
91
+ next [:empty] if no_results?(source, document)
92
+
93
+ candidates = parse_candidates(source, document)
94
+ next [:ok, candidates] if candidates.empty?
95
+ next [:failed, "query_mismatch"] unless query_matches_candidates?(query, candidates)
96
+
97
+ [:ok, candidates]
98
+ end
99
+ elapsed_ms = elapsed_since(started_at)
100
+ return failure(source, outcome.last, elapsed_ms, result.final_url) if outcome.first == :failed
101
+ return empty(source, elapsed_ms, result.final_url) if outcome.first == :empty
102
+
103
+ candidates = outcome.last
104
+ return failure(source, "parse", elapsed_ms, result.final_url) if candidates.empty?
105
+
106
+ SourceResponse.new(source: source, status: "ok", candidates: candidates, elapsed_ms: elapsed_ms, final_url: result.final_url)
107
+ rescue HttpClient::DeadlineExceeded
108
+ failure(source, "timeout", elapsed_since(started_at), url)
109
+ rescue Nokogiri::XML::SyntaxError
110
+ failure(source, "parse", elapsed_since(started_at), url)
111
+ rescue StandardError
112
+ # The public boundary intentionally maps implementation failures to a finite diagnostic code.
113
+ failure(source, "failed", elapsed_since(started_at), url)
114
+ end
115
+
116
+ def build_url(source, query)
117
+ SOURCES.fetch(source).fetch(:url) % { query: URI.encode_www_form_component(query) }
118
+ end
119
+
120
+ def parse_candidates(source, document)
121
+ result_nodes(source, document).filter_map do |node|
122
+ next if excluded_node?(node)
123
+
124
+ anchor = result_anchor(source, node)
125
+ next unless anchor
126
+
127
+ url = destination(source, anchor["href"])
128
+ title = normalized_text(title_node(source, node, anchor))
129
+ next if url.nil? || title.empty? || engine_url?(source, url)
130
+
131
+ snippet = normalized_text(node.at_css(snippet_selector(source)))
132
+ [title, url, snippet]
133
+ end.each_with_index.map do |(title, url, snippet), index|
134
+ Candidate.new(source: source, title: title, url: url, snippet: snippet.empty? ? nil : snippet, source_rank: index + 1)
135
+ end
136
+ end
137
+
138
+ def query_matches_candidates?(query, candidates)
139
+ normalized_query = normalized_query_text(query)
140
+ query_terms = meaningful_query_terms(normalized_query)
141
+ return true unless relevance_gate_applies?(normalized_query, query_terms)
142
+
143
+ candidates.first(RELEVANCE_HEALTH_CANDIDATE_COUNT).any? do |candidate|
144
+ candidate_terms = normalized_terms("#{candidate.title} #{candidate.snippet}")
145
+ (query_terms & candidate_terms).length >= 2
146
+ end
147
+ end
148
+
149
+ def relevance_gate_applies?(query, query_terms)
150
+ return false if query_terms.length < 3
151
+
152
+ !query.match?(%r{["']|(?:\A|\s)[+-]?[[:alpha:]][[:alnum:]_-]*:|[+#/]|(?:\b[[:alpha:]]\.){2,}|\d|\b[[:upper:]]{2,}\b})
153
+ end
154
+
155
+ def meaningful_query_terms(query)
156
+ normalized_terms(query).reject { |term| QUERY_FUNCTION_WORDS.include?(term) }
157
+ end
158
+
159
+ def normalized_terms(text)
160
+ normalized_query_text(text).downcase.scan(/[[:alnum:]]+/).uniq
161
+ end
162
+
163
+ def normalized_query_text(text)
164
+ text.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: " ").unicode_normalize(:nfc)
165
+ end
166
+
167
+ def result_nodes(source, document)
168
+ return brave_result_nodes(document) if source == "brave"
169
+
170
+ selector = {
171
+ "bing" => "#b_results li.b_algo",
172
+ "duckduckgo" => ".results .result, .result.results_links",
173
+ "google" => "#search .g, #search .MjjYud, #search [data-snhf]",
174
+ "ecosia" => ".result, article.result"
175
+ }.fetch(source)
176
+ nodes = document.css(selector).uniq
177
+ nodes.reject { |node| nodes.any? { |other| other != node && other.ancestors.include?(node) } }
178
+ end
179
+
180
+ def brave_result_nodes(document)
181
+ current = document.css(".snippet[data-type='web']")
182
+ legacy = document.css("#results > .snippet:not([id]):not([data-type])").select { |node| legacy_brave_result?(node) }
183
+ (current.to_a + legacy).uniq
184
+ end
185
+
186
+ def legacy_brave_result?(node)
187
+ node.at_css("h2, h3, .title.search-snippet-title") && result_anchor("brave", node)
188
+ end
189
+
190
+ def result_anchor(source, node)
191
+ case source
192
+ when "brave"
193
+ node.css("a[href]").find do |candidate|
194
+ url = destination(source, candidate["href"])
195
+ url && !engine_url?(source, url)
196
+ end
197
+ when "bing" then node.at_css("h2 a[href]")
198
+ when "duckduckgo" then node.at_css("a.result__a[href], h2 a[href]")
199
+ when "google" then node.at_css("a[href]:has(h3)") || node.at_css("h3")&.ancestors("a[href]")&.first
200
+ else node.at_css("h2 a[href], h3 a[href], a[href]:has(h3)")
201
+ end
202
+ end
203
+
204
+ def title_node(source, node, anchor)
205
+ return node.at_css(".title.search-snippet-title") || anchor.at_css("h2, h3") || anchor if source == "brave"
206
+
207
+ anchor.at_css("h2, h3") || anchor
208
+ end
209
+
210
+ def snippet_selector(source)
211
+ {
212
+ "brave" => ".content, .snippet-description, .snippet-content, .description",
213
+ "bing" => ".b_caption p, .b_paractl",
214
+ "duckduckgo" => ".result__snippet",
215
+ "google" => ".VwiC3b, .aCOpRe, [data-sncf]",
216
+ "ecosia" => ".result-snippet, .result__description"
217
+ }.fetch(source)
218
+ end
219
+
220
+ def excluded_node?(node)
221
+ node.xpath("ancestor-or-self::*").any? do |ancestor|
222
+ value = [ancestor["class"], ancestor["id"], ancestor["data-testid"]].compact.join(" ").downcase
223
+ value.match?(/\b(ad|ads|advert|enrichment|knowledge|llm|nav|pagination|related|answer)\b/)
224
+ end
225
+ end
226
+
227
+ def destination(source, href)
228
+ value = href.to_s.strip
229
+ value = decode_bing(value) if source == "bing"
230
+ value = decode_wrapper(source, value) if %w[google duckduckgo].include?(source)
231
+ uri = URI.parse(value)
232
+ return unless uri.is_a?(URI::HTTP) && uri.host
233
+
234
+ uri.to_s
235
+ rescue URI::InvalidURIError
236
+ nil
237
+ end
238
+
239
+ def decode_bing(value)
240
+ uri = URI.parse(value)
241
+ return value unless uri.path == "/ck/a" && (uri.host.nil? || wrapper_host?("bing", uri.host))
242
+
243
+ encoded = URI.decode_www_form(uri.query.to_s).assoc("u")&.last.to_s
244
+ return value unless encoded.start_with?("a1")
245
+
246
+ decoded = encoded.delete_prefix("a1").tr("-_,", "+/=")
247
+ decoded += "=" * ((4 - decoded.length % 4) % 4)
248
+ Base64.strict_decode64(decoded).force_encoding("UTF-8").scrub
249
+ rescue ArgumentError, URI::InvalidURIError
250
+ value
251
+ end
252
+
253
+ def decode_wrapper(source, value)
254
+ uri = URI.parse(value)
255
+ key = source == "google" ? %w[q url] : %w[uddg]
256
+ path = wrapper_path(source)
257
+ return value unless uri.path == path && (uri.host.nil? || wrapper_host?(source, uri.host))
258
+
259
+ URI.decode_www_form(uri.query.to_s).to_h.values_at(*key).compact.first || value
260
+ rescue URI::InvalidURIError
261
+ value
262
+ end
263
+
264
+ def engine_url?(source, value)
265
+ host = URI.parse(value).host
266
+ same_engine_host?(source, host) || wrapper_host?(source, host)
267
+ rescue URI::InvalidURIError
268
+ true
269
+ end
270
+
271
+ def same_engine_host?(source, host)
272
+ SOURCES.fetch(source).fetch(:hosts).include?(host.to_s.downcase)
273
+ end
274
+
275
+ def wrapper_host?(source, host)
276
+ WRAPPER_HOSTS.fetch(source, []).include?(host.to_s.downcase)
277
+ end
278
+
279
+ def wrapper_path(source)
280
+ { "bing" => "/ck/a", "google" => "/url", "duckduckgo" => "/l/" }.fetch(source)
281
+ end
282
+
283
+ def normalized_text(node)
284
+ node ? node.text.encode("UTF-8", invalid: :replace, undef: :replace, replace: " ").gsub(/\s+/, " ").strip : ""
285
+ end
286
+
287
+ def challenge?(source, response, document)
288
+ return true if source == "google" && URI.parse(response.final_url).path.start_with?("/sorry")
289
+ return true if source == "duckduckgo" && response.status == 202
290
+
291
+ title = normalized_text(document.at_css("title")).downcase
292
+ visible_text = visible_document_text(document)
293
+ "#{title} #{visible_text}".match?(/captcha|unusual traffic|verify you are human|consent/)
294
+ rescue URI::InvalidURIError
295
+ true
296
+ end
297
+
298
+ def no_results?(source, document)
299
+ text = visible_document_text(document)
300
+ patterns = {
301
+ "brave" => /no results|did not match any documents/,
302
+ "bing" => /there are no results|no results found/,
303
+ "duckduckgo" => /no results|no more results/,
304
+ "google" => /did not match any documents|no results found/,
305
+ "ecosia" => /no results found|we couldn't find/
306
+ }
307
+ text.match?(patterns.fetch(source))
308
+ end
309
+
310
+ def visible_document_text(document)
311
+ visible = document.dup
312
+ visible.css("script, style, template, noscript").remove
313
+ normalized_text(visible).downcase
314
+ end
315
+
316
+ def failure(source, reason, elapsed_ms, final_url)
317
+ reason = reason.to_s
318
+ reason = "failed" unless FAILURE_REASONS.include?(reason)
319
+ SourceResponse.new(source: source, status: "failed", elapsed_ms: elapsed_ms, final_url: final_url, reason: reason)
320
+ end
321
+
322
+ def empty(source, elapsed_ms, final_url)
323
+ SourceResponse.new(source: source, status: "empty", elapsed_ms: elapsed_ms, final_url: final_url)
324
+ end
325
+
326
+ def elapsed_since(started_at)
327
+ ((clock.call - started_at) * 1000).round
328
+ end
329
+
330
+ def within_deadline(deadline, &block)
331
+ seconds = deadline - clock.call
332
+ raise HttpClient::DeadlineExceeded unless seconds.positive?
333
+
334
+ Timeout.timeout(seconds, HttpClient::DeadlineExceeded, &block)
335
+ ensure
336
+ raise HttpClient::DeadlineExceeded unless deadline - clock.call > 0
337
+ end
338
+
339
+ # Separate from the regulatory HTTP client: this enforces the SERP host and deadline contract.
340
+ class HttpClient
341
+ REDIRECT_LIMIT = 4
342
+ MAX_RESPONSE_BYTES = 2 * 1024 * 1024
343
+ INFLATE_CHUNK_BYTES = 16 * 1024
344
+
345
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, max_response_bytes: MAX_RESPONSE_BYTES,
346
+ net_http: nil)
347
+ @clock = clock
348
+ @max_response_bytes = Integer(max_response_bytes)
349
+ @net_http = net_http || ->(uri) { Net::HTTP.new(uri.host, uri.port) }
350
+ end
351
+
352
+ def get(url, deadline:, allowed_hosts:)
353
+ fetch(URI.parse(url), deadline, allowed_hosts.map(&:downcase), REDIRECT_LIMIT)
354
+ rescue URI::InvalidURIError
355
+ HttpFailure.new(reason: "host", final_url: url)
356
+ end
357
+
358
+ private
359
+
360
+ attr_reader :clock, :max_response_bytes, :net_http
361
+
362
+ def fetch(uri, deadline, allowed_hosts, redirects_left)
363
+ return HttpFailure.new(reason: "host", final_url: uri.to_s) unless allowed_uri?(uri, allowed_hosts)
364
+ return HttpFailure.new(reason: "timeout", final_url: uri.to_s) unless remaining(deadline).positive?
365
+
366
+ response, body = request(uri, deadline)
367
+ ensure_remaining!(deadline)
368
+ if response.is_a?(Net::HTTPRedirection)
369
+ return HttpFailure.new(reason: "redirect", final_url: uri.to_s) if redirects_left.zero? || response["location"].to_s.empty?
370
+
371
+ target = begin
372
+ uri.merge(response["location"])
373
+ rescue URI::InvalidURIError
374
+ return HttpFailure.new(reason: "redirect", final_url: uri.to_s)
375
+ end
376
+ return HttpFailure.new(reason: "host", final_url: target.to_s) unless allowed_uri?(target, allowed_hosts)
377
+
378
+ return fetch(target, deadline, allowed_hosts, redirects_left - 1)
379
+ end
380
+ decoded = decode(body, response["content-encoding"], response["content-type"], deadline)
381
+ ensure_remaining!(deadline)
382
+
383
+ HttpResponse.new(status: response.code.to_i, headers: response.to_hash, body: decoded, final_url: uri.to_s)
384
+ rescue DeadlineExceeded, Timeout::Error
385
+ HttpFailure.new(reason: "timeout", final_url: uri.to_s)
386
+ rescue ResponseTooLarge
387
+ HttpFailure.new(reason: "size", final_url: uri.to_s)
388
+ rescue Zlib::Error
389
+ HttpFailure.new(reason: "parse", final_url: uri.to_s)
390
+ rescue SystemCallError, IOError
391
+ HttpFailure.new(reason: "failed", final_url: uri.to_s)
392
+ end
393
+
394
+ def request(uri, deadline)
395
+ seconds = remaining(deadline)
396
+ raise DeadlineExceeded unless seconds.positive?
397
+
398
+ http = net_http.call(uri)
399
+ http.use_ssl = uri.scheme == "https"
400
+ http.open_timeout = seconds
401
+ http.read_timeout = seconds
402
+ http.write_timeout = seconds
403
+ request = Net::HTTP::Get.new(
404
+ uri.request_uri.empty? ? "/" : uri.request_uri,
405
+ "Accept-Encoding" => "gzip, deflate",
406
+ "Accept-Language" => "en-US,en;q=0.9",
407
+ "User-Agent" => "fetch_util"
408
+ )
409
+ body = +""
410
+ response = within_wall_clock_timeout(seconds) do
411
+ http.request(request) do |incoming|
412
+ incoming.read_body do |chunk|
413
+ ensure_remaining!(deadline)
414
+ body << chunk
415
+ raise ResponseTooLarge if body.bytesize > max_response_bytes
416
+ end
417
+ end
418
+ end
419
+ [response, body]
420
+ end
421
+
422
+ def decode(body, encoding, content_type, deadline)
423
+ decoded = within_deadline(deadline) do
424
+ decoded = inflate(body, encoding, deadline)
425
+ charset = charset_from(content_type, decoded)
426
+ begin
427
+ decoded.force_encoding(charset).encode("UTF-8", invalid: :replace, undef: :replace, replace: " ")
428
+ rescue ArgumentError, Encoding::ConverterNotFoundError
429
+ decoded.force_encoding("UTF-8").scrub(" ")
430
+ end
431
+ end
432
+ ensure_remaining!(deadline)
433
+ decoded
434
+ end
435
+
436
+ def inflate(body, encoding, deadline)
437
+ return body unless %w[gzip deflate].include?(encoding.to_s.downcase)
438
+
439
+ window_bits = encoding.to_s.downcase == "gzip" ? Zlib::MAX_WBITS + 16 : Zlib::MAX_WBITS
440
+ inflater = Zlib::Inflate.new(window_bits)
441
+ decoded = +""
442
+ offset = 0
443
+ while offset < body.bytesize
444
+ ensure_remaining!(deadline)
445
+ chunk = body.byteslice(offset, INFLATE_CHUNK_BYTES)
446
+ append_decoded(decoded, inflater.inflate(chunk))
447
+ offset += chunk.bytesize
448
+ end
449
+ append_decoded(decoded, inflater.finish)
450
+ decoded
451
+ ensure
452
+ inflater&.close
453
+ end
454
+
455
+ def append_decoded(decoded, chunk)
456
+ decoded << chunk
457
+ raise ResponseTooLarge if decoded.bytesize > max_response_bytes
458
+ end
459
+
460
+ def charset_from(content_type, body)
461
+ header_charset = content_type.to_s[/charset\s*=\s*['"]?([^;'"\s]+)/i, 1]
462
+ return header_charset if header_charset
463
+
464
+ body.byteslice(0, 4096).to_s[/<meta\b[^>]*\bcharset\s*=\s*['"]?([^\s'";>]+)/i, 1] || "UTF-8"
465
+ end
466
+
467
+ def allowed_uri?(uri, allowed_hosts)
468
+ uri.is_a?(URI::HTTP) && uri.host && allowed_hosts.include?(uri.host.downcase)
469
+ end
470
+
471
+ def remaining(deadline)
472
+ deadline - clock.call
473
+ end
474
+
475
+ def ensure_remaining!(deadline)
476
+ raise DeadlineExceeded unless remaining(deadline).positive?
477
+ end
478
+
479
+ def within_wall_clock_timeout(seconds, &block)
480
+ return block.call unless seconds.finite?
481
+
482
+ Timeout.timeout(seconds, DeadlineExceeded, &block)
483
+ end
484
+
485
+ def within_deadline(deadline, &block)
486
+ seconds = remaining(deadline)
487
+ raise DeadlineExceeded unless seconds.positive?
488
+
489
+ within_wall_clock_timeout(seconds, &block)
490
+ end
491
+
492
+ ResponseTooLarge = Class.new(StandardError)
493
+ DeadlineExceeded = Class.new(StandardError)
494
+ end
495
+ end
496
+ end