fetch_util 0.5.2 → 0.6.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +3 -1
  4. data/Rakefile +0 -2
  5. data/lib/fetch_util/assets/extract.js +1 -1
  6. data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +12 -17
  7. data/lib/fetch_util/browser/interaction_helpers/timing_helpers.rb +42 -15
  8. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_commit_details_script.rb +48 -0
  9. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_product_state_script.rb +80 -0
  10. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_helpers_script.rb +53 -0
  11. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_state_script.rb +118 -0
  12. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_stabilization.rb +54 -0
  13. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_state.rb +27 -0
  14. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_product_state.rb +55 -0
  15. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_request_state.rb +100 -0
  16. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_state.rb +59 -0
  17. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_modules.rb +26 -0
  18. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_state.rb +93 -0
  19. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resources.rb +73 -0
  20. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_thread_state.rb +92 -0
  21. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_threads.rb +71 -0
  22. data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +9 -10
  23. data/lib/fetch_util/browser/site_stabilization/facebook_stabilization.rb +56 -0
  24. data/lib/fetch_util/browser/site_stabilization/gerrit_change_product_state_script.rb +97 -0
  25. data/lib/fetch_util/browser/site_stabilization/gerrit_change_request_state_script.rb +75 -0
  26. data/lib/fetch_util/browser/site_stabilization/gerrit_change_stabilization.rb +37 -0
  27. data/lib/fetch_util/browser/site_stabilization/gerrit_change_state.rb +39 -0
  28. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_fetch_state_script.rb +31 -0
  29. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_product_state_script.rb +76 -0
  30. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_request_state_script.rb +111 -0
  31. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_route_state_script.rb +71 -0
  32. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_stabilization.rb +37 -0
  33. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_state.rb +42 -0
  34. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_validation_state_script.rb +49 -0
  35. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_product_state.rb +88 -0
  36. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_state.rb +109 -0
  37. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resources.rb +30 -0
  38. data/lib/fetch_util/browser/site_stabilization/gitea_family_stabilization.rb +60 -0
  39. data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_product_state.rb +74 -0
  40. data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_timeline_state.rb +69 -0
  41. data/lib/fetch_util/browser/site_stabilization/gitea_family_threads.rb +28 -0
  42. data/lib/fetch_util/browser/site_stabilization/gitea_family_visibility_script.rb +28 -0
  43. data/lib/fetch_util/browser/site_stabilization/github_pull_resource_stabilization.rb +31 -0
  44. data/lib/fetch_util/browser/site_stabilization/github_pull_resources.rb +105 -0
  45. data/lib/fetch_util/browser/site_stabilization/github_threads.rb +82 -0
  46. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state.rb +17 -0
  47. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state_script.rb +98 -0
  48. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_visibility_script.rb +43 -0
  49. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resources.rb +28 -0
  50. data/lib/fetch_util/browser/site_stabilization/gitlab_repo.rb +3 -11
  51. data/lib/fetch_util/browser/site_stabilization/gitlab_stabilization.rb +33 -0
  52. data/lib/fetch_util/browser/site_stabilization/gitlab_threads.rb +92 -0
  53. data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +19 -38
  54. data/lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb +3 -3
  55. data/lib/fetch_util/browser/site_stabilization.rb +89 -0
  56. data/lib/fetch_util/browser/stabilization/anubis.rb +4 -5
  57. data/lib/fetch_util/browser/stabilization/page_flow.rb +71 -27
  58. data/lib/fetch_util/browser/stabilization/spa_hydration.rb +7 -5
  59. data/lib/fetch_util/browser.rb +88 -17
  60. data/lib/fetch_util/cli.rb +69 -23
  61. data/lib/fetch_util/extractor.rb +8 -2
  62. data/lib/fetch_util/fetcher.rb +144 -43
  63. data/lib/fetch_util/http_redirect_client.rb +161 -0
  64. data/lib/fetch_util/parallel_fetcher.rb +125 -28
  65. data/lib/fetch_util/raw_docs_fallback.rb +158 -21
  66. data/lib/fetch_util/regulatory/cache_store.rb +47 -14
  67. data/lib/fetch_util/regulatory/fetch_records.rb +10 -7
  68. data/lib/fetch_util/regulatory/headers.rb +8 -1
  69. data/lib/fetch_util/regulatory/http_client.rb +2 -97
  70. data/lib/fetch_util/regulatory/orchestration.rb +10 -6
  71. data/lib/fetch_util/regulatory/page.rb +8 -11
  72. data/lib/fetch_util/regulatory/robots.rb +2 -10
  73. data/lib/fetch_util/regulatory/source_selection.rb +13 -3
  74. data/lib/fetch_util/regulatory/tdm_page.rb +9 -4
  75. data/lib/fetch_util/regulatory/tdm_policy.rb +16 -9
  76. data/lib/fetch_util/regulatory/tdm_rep.rb +4 -3
  77. data/lib/fetch_util/regulatory/tdm_support.rb +10 -6
  78. data/lib/fetch_util/regulatory.rb +5 -1
  79. data/lib/fetch_util/request_log.rb +24 -3
  80. data/lib/fetch_util/result.rb +37 -89
  81. data/lib/fetch_util/search_transport.rb +102 -35
  82. data/lib/fetch_util/searcher.rb +15 -13
  83. data/lib/fetch_util/version.rb +1 -1
  84. data/lib/fetch_util.rb +4 -1
  85. metadata +66 -2
