fetch_util 0.5.2 → 0.6.1
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 +66 -0
- data/README.md +3 -1
- data/Rakefile +0 -2
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +12 -17
- data/lib/fetch_util/browser/interaction_helpers/timing_helpers.rb +42 -15
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_commit_details_script.rb +48 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_product_state_script.rb +80 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_helpers_script.rb +53 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_state_script.rb +118 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_stabilization.rb +54 -0
- data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_state.rb +27 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_product_state.rb +55 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_request_state.rb +100 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_state.rb +59 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_modules.rb +26 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_state.rb +93 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resources.rb +73 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_thread_state.rb +92 -0
- data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_threads.rb +71 -0
- data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +9 -10
- data/lib/fetch_util/browser/site_stabilization/facebook_stabilization.rb +56 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_change_product_state_script.rb +97 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_change_request_state_script.rb +75 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_change_stabilization.rb +37 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_change_state.rb +39 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_fetch_state_script.rb +31 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_product_state_script.rb +76 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_request_state_script.rb +111 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_route_state_script.rb +71 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_stabilization.rb +37 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_state.rb +42 -0
- data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_validation_state_script.rb +49 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_product_state.rb +88 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_state.rb +109 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resources.rb +30 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_stabilization.rb +60 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_product_state.rb +74 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_timeline_state.rb +69 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_threads.rb +28 -0
- data/lib/fetch_util/browser/site_stabilization/gitea_family_visibility_script.rb +28 -0
- data/lib/fetch_util/browser/site_stabilization/github_pull_resource_stabilization.rb +31 -0
- data/lib/fetch_util/browser/site_stabilization/github_pull_resources.rb +105 -0
- data/lib/fetch_util/browser/site_stabilization/github_threads.rb +82 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state.rb +17 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state_script.rb +98 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_visibility_script.rb +43 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resources.rb +28 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_repo.rb +3 -11
- data/lib/fetch_util/browser/site_stabilization/gitlab_stabilization.rb +33 -0
- data/lib/fetch_util/browser/site_stabilization/gitlab_threads.rb +92 -0
- data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +19 -38
- data/lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb +3 -3
- data/lib/fetch_util/browser/site_stabilization.rb +89 -0
- data/lib/fetch_util/browser/stabilization/anubis.rb +4 -5
- data/lib/fetch_util/browser/stabilization/page_flow.rb +71 -27
- data/lib/fetch_util/browser/stabilization/spa_hydration.rb +7 -5
- data/lib/fetch_util/browser.rb +88 -17
- data/lib/fetch_util/cli.rb +69 -23
- data/lib/fetch_util/extractor.rb +8 -2
- data/lib/fetch_util/fetcher.rb +144 -43
- data/lib/fetch_util/http_redirect_client.rb +161 -0
- data/lib/fetch_util/parallel_fetcher.rb +125 -28
- data/lib/fetch_util/raw_docs_fallback.rb +158 -21
- data/lib/fetch_util/regulatory/cache_store.rb +47 -14
- data/lib/fetch_util/regulatory/fetch_records.rb +10 -7
- data/lib/fetch_util/regulatory/headers.rb +8 -1
- data/lib/fetch_util/regulatory/http_client.rb +2 -97
- data/lib/fetch_util/regulatory/orchestration.rb +10 -6
- data/lib/fetch_util/regulatory/page.rb +8 -11
- data/lib/fetch_util/regulatory/robots.rb +2 -10
- data/lib/fetch_util/regulatory/source_selection.rb +13 -3
- data/lib/fetch_util/regulatory/tdm_page.rb +9 -4
- data/lib/fetch_util/regulatory/tdm_policy.rb +16 -9
- data/lib/fetch_util/regulatory/tdm_rep.rb +4 -3
- data/lib/fetch_util/regulatory/tdm_support.rb +10 -6
- data/lib/fetch_util/regulatory.rb +5 -1
- data/lib/fetch_util/request_log.rb +24 -3
- data/lib/fetch_util/result.rb +37 -89
- data/lib/fetch_util/search_transport.rb +102 -35
- data/lib/fetch_util/searcher.rb +15 -13
- data/lib/fetch_util/version.rb +1 -1
- data/lib/fetch_util.rb +4 -1
- metadata +66 -2
data/lib/fetch_util/extractor.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/fetch_util/fetcher.rb
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
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(
|
|
204
|
+
result = @browser.with_page(transport_url) do |page|
|
|
184
205
|
payload = @extractor.extract(page)
|
|
185
|
-
if telegram_focal_preview?(
|
|
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(
|
|
210
|
+
build_result(requested_url, page.current_url, payload)
|
|
190
211
|
end
|
|
191
|
-
fallback = seznam_cmp_redirect_fallback_candidate?(
|
|
192
|
-
fallback ||= docs_fallback_candidate?(
|
|
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(
|
|
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?(
|
|
224
|
+
fallback = docs_fallback_candidate?(transport_url) ? @raw_docs_fallback.fetch(transport_url) : nil
|
|
205
225
|
if fallback
|
|
206
|
-
result = fallback_result(
|
|
207
|
-
log_request(url, t0)
|
|
226
|
+
result = fallback_result(requested_url, fallback)
|
|
208
227
|
return result
|
|
209
228
|
end
|
|
210
229
|
|
|
211
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
951
|
-
|
|
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
|