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.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module Stabilization
6
+ module Anubis
7
+ ANUBIS_POLL = 0.25
8
+
9
+ private
10
+
11
+ def wait_for_anubis_challenge(page)
12
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
13
+ state = anubis_page_state(page)
14
+ if !valid_anubis_state?(state) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
15
+ state = anubis_page_state(page)
16
+ end
17
+ return false unless valid_anubis_state?(state) && state["challenge"] == true
18
+
19
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ return false unless remaining.positive?
21
+
22
+ retry_until_timeout(remaining, interval: ANUBIS_POLL) do
23
+ state = anubis_page_state(page)
24
+ next false unless valid_anubis_state?(state)
25
+ next false unless state["challenge"] == false
26
+
27
+ state["document_ready"] == true && state["body_present"] == true && state["body_text_present"] == true
28
+ end
29
+ end
30
+
31
+ def valid_anubis_state?(state)
32
+ state.is_a?(Hash) &&
33
+ [true, false].include?(state["challenge"]) &&
34
+ [true, false].include?(state["document_ready"]) &&
35
+ [true, false].include?(state["body_present"]) &&
36
+ [true, false].include?(state["body_text_present"]) &&
37
+ state["url"].is_a?(String) && !state["url"].empty?
38
+ end
39
+
40
+ def anubis_page_state(page)
41
+ safe_evaluate(page, <<~JS, default: {})
42
+ (() => {
43
+ const body = document.body;
44
+ const bodyText = body ? (body.innerText || '').replace(/\s+/g, ' ').trim() : '';
45
+ const anubisRoot = document.querySelector('#anubis_challenge');
46
+ const anubisScript = document.querySelector('script[src*="/.within.website/x/cmd/anubis/"]');
47
+ const visibleRoot = anubisRoot && (() => {
48
+ const style = window.getComputedStyle(anubisRoot);
49
+ const rect = anubisRoot.getBoundingClientRect();
50
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
51
+ })();
52
+ const makingSure = /making sure you're not a bot/i.test((document.title || '') + ' ' + bodyText);
53
+ const protectedChallenge = /protected by anubis/i.test(bodyText) &&
54
+ /(enable javascript to get past this challenge|please wait a moment while we ensure the security of your connection|loading|calculating|challenge:\s*anubis)/i.test(bodyText);
55
+ const challenge = Boolean((visibleRoot || anubisScript) && (makingSure || protectedChallenge));
56
+ return {
57
+ challenge,
58
+ document_ready: document.readyState === 'complete',
59
+ body_present: Boolean(body),
60
+ body_text_present: bodyText.length > 0,
61
+ url: location.href
62
+ };
63
+ })()
64
+ JS
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -38,6 +38,8 @@ module FetchUtil
38
38
  private
39
39
 
40
40
  def stabilize_page(page, url)
41
+ wait_for_anubis_challenge(page)
42
+
41
43
  if (profile = matching_stabilization_profile(url, PAGE_FLOW_STABILIZATION_PROFILES))
42
44
  return send(profile.fetch(:strategy), page)
43
45
  end
@@ -5,9 +5,11 @@ module FetchUtil
5
5
  module Stabilization
6
6
  autoload :PageFlow, "fetch_util/browser/stabilization/page_flow"
7
7
  autoload :SpaHydration, "fetch_util/browser/stabilization/spa_hydration"
8
+ autoload :Anubis, "fetch_util/browser/stabilization/anubis"
8
9
 
9
10
  include PageFlow
10
11
  include SpaHydration
12
+ include Anubis
11
13
  end
12
14
  end
13
15
  end
@@ -71,7 +71,7 @@ module FetchUtil
71
71
  end
72
72
 
73
73
  desc "search QUERY", "Search across configured engines and aggregate results"
74
- option :source, type: :array, default: FetchUtil::Searcher::DEFAULT_SOURCES, desc: "Search sources"
74
+ option :source, type: :array, default: nil, desc: "Search sources"
75
75
  option :limit, type: :numeric, default: nil, desc: "Maximum results; omit to return every result in the fetched response"
76
76
  option :verbose_search, type: :boolean, default: false, desc: "Include per-result search provenance"
77
77
  def search(*terms)
@@ -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 search(query)
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
- deadline = clock.call + timeout
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 = 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)
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 [:failed, "query_mismatch"] unless query_matches_candidates?(query, candidates)
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.last, elapsed_ms, result.final_url) if outcome.first == :failed
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 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
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 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) }
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 normalized_terms(text)
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(source: source, status: "failed", elapsed_ms: elapsed_ms, final_url: final_url, reason: 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
+ )
320
403
  end
321
404
 
322
405
  def empty(source, elapsed_ms, final_url)
@@ -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
- max_size = 0
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
- items = response ? response.candidates.filter_map { |candidate| normalized_candidate(candidate) } : []
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],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetchUtil
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.2"
5
5
  end
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.0
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