@@ -20,7 +20,7 @@ module FetchUtil
20
20
 
21
21
  def initialize(reader_mode: true, asset_root: nil)
22
22
  @reader_mode = reader_mode
23
- @asset_root = asset_root || File.join(__dir__, "assets")
23
+ @asset_root = (asset_root || File.join(__dir__, "assets")).dup.freeze
24
24
  @extraction_call = nil
25
25
  end
26
26
 
@@ -62,7 +62,13 @@ module FetchUtil
62
62
  inject_assets_inline(page)
63
63
  page.evaluate(extraction_call)
64
64
  ensure
65
- page.timeout = original_timeout if timeout_supported && original_timeout
65
+ restore_page_timeout(page, original_timeout) if timeout_supported
66
+ end
67
+
68
+ def restore_page_timeout(page, timeout)
69
+ page.timeout = timeout
70
+ rescue Ferrum::Error
71
+ nil
66
72
  end
67
73
 
68
74
  def extraction_call
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "uri"
4
4
  require "net/http"
5
+ require "timeout"
6
+ require "English"
5
7
 
6
8
  module FetchUtil
7
9
  class Fetcher
@@ -33,6 +35,12 @@ module FetchUtil
33
35
  /(?:20\d{2}|\d{4}/\d{2}/\d{2}|article|articles|blog|blogs|column|columns|
34
36
  entry|entries|post|posts|news/[\w-]+|wiki|dictionary|definition|definitions|thesaurus|\d{5,}[\w-]*\.html?)\b
35
37
  }ix
