fetch_util 0.3.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +187 -104
- data/lib/fetch_util/browser/interaction_helpers/dom_interaction.rb +14 -2
- data/lib/fetch_util/browser/navigation/headers_and_readiness.rb +0 -7
- data/lib/fetch_util/browser/navigation/navigator_patch.rb +3 -1
- data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +28 -27
- data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +25 -32
- data/lib/fetch_util/browser/stabilization/page_flow.rb +93 -11
- data/lib/fetch_util/browser.rb +55 -16
- data/lib/fetch_util/extractor.rb +27 -3
- data/lib/fetch_util/fetcher.rb +270 -144
- data/lib/fetch_util/parallel_fetcher.rb +15 -20
- data/lib/fetch_util/raw_docs_fallback.rb +8 -21
- data/lib/fetch_util/regulatory/http_client.rb +111 -43
- data/lib/fetch_util/version.rb +1 -1
- data/lib/fetch_util.rb +12 -2
- metadata +2 -2
|
@@ -2,34 +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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return stabilize_ebay_search(page) if ebay_search_url?(url)
|
|
14
|
-
return stabilize_gitlab_repo(page) if gitlab_repo_url?(url)
|
|
31
|
+
if (profile = matching_stabilization_profile(url, PAGE_FLOW_STABILIZATION_PROFILES))
|
|
32
|
+
return send(profile.fetch(:strategy), page)
|
|
33
|
+
end
|
|
15
34
|
|
|
16
35
|
reached_idle = !@wait_for_idle || wait_for_idle_or_content(page)
|
|
17
36
|
preserve_consent = preserve_consent_wall?(page, url)
|
|
18
37
|
accepted_cookies = preserve_consent ? false : accept_cookie_consent(page)
|
|
19
38
|
accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
23
44
|
|
|
24
45
|
wait_for_spa_hydration(page) if @wait_for_idle && reached_idle
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
27
53
|
|
|
28
54
|
return unless accepted_cookies && @wait_for_idle && reached_idle
|
|
29
55
|
|
|
30
|
-
page
|
|
56
|
+
wait_for_network_idle(page)
|
|
31
57
|
end
|
|
32
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
|
+
|
|
33
71
|
def wait_for_idle_or_content(page)
|
|
34
72
|
content_seen_at = nil
|
|
35
73
|
|
|
@@ -46,6 +84,17 @@ module FetchUtil
|
|
|
46
84
|
false
|
|
47
85
|
end
|
|
48
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
|
+
|
|
49
98
|
def page_has_content?(page)
|
|
50
99
|
page.evaluate(<<~JS)
|
|
51
100
|
(() => {
|
|
@@ -61,6 +110,7 @@ module FetchUtil
|
|
|
61
110
|
|
|
62
111
|
def preserve_consent_wall?(page, url)
|
|
63
112
|
host = FetchUtil.strip_www_host(url)
|
|
113
|
+
return true if host == "france24.com" || host.end_with?(".france24.com")
|
|
64
114
|
return false unless host == "youtube.com" || host.end_with?(".youtube.com") || host.match?(/\Agoogle\.[a-z.]+\z/)
|
|
65
115
|
|
|
66
116
|
state = page.evaluate(<<~JS)
|
|
@@ -75,7 +125,39 @@ module FetchUtil
|
|
|
75
125
|
rescue URI::InvalidURIError, Ferrum::JavaScriptError, Ferrum::TimeoutError
|
|
76
126
|
false
|
|
77
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
|
|
78
158
|
end
|
|
79
159
|
end
|
|
80
160
|
end
|
|
81
161
|
end
|
|
162
|
+
|
|
163
|
+
# rubocop:enable Metrics/ModuleLength
|
data/lib/fetch_util/browser.rb
CHANGED
|
@@ -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
|
-
|
|
76
|
-
page =
|
|
77
|
-
page
|
|
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
|
data/lib/fetch_util/extractor.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|