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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +6 -8
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +192 -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/site_stabilization/travel_and_lodging.rb +39 -0
- data/lib/fetch_util/browser/site_stabilization.rb +2 -0
- data/lib/fetch_util/browser/stabilization/page_flow.rb +128 -13
- data/lib/fetch_util/browser/stabilization/spa_hydration.rb +17 -0
- data/lib/fetch_util/browser.rb +55 -16
- data/lib/fetch_util/cli.rb +36 -10
- data/lib/fetch_util/extractor.rb +27 -3
- data/lib/fetch_util/fetcher.rb +417 -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/result.rb +124 -9
- data/lib/fetch_util/searcher.rb +9 -17
- data/lib/fetch_util/version.rb +1 -1
- data/lib/fetch_util.rb +12 -2
- metadata +3 -2
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 :
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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/cli.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
-
|
|
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 { |
|
|
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)
|
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)
|