fetch_util 0.3.1 → 0.4.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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module SiteStabilization
6
+ module TravelAndLodging
7
+ TRAVEL_LODGING_STABILIZATION_PROFILES = {
8
+ stabilize_lodging_detail: {
9
+ host: %w[airbnb.com booking.com],
10
+ path_query: ->(uri) { uri.path.match?(%r{/(?:rooms|hotel)/}) },
11
+ strategy: :stabilize_lodging_detail,
12
+ notes: "Wait for client-rendered lodging detail cues without bypassing access controls.",
13
+ tests: "spec/fetch_util/browser_stabilization_spec.rb"
14
+ }
15
+ }.freeze
16
+
17
+ private
18
+
19
+ def stabilize_lodging_detail(page)
20
+ accepted_cookies = dismiss_cookie_overlays(page)
21
+
22
+ retry_until_timeout(capped_timeout(8.0), interval: 0.25) do
23
+ safe_evaluate(page, <<~JS, default: false)
24
+ (() => {
25
+ const bodyText = document.body ? (document.body.innerText || '') : '';
26
+ const structuredLodging = Array.from(document.querySelectorAll('script[type="application/ld+json"]')).some((script) => /LodgingBusiness|LodgingReservation|Hotel|VacationRental|Accommodation/i.test(script.textContent || ''));
27
+ if (structuredLodging) return true;
28
+ if (document.querySelector('[data-testid*="amenity" i], [data-testid*="facility" i], [data-testid*="review-score" i], [data-testid="property-description"], [itemprop="address"]')) return true;
29
+ return /amenities|facilities|guest reviews|property highlights|hosted by|check-in|check-out/i.test(bodyText) && bodyText.length > 900;
30
+ })()
31
+ JS
32
+ end
33
+
34
+ settle_after_stabilization(0.25) if accepted_cookies
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -6,10 +6,12 @@ module FetchUtil
6
6
  autoload :CommunityAndMarketplace, "fetch_util/browser/site_stabilization/community_and_marketplace"
7
7
  autoload :GitlabRepo, "fetch_util/browser/site_stabilization/gitlab_repo"
8
8
  autoload :SocialPlatforms, "fetch_util/browser/site_stabilization/social_platforms"
9
+ autoload :TravelAndLodging, "fetch_util/browser/site_stabilization/travel_and_lodging"
9
10
 
10
11
  include CommunityAndMarketplace
11
12
  include GitlabRepo
12
13
  include SocialPlatforms
14
+ include TravelAndLodging
13
15
  end
14
16
  end
15
17
  end
@@ -2,34 +2,79 @@
2
2
 
3
3
  module FetchUtil
4
4
  class Browser
5
+ # rubocop:disable Metrics/ModuleLength -- shared stabilization module is intentionally centralized.
5
6
  module Stabilization
6
7
  module PageFlow
