fetch_util 0.3.0 → 0.3.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.
@@ -4,9 +4,11 @@ module FetchUtil
4
4
  class Browser
5
5
  module SiteStabilization
6
6
  autoload :CommunityAndMarketplace, "fetch_util/browser/site_stabilization/community_and_marketplace"
7
+ autoload :GitlabRepo, "fetch_util/browser/site_stabilization/gitlab_repo"
7
8
  autoload :SocialPlatforms, "fetch_util/browser/site_stabilization/social_platforms"
8
9
 
9
10
  include CommunityAndMarketplace
11
+ include GitlabRepo
10
12
  include SocialPlatforms
11
13
  end
12
14
  end
@@ -2,33 +2,72 @@
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
+ { host: "gitlab.com", path_query: ->(uri) { uri.path.split("/").reject(&:empty?).length == 2 },
14
+ strategy: :stabilize_gitlab_repo, notes: "Wait for repository README content on GitLab project roots.",
15
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" }
16
+ ].freeze
17
+ POST_GENERIC_STABILIZATION_PROFILES = [
18
+ { host: ["wyborcza.pl", "gazeta.pl"], path_query: ->(uri) { uri.path.match?(%r{/(?:\d+,){2}\d+,|/7,}) },
19
+ strategy: :wait_for_agora_article,
20
+ notes: "After generic consent/idle handling, wait briefly for delayed Agora article bodies.",
21
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" },
22
+ { host: "france24.com", path_query: ->(uri) { uri.path.match?(%r{/\d{8}-}) },
23
+ strategy: :wait_for_france24_article,
24
+ notes: "After generic consent/idle handling, wait briefly for delayed France24 article bodies.",
25
+ tests: "spec/fetch_util/browser_stabilization_spec.rb" }
26
+ ].freeze
27
+
7
28
  private
8
29
 
9
30
  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)
31
+ if (profile = matching_stabilization_profile(url, PAGE_FLOW_STABILIZATION_PROFILES))
32
+ return send(profile.fetch(:strategy), page)
33
+ end
14
34
 
15
35
  reached_idle = !@wait_for_idle || wait_for_idle_or_content(page)
16
36
  preserve_consent = preserve_consent_wall?(page, url)
17
37
  accepted_cookies = preserve_consent ? false : accept_cookie_consent(page)
18
38
  accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
19
- sleep @wait if @wait.positive?
20
- accepted_cookies = (!preserve_consent && accept_cookie_consent(page)) || accepted_cookies
21
- accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
39
+ if @wait.positive? && (!@wait_for_idle || accepted_cookies)
40
+ sleep @wait
41
+ accepted_cookies = (!preserve_consent && accept_cookie_consent(page)) || accepted_cookies
42
+ accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
43
+ end
22
44
 
23
45
  wait_for_spa_hydration(page) if @wait_for_idle && reached_idle
24
- accepted_cookies = (!preserve_consent && accept_cookie_consent(page)) || accepted_cookies
25
- accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
46
+ if accepted_cookies
47
+ accepted_cookies = (!preserve_consent && accept_cookie_consent(page)) || accepted_cookies
48
+ accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
49
+ end
50
+ if (profile = matching_stabilization_profile(url, POST_GENERIC_STABILIZATION_PROFILES))
51
+ send(profile.fetch(:strategy), page, url)
52
+ end
26
53
 
27
54
  return unless accepted_cookies && @wait_for_idle && reached_idle
28
55
 
29
- page.network.wait_for_idle(duration: @idle_duration, timeout: POST_CONSENT_IDLE_TIMEOUT)
56
+ wait_for_network_idle(page)
30
57
  end
31
58
 
59
+ def matching_stabilization_profile(url, profiles)
60
+ uri = URI.parse(url)
61
+ host = FetchUtil.strip_www_host(url)
62
+ profiles.find { |profile| profile_match?(profile, uri, host) }
63
+ rescue URI::InvalidURIError
64
+ nil
65
+ end
66
+
67
+ def profile_match?(profile, uri, host) = stabilization_host_matches?(profile.fetch(:host), host) && profile.fetch(:path_query, ->(_) { true }).call(uri)
68
+
69
+ def stabilization_host_matches?(matcher, host) = Array(matcher).any? { |candidate| host == candidate || host.end_with?(".#{candidate}") }
70
+
32
71
  def wait_for_idle_or_content(page)
33
72
  content_seen_at = nil
34
73
 
@@ -45,6 +84,17 @@ module FetchUtil
45
84
  false
46
85
  end
47
86
 
87
+ def wait_for_network_idle(page)
88
+ page.network.wait_for_idle(duration: @idle_duration, timeout: POST_CONSENT_IDLE_TIMEOUT)
89
+ true
90
+ rescue Ferrum::TimeoutError
91
+ false
92
+ rescue Ferrum::Error => e
93
+ raise if retryable_pending_connections_error?(e)
94
+
95
+ false
96
+ end
97
+
48
98
  def page_has_content?(page)
49
99
  page.evaluate(<<~JS)
50
100
  (() => {
@@ -60,6 +110,7 @@ module FetchUtil
60
110
 
61
111
  def preserve_consent_wall?(page, url)
62
112
  host = FetchUtil.strip_www_host(url)
113
+ return true if host == "france24.com" || host.end_with?(".france24.com")
63
114
  return false unless host == "youtube.com" || host.end_with?(".youtube.com") || host.match?(/\Agoogle\.[a-z.]+\z/)
64
115
 
65
116
  state = page.evaluate(<<~JS)
@@ -74,7 +125,39 @@ module FetchUtil
74
125
  rescue URI::InvalidURIError, Ferrum::JavaScriptError, Ferrum::TimeoutError
75
126
  false
76
127
  end
128
+
129
+ def wait_for_agora_article(page, _url)
130
+ retry_until_timeout(7.0, interval: 0.25) do
131
+ page.evaluate(<<~JS)
132
+ (() => {
133
+ const selectors = ['.mrf-article-body', '.article_body', 'div.articleBody', '.art_content', '.article-inner', 'section.article', '[itemprop=articleBody]'];
134
+ if (selectors.some((selector) => document.querySelector(selector))) return true;
135
+ const text = document.body ? (document.body.innerText || '') : '';
136
+ return !(new RegExp('Wyłącz AdBlocka/uBlocka|Nieznany błąd', 'i')).test(text) && text.length > 1200;
137
+ })()
138
+ JS
139
+ end
140
+ rescue Ferrum::JavaScriptError, Ferrum::TimeoutError
141
+ false
142
+ end
143
+
144
+ def wait_for_france24_article(page, _url)
145
+ retry_until_timeout(15.0, interval: 0.25) do
146
+ page.evaluate(<<~JS)
147
+ (() => {
148
+ const body = document.querySelector('.t-content__body') || document.querySelector('.t-content--article');
149
+ if (!body) return false;
150
+ const text = (body.innerText || '').replace(/\s+/g, ' ').trim();
151
+ return body.querySelectorAll('p').length >= 3 && text.length > 1000;
152
+ })()
153
+ JS
154
+ end
155
+ rescue Ferrum::JavaScriptError, Ferrum::TimeoutError
156
+ false
157
+ end
77
158
  end
78
159
  end
79
160
  end
80
161
  end
162
+
163
+ # rubocop:enable Metrics/ModuleLength
@@ -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
@@ -17,6 +17,7 @@ module FetchUtil
17
17
  content_type
18
18
  suspect
19
19
  warnings
20
+ error_message
20
21
  ].freeze
21
22
 
22
23
  class_option :log_path, type: :string, desc: "Append-only request log path"
@@ -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)