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.
@@ -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 : accept_cookie_consent(page)
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 = (!preserve_consent && accept_cookie_consent(page)) || 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 = (!preserve_consent && accept_cookie_consent(page)) || 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
 
@@ -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)
@@ -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
- !substantial_homepage_landing?(snapshot) && !government_service_portal?(final_url, snapshot) &&
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