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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +34 -10
- data/SKILL.md +20 -4
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/browser/stabilization/anubis.rb +69 -0
- data/lib/fetch_util/browser/stabilization/page_flow.rb +2 -0
- data/lib/fetch_util/browser/stabilization.rb +2 -0
- data/lib/fetch_util/cli.rb +2 -3
- data/lib/fetch_util/search_transport.rb +579 -0
- data/lib/fetch_util/searcher.rb +69 -90
- data/lib/fetch_util/version.rb +1 -1
- data/lib/fetch_util.rb +1 -0
- metadata +3 -1
|
@@ -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
|
|
@@ -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
|
data/lib/fetch_util/cli.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|