8
+ PAGE_FLOW_STABILIZATION_PROFILES = [
9
+ SiteStabilization::CommunityAndMarketplace::COMMUNITY_MARKETPLACE_STABILIZATION_PROFILES[:stabilize_reddit],
10
+ SiteStabilization::SocialPlatforms::SOCIAL_PLATFORM_STABILIZATION_PROFILES[:stabilize_instagram],
11
+ SiteStabilization::SocialPlatforms::SOCIAL_PLATFORM_STABILIZATION_PROFILES[:stabilize_facebook],
12
+ SiteStabilization::CommunityAndMarketplace::COMMUNITY_MARKETPLACE_STABILIZATION_PROFILES[:stabilize_ebay_search],
13
+ SiteStabilization::TravelAndLodging::TRAVEL_LODGING_STABILIZATION_PROFILES[:stabilize_lodging_detail],
14
+ { host: "t.me", path_query: ->(uri) { uri.path.match?(%r{\A/s/[^/]+/\d+/?\z}) },
15
+ strategy: :wait_for_telegram_message, notes: "Wait for the requested public Telegram preview message.",
16
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" },
17
+ { host: "gitlab.com", path_query: ->(uri) { uri.path.split("/").reject(&:empty?).length == 2 },
18
+ strategy: :stabilize_gitlab_repo, notes: "Wait for repository README content on GitLab project roots.",
19
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" },
20
+ { host: "onet.pl", path_query: ->(uri) { uri.path == "/" },
21
+ strategy: :wait_for_onet_homepage, notes: "Wait for Onet's hydrated feed regions and cards before extraction.",
22
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" },
23
+ { host: "wp.pl", path_query: ->(uri) { uri.path == "/" },
24
+ strategy: :wait_for_wp_homepage, notes: "Wait for WP's materialized section grids before extraction.",
25
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" }
26
+ ].freeze
27
+ POST_GENERIC_STABILIZATION_PROFILES = [
28
+ { host: ["wyborcza.pl", "gazeta.pl"], path_query: ->(uri) { uri.path.match?(%r{/(?:\d+,){2}\d+,|/7,}) },
29
+ strategy: :wait_for_agora_article,
30
+ notes: "After generic consent/idle handling, wait briefly for delayed Agora article bodies.",
31
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" },
32
+ { host: "france24.com", path_query: ->(uri) { uri.path.match?(%r{/\d{8}-}) },
33
+ strategy: :wait_for_france24_article,
34
+ notes: "After generic consent/idle handling, wait briefly for delayed France24 article bodies.",
35
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" }
36
+ ].freeze
37
+
7
38
  private
8
39
 
9
40
  def stabilize_page(page, url)
10
- return stabilize_reddit(page) if reddit_url?(url)
11
- return stabilize_instagram(page) if instagram_url?(url)
12
- return stabilize_facebook(page) if facebook_url?(url)
13
- return stabilize_ebay_search(page) if ebay_search_url?(url)
14
- return stabilize_gitlab_repo(page) if gitlab_repo_url?(url)
41
+ if (profile = matching_stabilization_profile(url, PAGE_FLOW_STABILIZATION_PROFILES))
42
+ return send(profile.fetch(:strategy), page)
43
+ end
15
44
 
16
45
  reached_idle = !@wait_for_idle || wait_for_idle_or_content(page)
17
46
  preserve_consent = preserve_consent_wall?(page, url)
18
- accepted_cookies = preserve_consent ? false : accept_cookie_consent(page)
19
- accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
20
- sleep @wait if @wait.positive?
21
- accepted_cookies = (!preserve_consent && accept_cookie_consent(page)) || accepted_cookies
22
- accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
47
+ accepted_cookies = preserve_consent ? false : dismiss_cookie_overlays(page)
48
+ if @wait.positive? && (!@wait_for_idle || accepted_cookies)
49
+ sleep @wait
50
+ accepted_cookies = dismiss_cookie_overlays(page) || accepted_cookies unless preserve_consent
51
+ end
23
52
 
24
53
  wait_for_spa_hydration(page) if @wait_for_idle && reached_idle
25
- accepted_cookies = (!preserve_consent && accept_cookie_consent(page)) || accepted_cookies
26
- accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
54
+ if accepted_cookies
55
+ accepted_cookies = dismiss_cookie_overlays(page) || accepted_cookies
56
+ end
57
+ if (profile = matching_stabilization_profile(url, POST_GENERIC_STABILIZATION_PROFILES))
58
+ send(profile.fetch(:strategy), page, url)
59
+ end
27
60
 
28
61
  return unless accepted_cookies && @wait_for_idle && reached_idle
29
62
 
30
- page.network.wait_for_idle(duration: @idle_duration, timeout: POST_CONSENT_IDLE_TIMEOUT)
63
+ wait_for_network_idle(page)
31
64
  end
32
65
 
66
+ def matching_stabilization_profile(url, profiles)
67
+ uri = URI.parse(url)
68
+ host = FetchUtil.strip_www_host(url)
69
+ profiles.find { |profile| profile_match?(profile, uri, host) }
70
+ rescue URI::InvalidURIError
71
+ nil
72
+ end
73
+
74
+ def profile_match?(profile, uri, host) = stabilization_host_matches?(profile.fetch(:host), host) && profile.fetch(:path_query, ->(_) { true }).call(uri)
75
+
76
+ def stabilization_host_matches?(matcher, host) = Array(matcher).any? { |candidate| host == candidate || host.end_with?(".#{candidate}") }
77
+
33
78
  def wait_for_idle_or_content(page)
34
79
  content_seen_at = nil
35
80
 
@@ -46,6 +91,17 @@ module FetchUtil
46
91
  false
47
92
  end
48
93
 
94
+ def wait_for_network_idle(page)
95
+ page.network.wait_for_idle(duration: @idle_duration, timeout: POST_CONSENT_IDLE_TIMEOUT)
96
+ true
97
+ rescue Ferrum::TimeoutError
98
+ false
99
+ rescue Ferrum::Error => e
100
+ raise if retryable_pending_connections_error?(e)
101
+
102
+ false
103
+ end
104
+
49
105
  def page_has_content?(page)
50
106
  page.evaluate(<<~JS)
51
107
  (() => {
@@ -61,6 +117,7 @@ module FetchUtil
61
117
 
62
118
  def preserve_consent_wall?(page, url)
63
119
  host = FetchUtil.strip_www_host(url)
120
+ return true if host == "france24.com" || host.end_with?(".france24.com")
64
121
  return false unless host == "youtube.com" || host.end_with?(".youtube.com") || host.match?(/\Agoogle\.[a-z.]+\z/)
65
122
 
66
123
  state = page.evaluate(<<~JS)
@@ -75,7 +132,65 @@ module FetchUtil
75
132
  rescue URI::InvalidURIError, Ferrum::JavaScriptError, Ferrum::TimeoutError
76
133
  false
77
134
  end
135
+
136
+ def wait_for_agora_article(page, _url)
137
+ retry_until_timeout(7.0, interval: 0.25) do
138
+ page.evaluate(<<~JS)
139
+ (() => {
140
+ const selectors = ['.mrf-article-body', '.article_body', 'div.articleBody', '.art_content', '.article-inner', 'section.article', '[itemprop=articleBody]'];
141
+ if (selectors.some((selector) => document.querySelector(selector))) return true;
142
+ const text = document.body ? (document.body.innerText || '') : '';
143
+ return !(new RegExp('Wyłącz AdBlocka/uBlocka|Nieznany błąd', 'i')).test(text) && text.length > 1200;
144
+ })()
145
+ JS
146
+ end
147
+ rescue Ferrum::JavaScriptError, Ferrum::TimeoutError
148
+ false
149
+ end
150
+
151
+ def wait_for_france24_article(page, _url)
152
+ retry_until_timeout(15.0, interval: 0.25) do
153
+ page.evaluate(<<~JS)
154
+ (() => {
155
+ const body = document.querySelector('.t-content__body') || document.querySelector('.t-content--article');
156
+ if (!body) return false;
157
+ const text = (body.innerText || '').replace(/\s+/g, ' ').trim();
158
+ return body.querySelectorAll('p').length >= 3 && text.length > 1000;
159
+ })()
160
+ JS
161
+ end
162
+ rescue Ferrum::JavaScriptError, Ferrum::TimeoutError
163
+ false
164
+ end
165
+
166
+ def wait_for_onet_homepage(page)
167
+ wait_for_structural_readiness(page, "section[class*='Feed_']", "article[class*='Card_']")
168
+ end
169
+
170
+ def wait_for_wp_homepage(page)
171
+ wait_for_structural_readiness(page, ".wp-section-grid", ".wp-teaser-tile, .wp-teaser-regular")
172
+ end
173
+
174
+ def wait_for_telegram_message(page)
175
+ target = URI.parse(page.current_url).path.delete_prefix("/s/")
176
+
177
+ ready = retry_until_timeout(capped_timeout(5.0), interval: 0.25) do
178
+ page.evaluate(<<~JS)
179
+ (() => {
180
+ const card = document.querySelector('.tgme_widget_message[data-post="#{target}"]');
181
+ const text = card && card.querySelector('.tgme_widget_message_text, .js-message_text');
182
+ return !!(text && (text.innerText || '').trim());
183
+ })()
184
+ JS
185
+ end
186
+ sleep PRE_EXTRACTION_SETTLE_WAIT if ready
187
+ ready
188
+ rescue URI::InvalidURIError, Ferrum::JavaScriptError, Ferrum::TimeoutError
189
+ false
190
+ end
78
191
  end
79
192
  end
80
193
  end
81
194
  end
195
+
196
+ # rubocop:enable Metrics/ModuleLength
@@ -19,6 +19,23 @@ module FetchUtil
19
19
  rescue Ferrum::JavaScriptError, Ferrum::TimeoutError
20
20
  end
21
21
 
22
+ def wait_for_structural_readiness(page, region_selector, card_selector)
23
+ previous_count = nil
24
+ retry_until_timeout(SPA_HYDRATION_TIMEOUT, interval: SPA_HYDRATION_POLL) do
25
+ counts = page.evaluate(<<~JS)
26
+ (() => ({
27
+ regions: document.querySelectorAll(#{region_selector.to_json}).length,
28
+ cards: document.querySelectorAll(#{card_selector.to_json}).length
29
+ }))()
30
+ JS
31
+ current_count = [counts["regions"], counts["cards"]]
32
+ ready = current_count == previous_count && current_count[0].positive? && current_count[1] >= 3
33
+ previous_count = current_count
34
+ ready
35
+ end
36
+ rescue Ferrum::JavaScriptError, Ferrum::TimeoutError
37
+ end
38
+
22
39
  def detect_spa_framework(page)
23
40
  result = page.evaluate(spa_framework_detection_script)
24
41
 
@@ -36,7 +36,10 @@ module FetchUtil
36
36
  CONTENT_READY_MIN_LENGTH = 200
37
37
  SPA_HYDRATION_TIMEOUT = 2.0
38
38
  SPA_HYDRATION_POLL = 0.15
39
+ PRE_EXTRACTION_SETTLE_WAIT = 0.25
40
+ HEAVY_SCRIPT_COUNT_THRESHOLD = 20
39
41
  NAVIGATION_MAX_RETRIES = 2
42
+ NAVIGATION_RETRY_WAIT = 2.0
40
43
 
41
44
  def initialize(timeout: 20, wait: 0.75, wait_for_idle: true, idle_duration: 0.35,
42
45
  viewport: DEFAULT_VIEWPORT, user_agent: DEFAULT_USER_AGENT,
@@ -49,6 +52,10 @@ module FetchUtil
49
52
  @viewport = DEFAULT_VIEWPORT.merge(symbolize_hash(viewport || {}))
50
53
  @user_agent = user_agent
51
54
  @accept_language = accept_language
55
+ @default_headers = {
56
+ "User-Agent" => @user_agent,
57
+ "Accept-Language" => @accept_language
58
+ }.freeze
52
59
  @browser_path = browser_path || ENV["BROWSER_PATH"] || BROWSER_CANDIDATES.find { |path| File.executable?(path) }
53
60
  @full_browser = @browser_path && !@browser_path.include?("headless_shell")
54
61
  default_opts = { "no-sandbox": nil }
@@ -61,6 +68,7 @@ module FetchUtil
61
68
  default_opts["enable-automation"] = false # override Ferrum default
62
69
  end
63
70
  @browser_options = default_opts.merge(browser_options || {})
71
+ @navigator_patch = build_navigator_patch
64
72
  @ferrum = nil
65
73
  @mutex = Mutex.new
66
74
  end
@@ -72,22 +80,9 @@ module FetchUtil
72
80
  def with_page(url)
73
81
  raise BrowserError, "No Chromium browser found. Set BROWSER_PATH or install Chromium." unless @browser_path
74
82
 
75
- ferrum = ensure_browser
76
- page = ferrum.create_page
77
- page.headers.set(default_headers)
78
- page.bypass_csp
79
- retries = 0
80
- begin
81
- page.go_to(url)
82
- rescue Ferrum::PendingConnectionsError, Ferrum::TimeoutError
83
- unless page_loaded_enough?(page)
84
- raise if retries >= NAVIGATION_MAX_RETRIES
85
-
86
- retries += 1
87
- retry
88
- end
89
- end
90
- stabilize_page(page, url)
83
+ normalized_url = FetchUtil.normalize_url(url)
84
+ page = load_page_with_retry(ensure_browser, normalized_url)
85
+ sleep PRE_EXTRACTION_SETTLE_WAIT if heavy_script_page?(page, normalized_url)
91
86
  yield page
92
87
  rescue Ferrum::Error => e
93
88
  raise BrowserError, e.message
@@ -131,5 +126,49 @@ module FetchUtil
131
126
  normalized_host = FetchUtil.strip_www_host(url)
132
127
  normalized_host == host || normalized_host.end_with?(".#{host}")
133
128
  end
129
+
130
+ def retryable_pending_connections_error?(error)
131
+ error.is_a?(Ferrum::PendingConnectionsError) || error.message.to_s.match?(/pending connections/i)
132
+ end
133
+
134
+ def retryable_navigation_error?(error)
135
+ error.is_a?(Ferrum::PendingConnectionsError) || error.is_a?(Ferrum::TimeoutError) ||
136
+ error.message.to_s.match?(/pending connections/i)
137
+ end
138
+
139
+ def heavy_script_page?(page, url)
140
+ return false if host_matches?(url, "google.com") || host_matches?(url, "youtube.com")
141
+
142
+ script_count = page.evaluate("document.scripts ? document.scripts.length : 0")
143
+ script_count = script_count.is_a?(Numeric) ? script_count.to_i : 0
144
+ script_count >= HEAVY_SCRIPT_COUNT_THRESHOLD
145
+ rescue Ferrum::Error
146
+ false
147
+ end
148
+
149
+ def load_page_with_retry(ferrum, url)
150
+ retries = 0
151
+
152
+ begin
153
+ page = ferrum.create_page
154
+ page.headers.set(@default_headers)
155
+ page.bypass_csp
156
+ begin
157
+ page.go_to(url)
158
+ rescue Ferrum::TimeoutError
159
+ raise unless page_loaded_enough?(page)
160
+ end
161
+ stabilize_page(page, url)
162
+ page
163
+ rescue Ferrum::PendingConnectionsError, Ferrum::TimeoutError, Ferrum::Error => e
164
+ page&.close
165
+ raise unless retryable_navigation_error?(e)
166
+ raise if retries >= NAVIGATION_MAX_RETRIES
167
+
168
+ retries += 1
169
+ sleep NAVIGATION_RETRY_WAIT * (2**(retries - 1))
170
+ retry
171
+ end
172
+ end
134
173
  end
135
174
  end
@@ -1,24 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "yaml"
4
5
  require "thor"
5
6
 
6
7
  module FetchUtil
7
8
  class CLI < Thor
8
9
  DEFAULT_FETCH_FIELDS = %i[
9
- url
10
- final_url
11
- canonical_url
12
10
  title
13
11
  byline
14
12
  site_name
15
13
  published_time
14
+ language
15
+ name
16
+ company
17
+ location
18
+ description
19
+ ingredients
20
+ instructions
21
+ bedrooms
22
+ bathrooms
23
+ area_sqft
16
24
  markdown
17
25
  content_type
26
+ price
27
+ rating
28
+ address
29
+ social_kind
30
+ platform
31
+ handle
32
+ reply_count
33
+ community
34
+ score
18
35
  suspect
19
36
  warnings
20
37
  error_message
21
38
  ].freeze
39
+ FETCH_URL_FIELDS = %i[url final_url canonical_url].freeze
22
40
 
23
41
  class_option :log_path, type: :string, desc: "Append-only request log path"
24
42
  class_option :format, type: :string, default: "markdown", enum: %w[markdown json jsonl], desc: "Output format"
@@ -28,6 +46,7 @@ module FetchUtil
28
46
  class_option :reader_mode, type: :boolean, default: true
29
47
  class_option :wait_for_idle, type: :boolean, default: true
30
48
  class_option :include_html, type: :boolean, default: false, desc: "Include raw html in fetch output"
49
+ class_option :include_urls, type: :boolean, default: false, desc: "Include URL fields in fetch output"
31
50
 
32
51
  desc "version", "Display fetch_util version"
33
52
  def version
@@ -45,10 +64,7 @@ module FetchUtil
45
64
  end
46
65
 
47
66
  if options[:format] == "markdown"
48
- results.each_with_index do |result, index|
49
- puts "\n---\n\n" if index > 0
50
- puts result.markdown
51
- end
67
+ puts results.map { |result| front_matter_document(result) }.join("\n\n")
52
68
  else
53
69
  emit(urls.length == 1 && options[:format] == "json" ? result_payload(results.first) : results.map { |result| result_payload(result) })
54
70
  end
@@ -56,7 +72,7 @@ module FetchUtil
56
72
 
57
73
  desc "search QUERY", "Search across configured engines and aggregate results"
58
74
  option :source, type: :array, default: FetchUtil::Searcher::DEFAULT_SOURCES, desc: "Search sources"
59
- option :limit, type: :numeric, default: 10
75
+ option :limit, type: :numeric, default: nil, desc: "Maximum results; omit to return every result in the fetched response"
60
76
  option :verbose_search, type: :boolean, default: false, desc: "Include per-result search provenance"
61
77
  def search(*terms)
62
78
  query = terms.join(" ").strip
@@ -107,10 +123,20 @@ module FetchUtil
107
123
 
108
124
  def result_payload(result)
109
125
  payload = result.to_h
110
- payload = payload.select { |key, _value| DEFAULT_FETCH_FIELDS.include?(key) }
126
+ fields = DEFAULT_FETCH_FIELDS
127
+ fields = FETCH_URL_FIELDS + fields if options[:include_urls]
128
+ payload = payload.select { |key, _value| fields.include?(key) }
111
129
  payload[:html] = result.html if options[:include_html]
112
130
 
113
- payload.reject { |_key, value| value.nil? || value == "" }
131
+ payload.reject { |key, value| key != :language && (value.nil? || value == "") }
132
+ end
133
+
134
+ def front_matter_document(result)
135
+ payload = result_payload(result)
136
+ markdown = result.markdown
137
+ payload = payload.reject { |key, _value| key == :markdown }
138
+ yaml = YAML.dump(payload.transform_keys(&:to_s)).sub(/\A---\n/, "")
139
+ "---\n#{yaml}---\n#{markdown}"
114
140
  end
115
141
 
116
142
  def emit(payload)
@@ -4,9 +4,24 @@ require "json"
4
4
 
5
5
  module FetchUtil
6
6
  class Extractor
7
+ INLINE_ASSET_PATHS = %w[vendor/readability.js vendor/turndown.js extract.js].freeze
8
+ @asset_cache = {}
9
+ @cache_mutex = Mutex.new
10
+
11
+ class << self
12
+ def inline_asset_scripts(asset_root)
13
+ @cache_mutex.synchronize do
14
+ @asset_cache[asset_root] ||= INLINE_ASSET_PATHS.map do |relative_path|
15
+ File.read(File.join(asset_root, relative_path), encoding: "UTF-8").freeze
16
+ end.freeze
17
+ end
18
+ end
19
+ end
20
+
7
21
  def initialize(reader_mode: true, asset_root: nil)
8
22
  @reader_mode = reader_mode
9
23
  @asset_root = asset_root || File.join(__dir__, "assets")
24
+ @extraction_call = nil
10
25
  end
11
26
 
12
27
  def extract(page)
@@ -27,13 +42,16 @@ module FetchUtil
27
42
  end
28
43
 
29
44
  def inject_assets_inline(page)
30
- %w[vendor/readability.js vendor/turndown.js extract.js].each do |relative_path|
31
- script = File.read(asset_path(relative_path), encoding: "UTF-8")
45
+ inline_asset_scripts.each do |script|
32
46
  page.evaluate("#{script}\ntrue")
33
47
  end
34
48
  end
35
49
 
36
50
  def extract_payload(page)
51
+ timeout_supported = page.respond_to?(:timeout) && page.respond_to?(:timeout=)
52
+ original_timeout = page.timeout if timeout_supported
53
+ page.timeout = [original_timeout.to_f, 60].max if timeout_supported
54
+
37
55
  inject_assets(page)
38
56
  page.evaluate(extraction_call)
39
57
  rescue Ferrum::TimeoutError
@@ -43,10 +61,16 @@ module FetchUtil
43
61
  end
44
62
  inject_assets_inline(page)
45
63
  page.evaluate(extraction_call)
64
+ ensure
65
+ page.timeout = original_timeout if timeout_supported && original_timeout
46
66
  end
47
67
 
48
68
  def extraction_call
49
- "window.FetchUtilExtract.extract(#{JSON.generate(reader_mode: @reader_mode)})"
69
+ @extraction_call ||= "window.FetchUtilExtract.extract(#{JSON.generate(reader_mode: @reader_mode)})"
70
+ end
71
+
72
+ def inline_asset_scripts
73
+ self.class.inline_asset_scripts(@asset_root)
50
74
  end
51
75
 
52
76
  def asset_path(relative_path)