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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +19 -3
- data/SKILL.md +10 -4
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/cli.rb +1 -2
- data/lib/fetch_util/search_transport.rb +496 -0
- data/lib/fetch_util/searcher.rb +45 -86
- data/lib/fetch_util/version.rb +1 -1
- data/lib/fetch_util.rb +1 -0
- metadata +2 -1
data/lib/fetch_util/cli.rb
CHANGED
|
@@ -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,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
|