fetch_util 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +18 -10
- data/SKILL.md +12 -2
- 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 +1 -1
- data/lib/fetch_util/search_transport.rb +120 -37
- data/lib/fetch_util/searcher.rb +28 -8
- data/lib/fetch_util/version.rb +1 -1
- metadata +2 -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)
|
|
@@ -34,19 +34,24 @@ module FetchUtil
|
|
|
34
34
|
"bing" => { url: "https://www.bing.com/search?q=%{query}&setlang=en-US&cc=US", hosts: %w[www.bing.com cn.bing.com] },
|
|
35
35
|
"duckduckgo" => { url: "https://html.duckduckgo.com/html/?q=%{query}", hosts: %w[html.duckduckgo.com] },
|
|
36
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] }
|
|
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] }
|
|
38
39
|
}.freeze
|
|
39
40
|
DEFAULT_TIMEOUT = 10.0
|
|
41
|
+
YAHOO_RECOVERY_ATTEMPTS = 2
|
|
40
42
|
FAILURE_REASONS = %w[challenge failed host http_status parse query_mismatch redirect size timeout].freeze
|
|
41
43
|
RELEVANCE_HEALTH_CANDIDATE_COUNT = 3
|
|
42
44
|
QUERY_FUNCTION_WORDS = %w[
|
|
43
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
|
|
44
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
|
|
45
47
|
].freeze
|
|
48
|
+
SCOPED_QUERY_TERM = /(?:\A|\s)[+-]?[[:alpha:]][[:alnum:]_-]*:(?:"[^"]*"|'[^']*'|\S+)/
|
|
49
|
+
NEGATED_QUERY_TERM = /(?:\A|\s)-\S+/
|
|
46
50
|
WRAPPER_HOSTS = {
|
|
47
51
|
"bing" => %w[bing.com www.bing.com cn.bing.com],
|
|
48
52
|
"duckduckgo" => %w[duckduckgo.com www.duckduckgo.com html.duckduckgo.com],
|
|
49
|
-
"google" => %w[google.com www.google.com]
|
|
53
|
+
"google" => %w[google.com www.google.com],
|
|
54
|
+
"yahoo" => %w[r.search.yahoo.com]
|
|
50
55
|
}.freeze
|
|
51
56
|
|
|
52
57
|
def initialize(sources: SOURCES.keys, timeout: DEFAULT_TIMEOUT, clock: nil, http_client: nil, html_parser: nil)
|
|
@@ -62,11 +67,28 @@ module FetchUtil
|
|
|
62
67
|
@html_parser = html_parser || ->(body) { Nokogiri::HTML(body) }
|
|
63
68
|
end
|
|
64
69
|
|
|
65
|
-
def
|
|
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)
|
|
66
85
|
query = query.to_s.strip
|
|
67
86
|
raise ArgumentError, "query must not be empty" if query.empty?
|
|
68
87
|
|
|
69
|
-
|
|
88
|
+
request_timeout = Float(timeout)
|
|
89
|
+
raise ArgumentError, "timeout must be positive" unless request_timeout.positive?
|
|
90
|
+
|
|
91
|
+
deadline = clock.call + request_timeout
|
|
70
92
|
responses = Array.new(sources.length)
|
|
71
93
|
threads = sources.each_with_index.map do |source, index|
|
|
72
94
|
Thread.new { responses[index] = search_source(source, query, deadline) }
|
|
@@ -82,8 +104,10 @@ module FetchUtil
|
|
|
82
104
|
def search_source(source, query, deadline)
|
|
83
105
|
started_at = clock.call
|
|
84
106
|
url = build_url(source, query)
|
|
85
|
-
result =
|
|
86
|
-
|
|
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
|
|
87
111
|
outcome = within_deadline(deadline) do
|
|
88
112
|
document = html_parser.call(result.body)
|
|
89
113
|
next [:failed, "challenge"] if challenge?(source, result, document)
|
|
@@ -92,13 +116,23 @@ module FetchUtil
|
|
|
92
116
|
|
|
93
117
|
candidates = parse_candidates(source, document)
|
|
94
118
|
next [:ok, candidates] if candidates.empty?
|
|
95
|
-
next [:
|
|
119
|
+
next [:suspect, candidates] unless self.class.candidates_match_query?(query, candidates)
|
|
96
120
|
|
|
97
121
|
[:ok, candidates]
|
|
98
122
|
end
|
|
99
123
|
elapsed_ms = elapsed_since(started_at)
|
|
100
|
-
return failure(source, outcome
|
|
124
|
+
return failure(source, outcome[1], elapsed_ms, result.final_url, candidates: outcome[2] || []) if outcome.first == :failed
|
|
101
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
|
|
102
136
|
|
|
103
137
|
candidates = outcome.last
|
|
104
138
|
return failure(source, "parse", elapsed_ms, result.final_url) if candidates.empty?
|
|
@@ -113,13 +147,31 @@ module FetchUtil
|
|
|
113
147
|
failure(source, "failed", elapsed_since(started_at), url)
|
|
114
148
|
end
|
|
115
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
|
+
|
|
116
168
|
def build_url(source, query)
|
|
117
169
|
SOURCES.fetch(source).fetch(:url) % { query: URI.encode_www_form_component(query) }
|
|
118
170
|
end
|
|
119
171
|
|
|
120
172
|
def parse_candidates(source, document)
|
|
121
173
|
result_nodes(source, document).filter_map do |node|
|
|
122
|
-
next if excluded_node?(node)
|
|
174
|
+
next if excluded_node?(source, node)
|
|
123
175
|
|
|
124
176
|
anchor = result_anchor(source, node)
|
|
125
177
|
next unless anchor
|
|
@@ -135,37 +187,27 @@ module FetchUtil
|
|
|
135
187
|
end
|
|
136
188
|
end
|
|
137
189
|
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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) }
|
|
147
193
|
end
|
|
148
194
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def meaningful_query_terms(query)
|
|
156
|
-
normalized_terms(query).reject { |term| QUERY_FUNCTION_WORDS.include?(term) }
|
|
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
|
|
157
200
|
end
|
|
158
201
|
|
|
159
|
-
def
|
|
160
|
-
normalized_query_text(text).downcase.scan(/[[:alnum:]]+/).uniq
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def normalized_query_text(text)
|
|
202
|
+
def self.normalized_query_text(text)
|
|
164
203
|
text.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: " ").unicode_normalize(:nfc)
|
|
165
204
|
end
|
|
166
205
|
|
|
206
|
+
private_class_method :meaningful_query_terms, :normalized_terms, :normalized_query_text
|
|
207
|
+
|
|
167
208
|
def result_nodes(source, document)
|
|
168
209
|
return brave_result_nodes(document) if source == "brave"
|
|
210
|
+
return yahoo_result_nodes(document) if source == "yahoo"
|
|
169
211
|
|
|
170
212
|
selector = {
|
|
171
213
|
"bing" => "#b_results li.b_algo",
|
|
@@ -183,6 +225,11 @@ module FetchUtil
|
|
|
183
225
|
(current.to_a + legacy).uniq
|
|
184
226
|
end
|
|
185
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
|
+
|
|
186
233
|
def legacy_brave_result?(node)
|
|
187
234
|
node.at_css("h2, h3, .title.search-snippet-title") && result_anchor("brave", node)
|
|
188
235
|
end
|
|
@@ -197,6 +244,7 @@ module FetchUtil
|
|
|
197
244
|
when "bing" then node.at_css("h2 a[href]")
|
|
198
245
|
when "duckduckgo" then node.at_css("a.result__a[href], h2 a[href]")
|
|
199
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]")
|
|
200
248
|
else node.at_css("h2 a[href], h3 a[href], a[href]:has(h3)")
|
|
201
249
|
end
|
|
202
250
|
end
|
|
@@ -213,21 +261,31 @@ module FetchUtil
|
|
|
213
261
|
"bing" => ".b_caption p, .b_paractl",
|
|
214
262
|
"duckduckgo" => ".result__snippet",
|
|
215
263
|
"google" => ".VwiC3b, .aCOpRe, [data-sncf]",
|
|
216
|
-
"ecosia" => ".result-snippet, .result__description"
|
|
264
|
+
"ecosia" => ".result-snippet, .result__description",
|
|
265
|
+
"yahoo" => ".compText, .compText p"
|
|
217
266
|
}.fetch(source)
|
|
218
267
|
end
|
|
219
268
|
|
|
220
|
-
def excluded_node?(node)
|
|
269
|
+
def excluded_node?(source, node)
|
|
270
|
+
return yahoo_excluded_node?(node) if source == "yahoo"
|
|
271
|
+
|
|
221
272
|
node.xpath("ancestor-or-self::*").any? do |ancestor|
|
|
222
273
|
value = [ancestor["class"], ancestor["id"], ancestor["data-testid"]].compact.join(" ").downcase
|
|
223
274
|
value.match?(/\b(ad|ads|advert|enrichment|knowledge|llm|nav|pagination|related|answer)\b/)
|
|
224
275
|
end
|
|
225
276
|
end
|
|
226
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
|
+
|
|
227
285
|
def destination(source, href)
|
|
228
286
|
value = href.to_s.strip
|
|
229
287
|
value = decode_bing(value) if source == "bing"
|
|
230
|
-
value = decode_wrapper(source, value) if %w[google duckduckgo].include?(source)
|
|
288
|
+
value = decode_wrapper(source, value) if %w[google duckduckgo yahoo].include?(source)
|
|
231
289
|
uri = URI.parse(value)
|
|
232
290
|
return unless uri.is_a?(URI::HTTP) && uri.host
|
|
233
291
|
|
|
@@ -252,6 +310,8 @@ module FetchUtil
|
|
|
252
310
|
|
|
253
311
|
def decode_wrapper(source, value)
|
|
254
312
|
uri = URI.parse(value)
|
|
313
|
+
return decode_yahoo_wrapper(uri, value) if source == "yahoo"
|
|
314
|
+
|
|
255
315
|
key = source == "google" ? %w[q url] : %w[uddg]
|
|
256
316
|
path = wrapper_path(source)
|
|
257
317
|
return value unless uri.path == path && (uri.host.nil? || wrapper_host?(source, uri.host))
|
|
@@ -261,6 +321,21 @@ module FetchUtil
|
|
|
261
321
|
value
|
|
262
322
|
end
|
|
263
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
|
+
|
|
264
339
|
def engine_url?(source, value)
|
|
265
340
|
host = URI.parse(value).host
|
|
266
341
|
same_engine_host?(source, host) || wrapper_host?(source, host)
|
|
@@ -302,7 +377,8 @@ module FetchUtil
|
|
|
302
377
|
"bing" => /there are no results|no results found/,
|
|
303
378
|
"duckduckgo" => /no results|no more results/,
|
|
304
379
|
"google" => /did not match any documents|no results found/,
|
|
305
|
-
"ecosia" => /no results found|we couldn't find
|
|
380
|
+
"ecosia" => /no results found|we couldn't find/,
|
|
381
|
+
"yahoo" => /no results found|we couldn't find|did not match any results/
|
|
306
382
|
}
|
|
307
383
|
text.match?(patterns.fetch(source))
|
|
308
384
|
end
|
|
@@ -313,10 +389,17 @@ module FetchUtil
|
|
|
313
389
|
normalized_text(visible).downcase
|
|
314
390
|
end
|
|
315
391
|
|
|
316
|
-
def failure(source, reason, elapsed_ms, final_url)
|
|
392
|
+
def failure(source, reason, elapsed_ms, final_url, candidates: [])
|
|
317
393
|
reason = reason.to_s
|
|
318
394
|
reason = "failed" unless FAILURE_REASONS.include?(reason)
|
|
319
|
-
SourceResponse.new(
|
|
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
|
+
)
|
|
320
403
|
end
|
|
321
404
|
|
|
322
405
|
def empty(source, elapsed_ms, final_url)
|
data/lib/fetch_util/searcher.rb
CHANGED
|
@@ -5,8 +5,9 @@ require "uri"
|
|
|
5
5
|
|
|
6
6
|
module FetchUtil
|
|
7
7
|
class Searcher
|
|
8
|
-
DEFAULT_SOURCES = %w[brave bing].freeze
|
|
9
|
-
|
|
8
|
+
DEFAULT_SOURCES = %w[brave bing yahoo].freeze
|
|
9
|
+
STRUCTURED_QUERY_AUTHORITY = "yahoo"
|
|
10
|
+
STRUCTURED_QUERY = /"[^"]+"|'[^']+'|\(|\)|\[|\]|\{|\}|(?:\A|\s)[+-]?[[:alpha:]][[:alnum:]_-]*:/
|
|
10
11
|
autoload :ResultFiltering, "fetch_util/searcher/result_filtering"
|
|
11
12
|
include ResultFiltering
|
|
12
13
|
private_constant :ResultFiltering
|
|
@@ -14,6 +15,7 @@ module FetchUtil
|
|
|
14
15
|
def initialize(transport: nil, request_log: RequestLog.new, sources: nil, limit: nil, verbose: false,
|
|
15
16
|
timeout: SearchTransport::DEFAULT_TIMEOUT)
|
|
16
17
|
@request_log = request_log
|
|
18
|
+
@sources_explicit = !sources.nil?
|
|
17
19
|
@sources = Array(sources || DEFAULT_SOURCES).map(&:to_s).uniq
|
|
18
20
|
unknown = @sources - SearchTransport::SOURCES.keys
|
|
19
21
|
raise ArgumentError, "unsupported search source: #{unknown.first}" if unknown.any?
|
|
@@ -33,7 +35,7 @@ module FetchUtil
|
|
|
33
35
|
|
|
34
36
|
payload = {
|
|
35
37
|
query: encoded_query,
|
|
36
|
-
results: formatted_results(apply_limit(aggregate(responses)))
|
|
38
|
+
results: formatted_results(apply_limit(aggregate(responses, encoded_query)))
|
|
37
39
|
}
|
|
38
40
|
payload[:diagnostics] = diagnostics(responses) if @verbose
|
|
39
41
|
payload
|
|
@@ -58,17 +60,18 @@ module FetchUtil
|
|
|
58
60
|
"search://#{@sources.join(",")}?q=#{CGI.escape(query)}"
|
|
59
61
|
end
|
|
60
62
|
|
|
61
|
-
def aggregate(responses)
|
|
63
|
+
def aggregate(responses, query)
|
|
62
64
|
parsed = {}
|
|
63
|
-
|
|
65
|
+
structured_query = query.match?(STRUCTURED_QUERY)
|
|
64
66
|
|
|
65
67
|
@sources.each do |source|
|
|
66
68
|
response = responses.find { |item| item.source == source }
|
|
67
|
-
|
|
68
|
-
parsed[source] = items
|
|
69
|
-
max_size = [max_size, items.length].max
|
|
69
|
+
parsed[source] = response ? response.candidates.filter_map { |candidate| normalized_candidate(candidate) } : []
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
apply_structured_query_authority!(parsed, responses) if structured_query
|
|
73
|
+
max_size = parsed.values.map(&:length).max || 0
|
|
74
|
+
|
|
72
75
|
items = []
|
|
73
76
|
seen = {}
|
|
74
77
|
|
|
@@ -92,6 +95,23 @@ module FetchUtil
|
|
|
92
95
|
items
|
|
93
96
|
end
|
|
94
97
|
|
|
98
|
+
def apply_structured_query_authority!(parsed, responses)
|
|
99
|
+
return if @sources_explicit
|
|
100
|
+
return unless @sources.include?(STRUCTURED_QUERY_AUTHORITY)
|
|
101
|
+
|
|
102
|
+
authority = responses.find { |response| response.source == STRUCTURED_QUERY_AUTHORITY }
|
|
103
|
+
return unless authority&.status == "ok" && authority.reason.nil?
|
|
104
|
+
|
|
105
|
+
authority_urls = parsed.fetch(STRUCTURED_QUERY_AUTHORITY).map { |item| item[:url] }
|
|
106
|
+
return if authority_urls.empty?
|
|
107
|
+
|
|
108
|
+
@sources.each do |source|
|
|
109
|
+
next if source == STRUCTURED_QUERY_AUTHORITY
|
|
110
|
+
|
|
111
|
+
parsed[source].select! { |item| authority_urls.include?(item[:url]) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
95
115
|
def build_result(source, item)
|
|
96
116
|
result = {
|
|
97
117
|
title: item[:title],
|
data/lib/fetch_util/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fetch_util
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- hmdne
|
|
@@ -86,6 +86,7 @@ files:
|
|
|
86
86
|
- lib/fetch_util/browser/site_stabilization/social_platforms.rb
|
|
87
87
|
- lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb
|
|
88
88
|
- lib/fetch_util/browser/stabilization.rb
|
|
89
|
+
- lib/fetch_util/browser/stabilization/anubis.rb
|
|
89
90
|
- lib/fetch_util/browser/stabilization/page_flow.rb
|
|
90
91
|
- lib/fetch_util/browser/stabilization/spa_hydration.rb
|
|
91
92
|
- lib/fetch_util/cli.rb
|