fetch_util 0.3.2 → 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 +29 -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 +5 -0
- 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 +39 -6
- data/lib/fetch_util/browser/stabilization/spa_hydration.rb +17 -0
- data/lib/fetch_util/cli.rb +36 -10
- data/lib/fetch_util/fetcher.rb +149 -2
- data/lib/fetch_util/result.rb +124 -9
- data/lib/fetch_util/searcher.rb +9 -17
- data/lib/fetch_util/version.rb +1 -1
- metadata +2 -1
|
@@ -289,6 +289,11 @@ module FetchUtil
|
|
|
289
289
|
)
|
|
290
290
|
end
|
|
291
291
|
|
|
292
|
+
def dismiss_cookie_overlays(page)
|
|
293
|
+
accepted_cookies = accept_cookie_consent(page)
|
|
294
|
+
dismiss_privacy_preference_overlay(page) || accepted_cookies
|
|
295
|
+
end
|
|
296
|
+
|
|
292
297
|
def consent_quick_indicator_selector_js
|
|
293
298
|
consent_selector_js(DEFAULT_CONSENT_CONFIG.quick_indicator_selectors)
|
|
294
299
|
end
|
|
@@ -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
|
|
@@ -10,8 +10,18 @@ module FetchUtil
|
|
|
10
10
|
SiteStabilization::SocialPlatforms::SOCIAL_PLATFORM_STABILIZATION_PROFILES[:stabilize_instagram],
|
|
11
11
|
SiteStabilization::SocialPlatforms::SOCIAL_PLATFORM_STABILIZATION_PROFILES[:stabilize_facebook],
|
|
12
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" },
|
|
13
17
|
{ host: "gitlab.com", path_query: ->(uri) { uri.path.split("/").reject(&:empty?).length == 2 },
|
|
14
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.",
|
|
15
25
|
tests: "spec/fetch_util/browser_stabilization_spec.rb" }
|
|
16
26
|
].freeze
|
|
17
27
|
POST_GENERIC_STABILIZATION_PROFILES = [
|
|
@@ -34,18 +44,15 @@ module FetchUtil
|
|
|
34
44
|
|
|
35
45
|
reached_idle = !@wait_for_idle || wait_for_idle_or_content(page)
|
|
36
46
|
preserve_consent = preserve_consent_wall?(page, url)
|
|
37
|
-
accepted_cookies = preserve_consent ? false :
|
|
38
|
-
accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
|
|
47
|
+
accepted_cookies = preserve_consent ? false : dismiss_cookie_overlays(page)
|
|
39
48
|
if @wait.positive? && (!@wait_for_idle || accepted_cookies)
|
|
40
49
|
sleep @wait
|
|
41
|
-
accepted_cookies = (
|
|
42
|
-
accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
|
|
50
|
+
accepted_cookies = dismiss_cookie_overlays(page) || accepted_cookies unless preserve_consent
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
wait_for_spa_hydration(page) if @wait_for_idle && reached_idle
|
|
46
54
|
if accepted_cookies
|
|
47
|
-
accepted_cookies = (
|
|
48
|
-
accepted_cookies = (!preserve_consent && dismiss_privacy_preference_overlay(page)) || accepted_cookies
|
|
55
|
+
accepted_cookies = dismiss_cookie_overlays(page) || accepted_cookies
|
|
49
56
|
end
|
|
50
57
|
if (profile = matching_stabilization_profile(url, POST_GENERIC_STABILIZATION_PROFILES))
|
|
51
58
|
send(profile.fetch(:strategy), page, url)
|
|
@@ -155,6 +162,32 @@ module FetchUtil
|
|
|
155
162
|
rescue Ferrum::JavaScriptError, Ferrum::TimeoutError
|
|
156
163
|
false
|
|
157
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
|
|
158
191
|
end
|
|
159
192
|
end
|
|
160
193
|
end
|
|
@@ -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/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/fetcher.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "uri"
|
|
4
|
+
require "net/http"
|
|
4
5
|
|
|
5
6
|
module FetchUtil
|
|
6
7
|
class Fetcher
|
|
@@ -36,6 +37,8 @@ module FetchUtil
|
|
|
36
37
|
LINKED_MARKDOWN_ITEM_PATTERN = /(?:^|\s)(?:\d+\.|[-*])\s+\[[^\]]{8,220}\]\(/
|
|
37
38
|
INDEX_QUERY_PATTERN = /(?:^|[&?])(?:q|query|search|searchtext|keyword|k)=/i
|
|
38
39
|
PDF_PATH_PATTERN = %r{(?:\.pdf\z|/pdf(?:/|\z)|[?&](?:format|download)=pdf\b)}i
|
|
40
|
+
PDF_CONTENT_TYPE_PATTERN = %r{\Aapplication/(?:pdf|x-pdf)\b}i
|
|
41
|
+
PDF_REDIRECT_LIMIT = 3
|
|
39
42
|
LEGAL_STATUTE_TITLE_PATTERN = Regexp.new(
|
|
40
43
|
"constitution|constitutional|constitui[cç]?[aã]o|c[oó]digo|codigo|code|statute|act|law|" \
|
|
41
44
|
"regulation|ordinance|decree|treaty|convention",
|
|
@@ -160,6 +163,7 @@ module FetchUtil
|
|
|
160
163
|
@extractor = extractor || Extractor.new(reader_mode: options.fetch(:reader_mode, true))
|
|
161
164
|
@raw_docs_fallback = options[:raw_docs_fallback] || RawDocsFallback.new(timeout: @timeout)
|
|
162
165
|
@request_log = options[:request_log]
|
|
166
|
+
@pdf_header_probe = options[:pdf_header_probe]
|
|
163
167
|
end
|
|
164
168
|
|
|
165
169
|
def quit
|
|
@@ -171,8 +175,17 @@ module FetchUtil
|
|
|
171
175
|
pending_connection_retries = 0
|
|
172
176
|
|
|
173
177
|
begin
|
|
178
|
+
if (pdf_result = direct_pdf_result(url))
|
|
179
|
+
log_request(url, t0)
|
|
180
|
+
return pdf_result
|
|
181
|
+
end
|
|
182
|
+
|
|
174
183
|
result = @browser.with_page(url) do |page|
|
|
175
184
|
payload = @extractor.extract(page)
|
|
185
|
+
if telegram_focal_preview?(url) && payload["contentType"] == "article"
|
|
186
|
+
sleep Browser::PRE_EXTRACTION_SETTLE_WAIT
|
|
187
|
+
payload = @extractor.extract(page)
|
|
188
|
+
end
|
|
176
189
|
build_result(url, page.current_url, payload)
|
|
177
190
|
end
|
|
178
191
|
fallback = seznam_cmp_redirect_fallback_candidate?(url, result) ? @raw_docs_fallback.fetch(url) : nil
|
|
@@ -204,6 +217,14 @@ module FetchUtil
|
|
|
204
217
|
|
|
205
218
|
private
|
|
206
219
|
|
|
220
|
+
def telegram_focal_preview?(url)
|
|
221
|
+
uri = URI.parse(url)
|
|
222
|
+
host = FetchUtil.strip_www_host(url)
|
|
223
|
+
host == "t.me" && uri.path.match?(%r{\A/s/[^/]+/\d+/?\z})
|
|
224
|
+
rescue URI::InvalidURIError
|
|
225
|
+
false
|
|
226
|
+
end
|
|
227
|
+
|
|
207
228
|
def build_result(url, final_url, payload)
|
|
208
229
|
raw_final_url = final_url
|
|
209
230
|
raw_canonical_url = payload["canonicalUrl"]
|
|
@@ -236,6 +257,7 @@ module FetchUtil
|
|
|
236
257
|
final_url = snapshot.final_url
|
|
237
258
|
content_type = payload["contentType"] || "article"
|
|
238
259
|
return "article" if content_type == "list" && scholarly_article_markdown?(final_url, snapshot)
|
|
260
|
+
return "article" if content_type == "list" && credible_article_route?(final_url, snapshot)
|
|
239
261
|
|
|
240
262
|
return content_type unless content_type == "article"
|
|
241
263
|
return content_type if payload["legalProvision"]
|
|
@@ -244,6 +266,7 @@ module FetchUtil
|
|
|
244
266
|
return content_type if legal_judgment_markdown?(snapshot) || legal_statute_markdown?(snapshot)
|
|
245
267
|
return content_type if scholarly_article_markdown?(final_url, snapshot)
|
|
246
268
|
return content_type if reference_table_article_markdown?(snapshot)
|
|
269
|
+
return content_type if credible_article_route?(final_url, snapshot)
|
|
247
270
|
return "list" if government_service_portal?(final_url, snapshot)
|
|
248
271
|
return "list" if homepage_like && homepage_index_markdown?(snapshot)
|
|
249
272
|
return "list" if index_list_markdown?(final_url, snapshot)
|
|
@@ -267,9 +290,11 @@ module FetchUtil
|
|
|
267
290
|
snapshot.raw_canonical_url)
|
|
268
291
|
warnings.delete("url_content_mismatch")
|
|
269
292
|
end
|
|
293
|
+
credible_homepage_list = credible_homepage_list_evidence?(content_type, homepage_like, snapshot, warnings)
|
|
294
|
+
warnings.delete("url_content_mismatch") if credible_homepage_list
|
|
270
295
|
if content_type == "list" && homepage_like && !payload["statusPage"] &&
|
|
271
|
-
!
|
|
272
|
-
!research_database_landing?(snapshot)
|
|
296
|
+
!credible_homepage_list && !substantial_homepage_landing?(snapshot) &&
|
|
297
|
+
!government_service_portal?(final_url, snapshot) && !research_database_landing?(snapshot)
|
|
273
298
|
warnings.delete("url_content_mismatch")
|
|
274
299
|
warnings << "homepage_index_page"
|
|
275
300
|
end
|
|
@@ -285,6 +310,18 @@ module FetchUtil
|
|
|
285
310
|
warnings.uniq
|
|
286
311
|
end
|
|
287
312
|
|
|
313
|
+
def credible_homepage_list_evidence?(content_type, homepage_like, snapshot, warnings)
|
|
314
|
+
return false unless content_type == "list" && homepage_like
|
|
315
|
+
return false if snapshot.payload["statusPage"]
|
|
316
|
+
return false if warnings.any? { |warning| warning.match?(/(?:access|auth|bot|challenge|consent|empty|error|interstitial|not_found|paywall|short|wall)/i) }
|
|
317
|
+
return false if generic_redirect_not_found?(snapshot) || auth_redirect_interstitial?(snapshot)
|
|
318
|
+
return false if snapshot.normalized_markdown.empty?
|
|
319
|
+
|
|
320
|
+
evidence = snapshot.payload["portalRootEvidence"]
|
|
321
|
+
evidence.is_a?(Hash) && evidence["namedSectionCount"].to_i >= 2 &&
|
|
322
|
+
evidence["canonicalCardCount"].to_i >= 4
|
|
323
|
+
end
|
|
324
|
+
|
|
288
325
|
def homepage_like?(url)
|
|
289
326
|
path = URI.parse(url).path
|
|
290
327
|
path.nil? || path.empty? || path == "/"
|
|
@@ -384,6 +421,22 @@ module FetchUtil
|
|
|
384
421
|
linked_headlines + linked_items >= 4
|
|
385
422
|
end
|
|
386
423
|
|
|
424
|
+
def credible_article_route?(url, snapshot)
|
|
425
|
+
path = URI.parse(url).path.to_s
|
|
426
|
+
a_detail = path.match?(%r{(?:^|/)a-[a-z0-9]+(?:/|$)}i)
|
|
427
|
+
bbc_detail = path.match?(%r{/(?:articles?)/[a-z0-9]+(?:/|$)}i)
|
|
428
|
+
detail_path = a_detail || bbc_detail
|
|
429
|
+
return false unless detail_path
|
|
430
|
+
return false unless snapshot.heading_count >= 1
|
|
431
|
+
return false unless snapshot.normalized_markdown.length >= 420
|
|
432
|
+
return false unless snapshot.long_prose_line_count_one_twenty >= 2
|
|
433
|
+
return false if a_detail && snapshot.payload["byline"].to_s.strip.empty? && snapshot.payload["publishedTime"].to_s.strip.empty?
|
|
434
|
+
|
|
435
|
+
true
|
|
436
|
+
rescue URI::InvalidURIError
|
|
437
|
+
false
|
|
438
|
+
end
|
|
439
|
+
|
|
387
440
|
def scholarly_article_markdown?(url, snapshot)
|
|
388
441
|
payload = snapshot.payload
|
|
389
442
|
normalized = snapshot.normalized_markdown
|
|
@@ -750,6 +803,100 @@ module FetchUtil
|
|
|
750
803
|
Result.error(url: url, warning: warning, message: message)
|
|
751
804
|
end
|
|
752
805
|
|
|
806
|
+
def direct_pdf_result(url)
|
|
807
|
+
pdf_info = if pdf_url?(url)
|
|
808
|
+
{ final_url: url, headers: {} }
|
|
809
|
+
else
|
|
810
|
+
pdf_header_info(url)
|
|
811
|
+
end
|
|
812
|
+
return nil unless pdf_info
|
|
813
|
+
|
|
814
|
+
final_url = normalized_result_url(pdf_info.fetch(:final_url, url))
|
|
815
|
+
headers = pdf_info.fetch(:headers, {})
|
|
816
|
+
Result.from_payload(
|
|
817
|
+
url: url,
|
|
818
|
+
final_url: final_url,
|
|
819
|
+
canonical_url: nil,
|
|
820
|
+
payload: {
|
|
821
|
+
"title" => pdf_title(final_url, headers),
|
|
822
|
+
"markdown" => "",
|
|
823
|
+
"contentType" => "pdf_document",
|
|
824
|
+
"warnings" => ["pdf_document"]
|
|
825
|
+
},
|
|
826
|
+
content_type: "pdf_document",
|
|
827
|
+
suspect: true,
|
|
828
|
+
warnings: ["pdf_document"]
|
|
829
|
+
)
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def pdf_header_info(url)
|
|
833
|
+
info = @pdf_header_probe ? @pdf_header_probe.call(url) : probe_pdf_headers(url)
|
|
834
|
+
return nil unless pdf_content_type?(info[:headers])
|
|
835
|
+
|
|
836
|
+
info
|
|
837
|
+
rescue URI::InvalidURIError, ArgumentError, FetchUtil::Error, IOError, SocketError, SystemCallError, Timeout::Error
|
|
838
|
+
nil
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
def probe_pdf_headers(url, limit = PDF_REDIRECT_LIMIT)
|
|
842
|
+
uri = parse_http_uri(url)
|
|
843
|
+
response = request_head(uri)
|
|
844
|
+
if response.is_a?(Net::HTTPRedirection) && limit.positive? && response["location"].to_s.strip != ""
|
|
845
|
+
return probe_pdf_headers(uri.merge(response["location"]).to_s, limit - 1)
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
{ final_url: uri.to_s, headers: response.to_hash.transform_keys(&:downcase) }
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def request_head(uri)
|
|
852
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |http|
|
|
853
|
+
request = Net::HTTP::Head.new(uri.request_uri.empty? ? "/" : uri.request_uri)
|
|
854
|
+
http.request(request)
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def parse_http_uri(url)
|
|
859
|
+
uri = URI.parse(url.to_s)
|
|
860
|
+
raise URI::InvalidURIError, "unsupported url: #{url}" unless uri.is_a?(URI::HTTP) && uri.host
|
|
861
|
+
|
|
862
|
+
uri
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def pdf_url?(url)
|
|
866
|
+
URI.parse(url.to_s).then do |uri|
|
|
867
|
+
[uri.path, uri.query && "?#{uri.query}"].compact.join.match?(PDF_PATH_PATTERN)
|
|
868
|
+
end
|
|
869
|
+
rescue URI::InvalidURIError
|
|
870
|
+
false
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def pdf_content_type?(headers)
|
|
874
|
+
Array(headers&.fetch("content-type", nil)).any? { |value| value.to_s.match?(PDF_CONTENT_TYPE_PATTERN) }
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def pdf_title(url, headers)
|
|
878
|
+
title_from_content_disposition(headers) || title_from_url(url)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
def title_from_content_disposition(headers)
|
|
882
|
+
value = Array(headers["content-disposition"]).first.to_s
|
|
883
|
+
filename = value[/filename\*=UTF-8''([^;]+)/i, 1] || value[/filename="?([^";]+)"?/i, 1]
|
|
884
|
+
return nil if filename.to_s.strip.empty?
|
|
885
|
+
|
|
886
|
+
URI.decode_www_form_component(filename).strip
|
|
887
|
+
rescue ArgumentError
|
|
888
|
+
filename.to_s.strip
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def title_from_url(url)
|
|
892
|
+
path = URI.parse(url.to_s).path.to_s
|
|
893
|
+
basename = File.basename(path)
|
|
894
|
+
title = basename.empty? || basename == "/" ? URI.parse(url.to_s).host.to_s : basename
|
|
895
|
+
URI.decode_www_form_component(title.tr("+", " ")).strip
|
|
896
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
897
|
+
url.to_s
|
|
898
|
+
end
|
|
899
|
+
|
|
753
900
|
def network_error?(error)
|
|
754
901
|
error.message.to_s.match?(NETWORK_ERROR_PATTERN)
|
|
755
902
|
end
|