38
+ REDIRECT_QUERY_NOISE_PATTERN = /\A(?:_gl|click_?id|dclid|fbclid|gclid|jsessionid|mc_cid|mc_eid|msclkid|phpsessid|ref|session_?id|sid|utm_[a-z0-9_]+|yclid)\z/i
39
+ MARKDOWN_HTTP_RESOURCE_PATTERN = %r{
40
+ \]\(\s*(https?://[^\s)]+)|
41
+ <\s*(https?://[^>\s]+)\s*>|
42
+ (?:href|src)\s*=\s*["'](https?://[^"']+)["']
43
+ }ix
36
44
  LINKED_MARKDOWN_HEADING_PATTERN = /(?:^|\s)(?:(?:\d+\.|[-*])\s+)?\#{1,4}\s+\[[^\]]{8,220}\]\(/
37
45
  LINKED_MARKDOWN_ITEM_PATTERN = /(?:^|\s)(?:\d+\.|[-*])\s+\[[^\]]{8,220}\]\(/
38
46
  INDEX_QUERY_PATTERN = /(?:^|[&?])(?:q|query|search|searchtext|keyword|k)=/i
@@ -69,7 +77,6 @@ module FetchUtil
69
77
  tag tags topic topics wholesale
70
78
  ].freeze
71
79
  CONTENT_ROUTE_SEGMENTS = %w[article articles content paper papers preprint preprints].freeze
72
- SECOND_LEVEL_COUNTRY_TLDS = /\A(co|com|org|net|gov|edu|ac)\z/
73
80
  GOOGLE_HOST_PATTERN = /\Agoogle\.[a-z.]+\z/
74
81
  NETWORK_ERROR_PATTERN = Regexp.new(
75
82
  "\\b(?:net::ERR_|ERR_NAME_NOT_RESOLVED|pending connections|DNS|resolve|resolution|ENOTFOUND|" \
@@ -80,6 +87,17 @@ module FetchUtil
80
87
  PENDING_CONNECTIONS_FETCH_RETRIES = 1
81
88
  PENDING_CONNECTIONS_FETCH_RETRY_WAIT = 1.0
82
89
 
90
+ def self.fetch_once(url, **options)
91
+ fetcher = new(**options)
92
+ fetcher.fetch(url)
93
+ ensure
94
+ begin
95
+ fetcher&.quit
96
+ rescue Ferrum::Error
97
+ nil
98
+ end
99
+ end
100
+
83
101
  class PayloadSnapshot
84
102
  attr_reader :payload, :requested_url, :final_url, :canonical_url, :raw_final_url, :raw_canonical_url,
85
103
  :markdown, :normalized_markdown, :content_downcase, :context, :context_downcase,
@@ -171,28 +189,30 @@ module FetchUtil
171
189
  end
172
190
 
173
191
  def fetch(url)
192
+ requested_url = url.dup.freeze
174
193
  t0 = monotonic_now
175
194
  pending_connection_retries = 0
176
195
 
177
196
  begin
178
- if (pdf_result = direct_pdf_result(url))
179
- log_request(url, t0)
197
+ transport_url = FetchUtil.normalize_url(requested_url)
198
+ parse_http_uri(transport_url)
199
+
200
+ if (pdf_result = direct_pdf_result(transport_url, requested_url: requested_url))
180
201
  return pdf_result
181
202
  end
182
203
 
183
- result = @browser.with_page(url) do |page|
204
+ result = @browser.with_page(transport_url) do |page|
184
205
  payload = @extractor.extract(page)
185
- if telegram_focal_preview?(url) && payload["contentType"] == "article"
206
+ if telegram_focal_preview?(transport_url) && payload["contentType"] == "article"
186
207
  sleep Browser::PRE_EXTRACTION_SETTLE_WAIT
187
208
  payload = @extractor.extract(page)
188
209
  end
189
- build_result(url, page.current_url, payload)
210
+ build_result(requested_url, page.current_url, payload)
190
211
  end
191
- fallback = seznam_cmp_redirect_fallback_candidate?(url, result) ? @raw_docs_fallback.fetch(url) : nil
192
- fallback ||= docs_fallback_candidate?(url, result) && poor_docs_result?(result) ? @raw_docs_fallback.fetch(url) : nil
212
+ fallback = seznam_cmp_redirect_fallback_candidate?(transport_url, result) ? @raw_docs_fallback.fetch(transport_url) : nil
213
+ fallback ||= docs_fallback_candidate?(transport_url, result) && poor_docs_result?(result) ? @raw_docs_fallback.fetch(transport_url) : nil
193
214
  fallback ||= article_body_fallback_candidate?(result) ? @raw_docs_fallback.fetch(result.final_url) : nil
194
- result = fallback_result(url, fallback) if fallback
195
- log_request(url, t0)
215
+ result = fallback_result(requested_url, fallback) if fallback
196
216
  result
197
217
  rescue BrowserError, ExtractionError => e
198
218
  if e.is_a?(BrowserError) && pending_connections_error?(e) && pending_connection_retries < PENDING_CONNECTIONS_FETCH_RETRIES
@@ -201,17 +221,22 @@ module FetchUtil
201
221
  retry
202
222
  end
203
223
 
204
- fallback = docs_fallback_candidate?(url) ? @raw_docs_fallback.fetch(url) : nil
224
+ fallback = docs_fallback_candidate?(transport_url) ? @raw_docs_fallback.fetch(transport_url) : nil
205
225
  if fallback
206
- result = fallback_result(url, fallback)
207
- log_request(url, t0)
226
+ result = fallback_result(requested_url, fallback)
208
227
  return result
209
228
  end
210
229
 
211
- log_request(url, t0)
212
- return network_error_result(url, e) if e.is_a?(BrowserError) && network_error?(e)
230
+ return network_error_result(requested_url, e) if e.is_a?(BrowserError) && network_error?(e)
213
231
 
214
232
  raise e
233
+ ensure
234
+ active_exception = $ERROR_INFO
235
+ begin
236
+ log_request(requested_url, t0)
237
+ rescue SystemCallError, IOError
238
+ raise unless active_exception
239
+ end
215
240
  end
216
241
  end
217
242
 
@@ -229,7 +254,11 @@ module FetchUtil
229
254
  raw_final_url = final_url
230
255
  raw_canonical_url = payload["canonicalUrl"]
231
256
  final_url = normalized_result_url(final_url)
232
- canonical_url = normalized_result_url(raw_canonical_url)
257
+ canonical_url = normalized_canonical_url(raw_canonical_url)
258
+ if raw_canonical_url && canonical_url.nil?
259
+ payload = payload.merge("canonicalUrl" => nil)
260
+ raw_canonical_url = nil
261
+ end
233
262
  snapshot = PayloadSnapshot.new(
234
263
  payload: payload, requested_url: url, final_url: final_url, canonical_url: canonical_url,
235
264
  raw_final_url: raw_final_url, raw_canonical_url: raw_canonical_url
@@ -574,7 +603,7 @@ module FetchUtil
574
603
  def pdf_document?(requested_url, final_url, payload)
575
604
  return true if payload["contentType"].to_s == "pdf"
576
605
 
577
- [requested_url, final_url, payload["canonicalUrl"]].compact.any? do |url|
606
+ [requested_url, final_url].compact.any? do |url|
578
607
  parsed = URI.parse(url)
579
608
  [parsed.path, parsed.query].compact.join("?").match?(PDF_PATH_PATTERN)
580
609
  end
@@ -685,7 +714,8 @@ module FetchUtil
685
714
  content = snapshot.content_downcase
686
715
  matches = tokens.select { |token| content.include?(token) }
687
716
 
688
- matches.any? { |token| code_like_identifier?(token) } || matches.length >= 2
717
+ matches.any? { |token| code_like_identifier?(token) } || matches.length >= 2 ||
718
+ matching_redirect_query_pair?(snapshot)
689
719
  rescue URI::InvalidURIError
690
720
  false
691
721
  end
@@ -697,8 +727,11 @@ module FetchUtil
697
727
  return false unless cross_domain_redirect?(requested_url, final_url)
698
728
  return false unless scholarly_article_markdown?(final_url, snapshot)
699
729
 
730
+ requested_doi = doi_from_url(requested_url)
731
+ return false if requested_doi.nil?
732
+
700
733
  dois = snapshot.resource_urls.filter_map { |url| doi_from_url(url) }.uniq
701
- dois.length == 1
734
+ dois == [requested_doi]
702
735
  end
703
736
 
704
737
  def doi_from_url(url)
@@ -723,6 +756,48 @@ module FetchUtil
723
756
  []
724
757
  end
725
758
 
759
+ def matching_redirect_query_pair?(snapshot)
760
+ requested_pairs = redirect_query_pairs(snapshot.requested_url)
761
+ return false if requested_pairs.empty?
762
+
763
+ final_pairs = redirect_query_pairs(snapshot.final_url)
764
+ return true if redirect_query_pairs_preserved?(requested_pairs, final_pairs)
765
+
766
+ final_host = FetchUtil.strip_www_host(snapshot.final_url)
767
+ markdown_http_resource_urls(snapshot.markdown).any? do |url|
768
+ FetchUtil.strip_www_host(url) == final_host &&
769
+ redirect_query_pairs_preserved?(requested_pairs, redirect_query_pairs(url))
770
+ end
771
+ rescue URI::InvalidURIError
772
+ false
773
+ end
774
+
775
+ def redirect_query_pairs(url)
776
+ query = URI.parse(url).query.to_s
777
+ URI.decode_www_form(query).filter_map do |key, value|
778
+ next if key.empty? || value.empty? || key.match?(REDIRECT_QUERY_NOISE_PATTERN)
779
+
780
+ [key, value]
781
+ end
782
+ rescue ArgumentError, URI::InvalidURIError
783
+ []
784
+ end
785
+
786
+ def redirect_query_pairs_preserved?(requested_pairs, candidate_pairs)
787
+ remaining = candidate_pairs.dup
788
+ requested_pairs.all? do |pair|
789
+ index = remaining.index(pair)
790
+ next false unless index
791
+
792
+ remaining.delete_at(index)
793
+ true
794
+ end
795
+ end
796
+
797
+ def markdown_http_resource_urls(markdown)
798
+ markdown.scan(MARKDOWN_HTTP_RESOURCE_PATTERN).flat_map(&:compact)
799
+ end
800
+
726
801
  def matching_apex_instrument_redirect?(payload, requested_url, final_url)
727
802
  requested_instrument = apex_instrument_id(requested_url)
728
803
  final_instrument = apex_instrument_id(final_url)
@@ -803,7 +878,7 @@ module FetchUtil
803
878
  Result.error(url: url, warning: warning, message: message)
804
879
  end
805
880
 
806
- def direct_pdf_result(url)
881
+ def direct_pdf_result(url, requested_url: url)
807
882
  pdf_info = if pdf_url?(url)
808
883
  { final_url: url, headers: {} }
809
884
  else
@@ -814,7 +889,7 @@ module FetchUtil
814
889
  final_url = normalized_result_url(pdf_info.fetch(:final_url, url))
815
890
  headers = pdf_info.fetch(:headers, {})
816
891
  Result.from_payload(
817
- url: url,
892
+ url: requested_url,
818
893
  final_url: final_url,
819
894
  canonical_url: nil,
820
895
  payload: {
@@ -831,33 +906,43 @@ module FetchUtil
831
906
 
832
907
  def pdf_header_info(url)
833
908
  info = @pdf_header_probe ? @pdf_header_probe.call(url) : probe_pdf_headers(url)
834
- return nil unless pdf_content_type?(info[:headers])
909
+ return nil unless info && pdf_content_type?(info[:headers])
835
910
 
836
911
  info
837
- rescue URI::InvalidURIError, ArgumentError, FetchUtil::Error, IOError, SocketError, SystemCallError, Timeout::Error
912
+ rescue URI::InvalidURIError, ArgumentError, FetchUtil::Error, IOError, SocketError, SystemCallError, Timeout::Error,
913
+ OpenSSL::SSL::SSLError
838
914
  nil
839
915
  end
840
916
 
841
- def probe_pdf_headers(url, limit = PDF_REDIRECT_LIMIT)
917
+ def probe_pdf_headers(url, limit = PDF_REDIRECT_LIMIT, deadline: nil)
918
+ deadline ||= monotonic_now + @timeout
842
919
  uri = parse_http_uri(url)
843
- response = request_head(uri)
920
+ remaining = deadline - monotonic_now
921
+ return nil unless remaining.positive?
922
+
923
+ response = request_head(uri, timeout: remaining)
924
+ return nil unless deadline > monotonic_now
925
+
844
926
  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)
927
+ return probe_pdf_headers(uri.merge(response["location"]).to_s, limit - 1, deadline: deadline)
846
928
  end
929
+ return nil unless response.is_a?(Net::HTTPSuccess)
847
930
 
848
931
  { final_url: uri.to_s, headers: response.to_hash.transform_keys(&:downcase) }
849
932
  end
850
933
 
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)
934
+ def request_head(uri, timeout: @timeout)
935
+ Timeout.timeout(timeout) do
936
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout) do |http|
937
+ request = Net::HTTP::Head.new(uri.request_uri.empty? ? "/" : uri.request_uri)
938
+ http.request(request)
939
+ end
855
940
  end
856
941
  end
857
942
 
858
943
  def parse_http_uri(url)
859
944
  uri = URI.parse(url.to_s)
860
- raise URI::InvalidURIError, "unsupported url: #{url}" unless uri.is_a?(URI::HTTP) && uri.host
945
+ raise URI::InvalidURIError, "unsupported url: #{url}" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
861
946
 
862
947
  uri
863
948
  end
@@ -883,7 +968,7 @@ module FetchUtil
883
968
  filename = value[/filename\*=UTF-8''([^;]+)/i, 1] || value[/filename="?([^";]+)"?/i, 1]
884
969
  return nil if filename.to_s.strip.empty?
885
970
 
886
- URI.decode_www_form_component(filename).strip
971
+ URI::DEFAULT_PARSER.unescape(filename).strip
887
972
  rescue ArgumentError
888
973
  filename.to_s.strip
889
974
  end
@@ -892,7 +977,7 @@ module FetchUtil
892
977
  path = URI.parse(url.to_s).path.to_s
893
978
  basename = File.basename(path)
894
979
  title = basename.empty? || basename == "/" ? URI.parse(url.to_s).host.to_s : basename
895
- URI.decode_www_form_component(title.tr("+", " ")).strip
980
+ URI::DEFAULT_PARSER.unescape(title).strip
896
981
  rescue URI::InvalidURIError, ArgumentError
897
982
  url.to_s
898
983
  end
@@ -925,7 +1010,24 @@ module FetchUtil
925
1010
  end
926
1011
 
927
1012
  def log_request(url, t0)
928
- @request_log&.append(url, duration: monotonic_now - t0)
1013
+ @request_log&.append(request_log_url(url), duration: monotonic_now - t0)
1014
+ end
1015
+
1016
+ def request_log_url(url)
1017
+ value = url.to_s
1018
+ scheme = value.match(/\Ahttps?:/i)
1019
+ return value unless scheme
1020
+
1021
+ separators = value[scheme.end(0)..].to_s.match(%r{\A[\\/]+})
1022
+ return value unless separators
1023
+
1024
+ authority_start = scheme.end(0) + separators[0].length
1025
+ authority_end = value.index(%r{[\\/?#\s]}, authority_start) || value.length
1026
+ authority = value[authority_start...authority_end]
1027
+ userinfo_end = authority.rindex("@")
1028
+ return value unless userinfo_end
1029
+
1030
+ value[0...authority_start] + authority[(userinfo_end + 1)..] + value[authority_end..]
929
1031
  end
930
1032
 
931
1033
  def monotonic_now
@@ -947,15 +1049,8 @@ module FetchUtil
947
1049
 
948
1050
  def effective_domain(url)
949
1051
  host = FetchUtil.strip_www_host(url)
950
- parts = host.split(".")
951
- return host if parts.length <= 2
952
-
953
- if parts.length >= 3 && parts[-2].match?(SECOND_LEVEL_COUNTRY_TLDS) && parts[-1].length == 2
954
- parts.last(3).join(".")
955
- else
956
- parts.last(2).join(".")
957
- end
958
- rescue URI::InvalidURIError
1052
+ PublicSuffix.domain(host, default_rule: nil) || host
1053
+ rescue URI::InvalidURIError, PublicSuffix::Error
959
1054
  nil
960
1055
  end
961
1056
 
@@ -990,6 +1085,12 @@ module FetchUtil
990
1085
  strip_tracking_params(url)
991
1086
  end
992
1087
 
1088
+ def normalized_canonical_url(url)
1089
+ parse_http_uri(normalized_result_url(url)).to_s
1090
+ rescue URI::InvalidURIError
1091
+ nil
1092
+ end
1093
+
993
1094
  def strip_tracking_params(url)
994
1095
  return url if url.nil? || url.empty?
995
1096
 
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module FetchUtil
9
+ class HttpRedirectClient
10
+ REDIRECT_LIMIT = 5
11
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024
12
+ TRANSIENT_ERRORS = [EOFError, IOError, SocketError, SystemCallError, Timeout::Error].freeze
13
+ CLEANUP_ERRORS = (TRANSIENT_ERRORS + [OpenSSL::SSL::SSLError]).freeze
14
+ Response = Struct.new(:url, :status, :headers, :body, :redirects, keyword_init: true)
15
+
16
+ def initialize(timeout:, headers: {}, max_response_bytes: MAX_RESPONSE_BYTES, clock: nil)
17
+ @timeout = positive_timeout(timeout)
18
+ owned_headers = {}
19
+ headers.each do |key, value|
20
+ next if value.to_s.empty?
21
+
22
+ owned_headers[key.dup.freeze] = value.dup.freeze
23
+ end
24
+ @headers = owned_headers.freeze
25
+ @max_response_bytes = positive_max_response_bytes(max_response_bytes)
26
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
27
+ end
28
+
29
+ def get(url, limit: REDIRECT_LIMIT)
30
+ connections = {}
31
+ deadline = clock.call + timeout
32
+ fetch(parse_http_uri(url), limit, [], connections, deadline)
33
+ ensure
34
+ close_connections(connections) if connections
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :timeout, :headers, :max_response_bytes, :clock
40
+
41
+ def positive_timeout(value)
42
+ timeout = Float(value)
43
+ return timeout if timeout.positive? && timeout.finite?
44
+
45
+ raise ArgumentError
46
+ rescue ArgumentError, TypeError
47
+ raise InputError, "timeout must be positive"
48
+ end
49
+
50
+ def positive_max_response_bytes(value)
51
+ bytes = Integer(value)
52
+ return bytes if bytes.positive?
53
+
54
+ raise ArgumentError
55
+ rescue ArgumentError, TypeError, FloatDomainError
56
+ raise InputError, "max_response_bytes must be positive"
57
+ end
58
+
59
+ def fetch(uri, limit, redirects, connections, deadline)
60
+ response, body = request(uri, connections, deadline)
61
+ return build_response(uri, response, body: body, redirects: redirects) unless response.is_a?(Net::HTTPRedirection)
62
+
63
+ raise FetchUtil::Error, "too many redirects for #{uri}" if limit <= 0
64
+
65
+ location = response["location"].to_s.strip
66
+ return build_response(uri, response, body: body, redirects: redirects) if location.empty?
67
+
68
+ redirect_response = build_response(uri, response, body: body)
69
+ redirect_uri = parse_http_uri(uri.merge(location))
70
+ fetch(redirect_uri, limit - 1, redirects + [redirect_response], connections, deadline)
71
+ end
72
+
73
+ def request(uri, connections, deadline)
74
+ attempts = 0
75
+ begin
76
+ response, body = within_deadline(deadline) do |remaining|
77
+ http = connection_for(uri, connections, remaining)
78
+ request = Net::HTTP::Get.new(uri.request_uri.empty? ? "/" : uri.request_uri)
79
+ headers.each { |key, value| request[key] = value }
80
+ body = +""
81
+ response = http.request(request) do |incoming|
82
+ incoming.read_body do |chunk|
83
+ ensure_remaining!(deadline)
84
+ if body.bytesize + chunk.bytesize > max_response_bytes
85
+ raise FetchUtil::Error, "response body exceeds #{max_response_bytes} bytes for #{uri}"
86
+ end
87
+ body << chunk
88
+ end
89
+ end
90
+ [response, body]
91
+ end
92
+ rescue *TRANSIENT_ERRORS
93
+ close_connection(uri, connections)
94
+ attempts += 1
95
+ retry if attempts <= 1
96
+ raise
97
+ end
98
+ end
99
+
100
+ def connection_for(uri, connections, remaining)
101
+ key = [uri.scheme, uri.host, uri.port]
102
+ connections[key] ||= Net::HTTP.start(
103
+ uri.host,
104
+ uri.port,
105
+ use_ssl: uri.scheme == "https",
106
+ open_timeout: remaining,
107
+ read_timeout: remaining
108
+ )
109
+ end
110
+
111
+ def within_deadline(deadline)
112
+ remaining = remaining_timeout(deadline)
113
+ Timeout.timeout(remaining) { yield remaining }
114
+ end
115
+
116
+ def ensure_remaining!(deadline)
117
+ remaining_timeout(deadline)
118
+ end
119
+
120
+ def remaining_timeout(deadline)
121
+ remaining = deadline - clock.call
122
+ raise Timeout::Error, "execution expired" unless remaining.positive?
123
+
124
+ remaining
125
+ end
126
+
127
+ def close_connection(uri, connections)
128
+ key = [uri.scheme, uri.host, uri.port]
129
+ connections.delete(key)&.finish
130
+ rescue *CLEANUP_ERRORS
131
+ nil
132
+ end
133
+
134
+ def close_connections(connections)
135
+ connections.each_value do |http|
136
+ http.finish if http.started?
137
+ rescue *CLEANUP_ERRORS
138
+ nil
139
+ end
140
+ ensure
141
+ connections.clear
142
+ end
143
+
144
+ def build_response(uri, response, body:, redirects: [])
145
+ Response.new(
146
+ url: uri.to_s,
147
+ status: response.code.to_i,
148
+ headers: response.to_hash.transform_keys(&:downcase),
149
+ body: body,
150
+ redirects: redirects
151
+ )
152
+ end
153
+
154
+ def parse_http_uri(url)
155
+ uri = URI.parse(url.to_s)
156
+ raise URI::InvalidURIError, "unsupported url: #{url}" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
157
+
158
+ uri
159
+ end
160
+ end
161
+ end