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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module FetchUtil
|
|
4
6
|
class ParallelFetcher
|
|
5
7
|
Failure = Struct.new(:index, :url, :error, keyword_init: true)
|
|
@@ -8,7 +10,7 @@ module FetchUtil
|
|
|
8
10
|
attr_reader :failures, :results
|
|
9
11
|
|
|
10
12
|
def initialize(failures, results = nil)
|
|
11
|
-
@failures = failures.freeze
|
|
13
|
+
@failures = ordered_failures(failures).freeze
|
|
12
14
|
@results = results&.freeze
|
|
13
15
|
super(self.class.build_message(@failures))
|
|
14
16
|
end
|
|
@@ -19,60 +21,107 @@ module FetchUtil
|
|
|
19
21
|
|
|
20
22
|
def self.build_message(failures)
|
|
21
23
|
preview = failures.first(3).map do |failure|
|
|
22
|
-
label = failure.
|
|
24
|
+
label = if failure.index.nil?
|
|
25
|
+
"<initialization>"
|
|
26
|
+
elsif failure.url.empty?
|
|
27
|
+
"<blank>"
|
|
28
|
+
else
|
|
29
|
+
failure.url
|
|
30
|
+
end
|
|
23
31
|
"#{label} (#{failure.error.class}: #{failure.error.message})"
|
|
24
32
|
end.join(", ")
|
|
25
33
|
suffix = failures.length > 3 ? ", +#{failures.length - 3} more" : ""
|
|
26
34
|
"parallel fetch failed for #{failures.length} URLs: #{preview}#{suffix}"
|
|
27
35
|
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def ordered_failures(failures)
|
|
40
|
+
initialization_failures, url_failures = failures.partition { |failure| failure.index.nil? }
|
|
41
|
+
initialization_failures.sort_by! { |failure| [failure.error.class.name.to_s, failure.error.message] }
|
|
42
|
+
url_failures.sort_by!(&:index)
|
|
43
|
+
initialization_failures + url_failures
|
|
44
|
+
end
|
|
28
45
|
end
|
|
29
46
|
|
|
30
47
|
DEFAULT_CONCURRENCY = 4
|
|
48
|
+
FETCHER_COLLABORATOR_OPTIONS = %i[browser extractor raw_docs_fallback request_log pdf_header_probe].freeze
|
|
49
|
+
private_constant :FETCHER_COLLABORATOR_OPTIONS
|
|
31
50
|
|
|
32
51
|
def initialize(fetcher_factory: nil, concurrency: DEFAULT_CONCURRENCY, **fetch_options)
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
unless concurrency.is_a?(Integer) && concurrency.positive?
|
|
53
|
+
raise InputError, "concurrency must be a positive Integer"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@shared_browser = fetcher_factory.nil? && fetch_options[:browser]
|
|
57
|
+
@fetcher_factory = fetcher_factory || default_fetcher_factory(fetch_options)
|
|
58
|
+
@concurrency = concurrency
|
|
35
59
|
end
|
|
36
60
|
|
|
37
61
|
def fetch(urls)
|
|
38
|
-
work = Array(urls).
|
|
62
|
+
work = Array(urls).map { |url| url.to_s.dup.freeze }
|
|
39
63
|
return [] if work.empty?
|
|
40
64
|
|
|
41
65
|
results = Array.new(work.length)
|
|
42
|
-
worker_count = [@concurrency, work.length].min
|
|
43
66
|
failures = []
|
|
67
|
+
pending_indices = []
|
|
68
|
+
work.each_with_index do |url, index|
|
|
69
|
+
if url.empty?
|
|
70
|
+
error = URI::InvalidURIError.new("unsupported url: #{url}")
|
|
71
|
+
failures << Failure.new(index: index, url: url, error: error)
|
|
72
|
+
else
|
|
73
|
+
pending_indices << index
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
worker_count = [@concurrency, pending_indices.length].min
|
|
78
|
+
if worker_count > 1 && @shared_browser
|
|
79
|
+
raise InputError, "browser cannot be shared across parallel workers; use fetcher_factory"
|
|
80
|
+
end
|
|
44
81
|
next_index = 0
|
|
45
82
|
mutex = Mutex.new
|
|
46
|
-
|
|
47
|
-
threads =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
stopping = false
|
|
84
|
+
threads = []
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
worker_count.times do
|
|
88
|
+
threads << Thread.new do
|
|
89
|
+
fetcher = @fetcher_factory.call
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
loop do
|
|
93
|
+
index = mutex.synchronize do
|
|
94
|
+
if !stopping && next_index < pending_indices.length
|
|
95
|
+
current = pending_indices[next_index]
|
|
96
|
+
next_index += 1
|
|
97
|
+
current
|
|
98
|
+
end
|
|
58
99
|
end
|
|
59
|
-
|
|
60
|
-
break if index.nil?
|
|
100
|
+
break if index.nil?
|
|
61
101
|
|
|
62
|
-
|
|
102
|
+
url = work[index]
|
|
63
103
|
|
|
104
|
+
begin
|
|
105
|
+
results[index] = fetcher.fetch(url)
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
mutex.synchronize { failures << Failure.new(index: index, url: url, error: e) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
ensure
|
|
64
111
|
begin
|
|
65
|
-
|
|
66
|
-
rescue
|
|
67
|
-
|
|
112
|
+
fetcher.quit if fetcher.respond_to?(:quit)
|
|
113
|
+
rescue Ferrum::Error
|
|
114
|
+
nil
|
|
68
115
|
end
|
|
69
116
|
end
|
|
70
|
-
|
|
71
|
-
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
mutex.synchronize { failures << Failure.new(index: nil, url: nil, error: e) }
|
|
72
119
|
end
|
|
73
|
-
rescue StandardError => e
|
|
74
|
-
mutex.synchronize { failures << Failure.new(index: nil, url: nil, error: e) }
|
|
75
120
|
end
|
|
121
|
+
rescue ThreadError
|
|
122
|
+
mutex.synchronize { stopping = true }
|
|
123
|
+
threads.each(&:join)
|
|
124
|
+
raise
|
|
76
125
|
end
|
|
77
126
|
|
|
78
127
|
threads.each(&:join)
|
|
@@ -83,6 +132,54 @@ module FetchUtil
|
|
|
83
132
|
|
|
84
133
|
private
|
|
85
134
|
|
|
135
|
+
def default_fetcher_factory(fetch_options)
|
|
136
|
+
owned_options = immutable_fetch_options(fetch_options)
|
|
137
|
+
-> { Fetcher.new(**immutable_fetch_options(owned_options)) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def immutable_fetch_options(fetch_options)
|
|
141
|
+
memo = {}.compare_by_identity
|
|
142
|
+
owned = {}
|
|
143
|
+
memo[fetch_options] = owned
|
|
144
|
+
|
|
145
|
+
fetch_options.each do |key, value|
|
|
146
|
+
owned_key = immutable_fetch_option(key, memo)
|
|
147
|
+
owned[owned_key] = if FETCHER_COLLABORATOR_OPTIONS.include?(key)
|
|
148
|
+
value
|
|
149
|
+
else
|
|
150
|
+
immutable_fetch_option(value, memo)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
owned.freeze
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def immutable_fetch_option(value, memo)
|
|
157
|
+
return memo[value] if memo.key?(value)
|
|
158
|
+
|
|
159
|
+
case value
|
|
160
|
+
when String
|
|
161
|
+
memo[value] = value.dup.freeze
|
|
162
|
+
when Array
|
|
163
|
+
owned = value.dup
|
|
164
|
+
memo[value] = owned
|
|
165
|
+
owned.map! { |item| immutable_fetch_option(item, memo) }
|
|
166
|
+
owned.freeze
|
|
167
|
+
when Hash
|
|
168
|
+
owned = value.dup
|
|
169
|
+
memo[value] = owned
|
|
170
|
+
owned.clear
|
|
171
|
+
owned.default = immutable_fetch_option(value.default, memo) unless value.default_proc
|
|
172
|
+
value.each do |key, item|
|
|
173
|
+
owned_key = value.compare_by_identity? ? key : immutable_fetch_option(key, memo)
|
|
174
|
+
owned[owned_key] = immutable_fetch_option(item, memo)
|
|
175
|
+
end
|
|
176
|
+
owned.freeze unless owned.default_proc
|
|
177
|
+
owned
|
|
178
|
+
else
|
|
179
|
+
value
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
86
183
|
def raise_for_failures(failures, results)
|
|
87
184
|
return if failures.empty?
|
|
88
185
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cgi"
|
|
4
3
|
require "nokogiri"
|
|
4
|
+
require "openssl"
|
|
5
5
|
require "uri"
|
|
6
6
|
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "http_redirect_client"
|
|
8
8
|
|
|
9
9
|
module FetchUtil
|
|
10
10
|
class RawDocsFallback
|
|
@@ -15,6 +15,10 @@ module FetchUtil
|
|
|
15
15
|
|
|
16
16
|
BLOCK_ELEMENTS = %w[h1 h2 h3 h4 h5 h6 p pre ul ol li table tr].freeze
|
|
17
17
|
BLOCK_SELECTOR = BLOCK_ELEMENTS.join(", ").freeze
|
|
18
|
+
URL_ATTRIBUTES = %w[action background cite classid codebase data data-lazy data-lazy-src data-original data-src formaction
|
|
19
|
+
href itemid longdesc manifest poster profile src usemap xlink:href].freeze
|
|
20
|
+
SRCSET_ATTRIBUTES = %w[imagesrcset srcset].freeze
|
|
21
|
+
DROP_ATTRIBUTES = %w[archive ping srcdoc style].freeze
|
|
18
22
|
DROP_SELECTORS = [
|
|
19
23
|
"script",
|
|
20
24
|
"style",
|
|
@@ -49,7 +53,7 @@ module FetchUtil
|
|
|
49
53
|
PRUNED_TEXT_PATTERN = /\A(?:on this page|table of contents|edit this page|copy page|copy item path|search|settings|help|expand description)\z/i
|
|
50
54
|
|
|
51
55
|
def initialize(timeout: 20, http_client: nil)
|
|
52
|
-
@timeout = timeout.
|
|
56
|
+
@timeout = timeout.to_f
|
|
53
57
|
@http_client = http_client || FetchUtil::HttpRedirectClient.new(timeout: @timeout, headers: DEFAULT_HEADERS)
|
|
54
58
|
end
|
|
55
59
|
|
|
@@ -59,7 +63,7 @@ module FetchUtil
|
|
|
59
63
|
return nil unless payload
|
|
60
64
|
|
|
61
65
|
[final_url, payload]
|
|
62
|
-
rescue Error, SocketError, SystemCallError, Timeout::Error, URI::InvalidURIError
|
|
66
|
+
rescue Error, IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, URI::InvalidURIError
|
|
63
67
|
nil
|
|
64
68
|
end
|
|
65
69
|
|
|
@@ -69,20 +73,24 @@ module FetchUtil
|
|
|
69
73
|
return nil unless root
|
|
70
74
|
|
|
71
75
|
prune!(root)
|
|
76
|
+
prune_credential_urls!(root, final_url)
|
|
72
77
|
title = [fragment_title(document, final_url), first_heading(root), meta_title(document), document.title]
|
|
73
78
|
.map { |candidate| clean_text(candidate) }
|
|
74
79
|
.find { |candidate| !candidate.empty? }
|
|
75
80
|
markdown = markdown_from_root(root, title)
|
|
76
81
|
return nil if clean_text(markdown).length < 40
|
|
77
82
|
|
|
83
|
+
language = clean_optional_text(document.at_css("html")&.[]("lang"))
|
|
84
|
+
|
|
78
85
|
{
|
|
79
86
|
"title" => title,
|
|
80
|
-
"byline" => meta_value(document, "author"),
|
|
87
|
+
"byline" => clean_optional_text(meta_value(document, "author")),
|
|
81
88
|
"excerpt" => first_paragraph(root),
|
|
82
|
-
"siteName" => meta_value(document, "og:site_name", attr: "property") || safe_host(final_url),
|
|
83
|
-
"publishedTime" => meta_value(document, "article:published_time", attr: "property") ||
|
|
89
|
+
"siteName" => clean_optional_text(meta_value(document, "og:site_name", attr: "property")) || safe_host(final_url),
|
|
90
|
+
"publishedTime" => clean_optional_text(meta_value(document, "article:published_time", attr: "property") ||
|
|
91
|
+
meta_value(document, "publish-date")),
|
|
84
92
|
"canonicalUrl" => canonical_url(document, final_url),
|
|
85
|
-
"language" =>
|
|
93
|
+
"language" => language,
|
|
86
94
|
"html" => root.to_html,
|
|
87
95
|
"markdown" => markdown,
|
|
88
96
|
"readerMode" => false,
|
|
@@ -99,17 +107,64 @@ module FetchUtil
|
|
|
99
107
|
end
|
|
100
108
|
|
|
101
109
|
def canonical_url(document, final_url)
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
fallback_url = http_url(strip_fragment(final_url))
|
|
111
|
+
document.css('link[rel="canonical"]').each do |node|
|
|
112
|
+
href = node["href"]
|
|
113
|
+
next unless href && !href.empty?
|
|
114
|
+
|
|
115
|
+
url = http_url(URI.join(final_url, href).to_s)
|
|
116
|
+
return url if url
|
|
117
|
+
rescue URI::InvalidURIError
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
fallback_url
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def http_url(value)
|
|
124
|
+
uri = URI.parse(value.to_s)
|
|
125
|
+
uri.to_s if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && uri.userinfo.nil?
|
|
126
|
+
rescue URI::InvalidURIError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def prune_credential_urls!(root, base_url)
|
|
131
|
+
([root] + root.css("*").to_a).each do |node|
|
|
132
|
+
DROP_ATTRIBUTES.each { |attribute| node.remove_attribute(attribute) }
|
|
133
|
+
URL_ATTRIBUTES.each do |attribute|
|
|
134
|
+
value = node[attribute]
|
|
135
|
+
next unless value
|
|
136
|
+
|
|
137
|
+
node.remove_attribute(attribute) if credential_url?(value, base_url)
|
|
138
|
+
end
|
|
139
|
+
SRCSET_ATTRIBUTES.each do |attribute|
|
|
140
|
+
value = node[attribute]
|
|
141
|
+
next unless value
|
|
142
|
+
|
|
143
|
+
entries = value.split(",").map(&:strip).reject do |entry|
|
|
144
|
+
reference = entry.split(/\s+/, 2).first
|
|
145
|
+
credential_url?(reference, base_url)
|
|
146
|
+
end
|
|
147
|
+
entries.empty? ? node.remove_attribute(attribute) : node[attribute] = entries.join(", ")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
root.xpath(".//text() | self::text()").each do |node|
|
|
151
|
+
sanitized = credential_free_text(node.text)
|
|
152
|
+
node.content = sanitized unless sanitized == node.text
|
|
153
|
+
end
|
|
154
|
+
end
|
|
104
155
|
|
|
105
|
-
|
|
156
|
+
def credential_url?(value, base_url)
|
|
157
|
+
return true if value.to_s.match?(%r{\A\s*(?:https?:)?[\\/]{2}[^\\/\s?#@]+@}i)
|
|
158
|
+
|
|
159
|
+
uri = URI.join(base_url, value.to_s)
|
|
160
|
+
uri.is_a?(URI::HTTP) && !uri.userinfo.nil?
|
|
106
161
|
rescue URI::InvalidURIError
|
|
107
|
-
|
|
162
|
+
false
|
|
108
163
|
end
|
|
109
164
|
|
|
110
165
|
def fragment_id(url)
|
|
111
166
|
fragment = URI.parse(url).fragment.to_s
|
|
112
|
-
|
|
167
|
+
URI::DEFAULT_PARSER.unescape(fragment)
|
|
113
168
|
rescue URI::InvalidURIError
|
|
114
169
|
""
|
|
115
170
|
end
|
|
@@ -121,11 +176,13 @@ module FetchUtil
|
|
|
121
176
|
node = fragment_node(document, id)
|
|
122
177
|
return nil unless node
|
|
123
178
|
|
|
124
|
-
|
|
179
|
+
return fragment_heading_root(document, node) if fragment_heading_level(node)
|
|
180
|
+
|
|
181
|
+
if fragment_anchor?(node, id)
|
|
125
182
|
container = Nokogiri::XML::Node.new("div", document)
|
|
126
183
|
sibling = node.next_sibling
|
|
127
184
|
while sibling
|
|
128
|
-
break if sibling.element? && sibling
|
|
185
|
+
break if sibling.element? && fragment_anchor?(sibling)
|
|
129
186
|
|
|
130
187
|
container.add_child(sibling.dup)
|
|
131
188
|
sibling = sibling.next_sibling
|
|
@@ -143,6 +200,29 @@ module FetchUtil
|
|
|
143
200
|
node.dup
|
|
144
201
|
end
|
|
145
202
|
|
|
203
|
+
def fragment_heading_root(document, heading)
|
|
204
|
+
level = fragment_heading_level(heading)
|
|
205
|
+
container = Nokogiri::XML::Node.new("div", document)
|
|
206
|
+
sibling = heading
|
|
207
|
+
|
|
208
|
+
while sibling
|
|
209
|
+
if sibling != heading && sibling.element?
|
|
210
|
+
sibling_level = fragment_heading_level(sibling)
|
|
211
|
+
break if empty_fragment_anchor?(sibling) || (sibling_level && sibling_level <= level)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
container.add_child(sibling.dup)
|
|
215
|
+
sibling = sibling.next_sibling
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
container
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def fragment_heading_level(node)
|
|
222
|
+
match = node&.name&.match(/\Ah([1-6])\z/)
|
|
223
|
+
match && Integer(match[1], 10)
|
|
224
|
+
end
|
|
225
|
+
|
|
146
226
|
def fragment_title(document, url)
|
|
147
227
|
id = fragment_id(url)
|
|
148
228
|
return nil if id.empty?
|
|
@@ -152,7 +232,7 @@ module FetchUtil
|
|
|
152
232
|
|
|
153
233
|
heading = if node.name.match?(/h[1-6]/)
|
|
154
234
|
node
|
|
155
|
-
elsif node
|
|
235
|
+
elsif fragment_anchor?(node, id)
|
|
156
236
|
node.at_xpath("following-sibling::*[1][self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]") ||
|
|
157
237
|
node.at_xpath("following-sibling::*[1]//strong[1]")
|
|
158
238
|
else
|
|
@@ -219,7 +299,11 @@ module FetchUtil
|
|
|
219
299
|
|
|
220
300
|
def markdown_from_root(root, title)
|
|
221
301
|
sections = []
|
|
222
|
-
root.css(BLOCK_SELECTOR).
|
|
302
|
+
blocks = root.css(BLOCK_SELECTOR).to_a
|
|
303
|
+
blocks.unshift(root) if %w[ul ol].include?(root.name)
|
|
304
|
+
blocks.each do |node|
|
|
305
|
+
next if node.ancestors.any? { |ancestor| %w[ul ol].include?(ancestor.name) }
|
|
306
|
+
|
|
223
307
|
text = clean_text(node.text)
|
|
224
308
|
next if text.empty?
|
|
225
309
|
|
|
@@ -230,9 +314,12 @@ module FetchUtil
|
|
|
230
314
|
when "p"
|
|
231
315
|
sections << text
|
|
232
316
|
when "pre"
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
317
|
+
code = node.text.rstrip
|
|
318
|
+
fence_length = [3, code.scan(/`+/).map(&:length).max.to_i + 1].max
|
|
319
|
+
fence = "`" * fence_length
|
|
320
|
+
sections << [fence, code, fence].join("\n")
|
|
321
|
+
when "ul", "ol"
|
|
322
|
+
sections << markdown_list(node)
|
|
236
323
|
when "tr"
|
|
237
324
|
cells = node.css("th, td").map { |cell| clean_text(cell.text) }.reject(&:empty?)
|
|
238
325
|
sections << "- #{cells.join(": ")}" unless cells.empty?
|
|
@@ -244,8 +331,45 @@ module FetchUtil
|
|
|
244
331
|
markdown
|
|
245
332
|
end
|
|
246
333
|
|
|
334
|
+
def markdown_list(list, depth = 0)
|
|
335
|
+
ordinal = integer_attribute(list, "start") || 1
|
|
336
|
+
items = list.css("li").select do |item|
|
|
337
|
+
item.ancestors.find { |ancestor| %w[ul ol].include?(ancestor.name) } == list
|
|
338
|
+
end
|
|
339
|
+
lines = items.flat_map do |item|
|
|
340
|
+
marker_value = integer_attribute(item, "value")
|
|
341
|
+
ordinal = marker_value if marker_value
|
|
342
|
+
marker = list.name == "ol" ? "#{ordinal}." : "-"
|
|
343
|
+
content = item.dup
|
|
344
|
+
content.css("ul, ol").remove
|
|
345
|
+
line = "#{" " * depth}#{marker} #{clean_text(content.text)}"
|
|
346
|
+
ordinal += 1 if list.name == "ol"
|
|
347
|
+
|
|
348
|
+
nested = item.css("ul, ol").select do |child|
|
|
349
|
+
child.ancestors.find { |ancestor| ancestor.name == "li" } == item
|
|
350
|
+
end
|
|
351
|
+
[line] + nested.flat_map { |child| markdown_list(child, depth + 1).lines(chomp: true) }
|
|
352
|
+
end
|
|
353
|
+
lines.join("\n")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def integer_attribute(node, name)
|
|
357
|
+
Integer(node[name], 10)
|
|
358
|
+
rescue ArgumentError, TypeError
|
|
359
|
+
nil
|
|
360
|
+
end
|
|
361
|
+
|
|
247
362
|
def clean_text(text)
|
|
248
|
-
FetchUtil.normalize_whitespace(text)
|
|
363
|
+
FetchUtil.normalize_whitespace(credential_free_text(text))
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def clean_optional_text(text)
|
|
367
|
+
value = clean_text(text)
|
|
368
|
+
value unless value.empty?
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def credential_free_text(text)
|
|
372
|
+
text.to_s.gsub(%r{(https?:[\\/]{2})[^\\/\s?#@]+@}i, "\\1")
|
|
249
373
|
end
|
|
250
374
|
|
|
251
375
|
def strip_fragment(url)
|
|
@@ -266,6 +390,19 @@ module FetchUtil
|
|
|
266
390
|
document.at_xpath(%(//*[@id=#{xpath_literal(id)}])) || document.at_xpath(%(//a[@name=#{xpath_literal(id)}]))
|
|
267
391
|
end
|
|
268
392
|
|
|
393
|
+
def fragment_anchor?(node, id = nil)
|
|
394
|
+
return false unless node.name == "a"
|
|
395
|
+
|
|
396
|
+
anchor_id = node["name"]
|
|
397
|
+
anchor_id ||= node["id"] if node.element_children.empty? && clean_text(node.text).empty?
|
|
398
|
+
id ? anchor_id == id : !anchor_id.to_s.empty?
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def empty_fragment_anchor?(node)
|
|
402
|
+
node.name == "a" && node.element_children.empty? && clean_text(node.text).empty? &&
|
|
403
|
+
![node["name"], node["id"]].compact.empty?
|
|
404
|
+
end
|
|
405
|
+
|
|
269
406
|
def xpath_literal(value)
|
|
270
407
|
return %('#{value}') unless value.include?("'")
|
|
271
408
|
return %("#{value}") unless value.include?('"')
|
|
@@ -10,27 +10,34 @@ module FetchUtil
|
|
|
10
10
|
cached = read_cache(path)
|
|
11
11
|
return cached if cached
|
|
12
12
|
|
|
13
|
-
payload = yield
|
|
14
|
-
|
|
13
|
+
payload, cacheable = yield
|
|
14
|
+
begin
|
|
15
|
+
write_cache(path, payload) if cacheable
|
|
16
|
+
rescue SystemCallError, IOError
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
15
19
|
payload
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def fetch_record(key, uri, fallback: nil, require_success: true)
|
|
19
23
|
cache_fetch(key) do
|
|
20
|
-
response = record_response(uri, require_success: require_success)
|
|
21
|
-
response ? yield(response.body, response) : fallback
|
|
24
|
+
response, cacheable = record_response(uri, require_success: require_success)
|
|
25
|
+
payload = response ? yield(response.body, response) : fallback
|
|
26
|
+
[payload, cacheable]
|
|
22
27
|
end
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
def record_response(uri, require_success:)
|
|
31
|
+
cacheable = true
|
|
26
32
|
Array(uri).each do |candidate|
|
|
27
|
-
response = safe_get(candidate)
|
|
33
|
+
response, completed = safe_get(candidate)
|
|
34
|
+
cacheable &&= completed
|
|
28
35
|
next unless response
|
|
29
36
|
|
|
30
|
-
return response if !require_success || response.status&.between?(200, 299)
|
|
37
|
+
return [response, cacheable] if !require_success || response.status&.between?(200, 299)
|
|
31
38
|
end
|
|
32
39
|
|
|
33
|
-
nil
|
|
40
|
+
[nil, cacheable]
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
def cache_file_path(key)
|
|
@@ -42,25 +49,51 @@ module FetchUtil
|
|
|
42
49
|
return nil unless File.exist?(path)
|
|
43
50
|
|
|
44
51
|
parsed = JSON.parse(File.read(path))
|
|
52
|
+
return nil unless parsed.is_a?(Hash)
|
|
53
|
+
|
|
45
54
|
cached_at = Time.parse(parsed.fetch("cached_at"))
|
|
46
|
-
|
|
55
|
+
now = Time.now.utc
|
|
56
|
+
return nil if cached_at > now || now - cached_at > CACHE_TTL
|
|
47
57
|
|
|
48
58
|
parsed["payload"]
|
|
49
|
-
rescue
|
|
59
|
+
rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, ArgumentError
|
|
50
60
|
nil
|
|
51
61
|
end
|
|
52
62
|
|
|
53
63
|
def write_cache(path, payload)
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
directory = File.dirname(path)
|
|
65
|
+
serialized = JSON.generate({ "cached_at" => Time.now.utc.iso8601, "payload" => json_safe(payload) })
|
|
66
|
+
FileUtils.mkdir_p(directory)
|
|
67
|
+
existing_mode = cache_file_mode(path)
|
|
68
|
+
temp_path = File.join(directory, ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
|
|
69
|
+
|
|
70
|
+
File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL, 0o666) do |file|
|
|
71
|
+
file.write(serialized)
|
|
72
|
+
end
|
|
73
|
+
File.chmod(existing_mode, temp_path) if existing_mode
|
|
74
|
+
File.rename(temp_path, path)
|
|
75
|
+
ensure
|
|
76
|
+
FileUtils.rm_f(temp_path) if temp_path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cache_file_mode(path)
|
|
80
|
+
File.stat(path).mode & 0o777
|
|
81
|
+
rescue Errno::ENOENT
|
|
82
|
+
nil
|
|
56
83
|
end
|
|
57
84
|
|
|
58
85
|
def safe_get(url)
|
|
59
|
-
client.get(url)
|
|
86
|
+
response = client.get(url)
|
|
87
|
+
[response, cacheable_response?(response)]
|
|
60
88
|
rescue ArgumentError, IOError, SocketError, Timeout::Error
|
|
61
|
-
nil
|
|
89
|
+
[nil, false]
|
|
62
90
|
rescue FetchUtil::Error, SystemCallError, OpenSSL::SSL::SSLError
|
|
63
|
-
nil
|
|
91
|
+
[nil, false]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cacheable_response?(response)
|
|
95
|
+
status = response.status
|
|
96
|
+
![408, 429].include?(status) && !status&.between?(500, 599)
|
|
64
97
|
end
|
|
65
98
|
|
|
66
99
|
def deep_copy(value)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
3
5
|
module FetchUtil
|
|
4
6
|
class Regulatory
|
|
5
7
|
module FetchRecords
|
|
@@ -63,12 +65,12 @@ module FetchUtil
|
|
|
63
65
|
def parse_http_uri(value)
|
|
64
66
|
uri = URI.parse(value.to_s.strip)
|
|
65
67
|
unless uri.is_a?(URI::HTTP) && uri.host
|
|
66
|
-
raise
|
|
68
|
+
raise InputError, "unsupported url: #{value}"
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
uri
|
|
70
72
|
rescue URI::InvalidURIError
|
|
71
|
-
raise
|
|
73
|
+
raise InputError, "unsupported url: #{value}"
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
def first_header_value(headers, name)
|
|
@@ -87,12 +89,13 @@ module FetchUtil
|
|
|
87
89
|
end
|
|
88
90
|
|
|
89
91
|
def parse_meta_tags(body)
|
|
90
|
-
body.to_s
|
|
91
|
-
|
|
92
|
-
tag.
|
|
93
|
-
|
|
92
|
+
document = Nokogiri::HTML(body.to_s)
|
|
93
|
+
document.css("meta").filter_map do |tag|
|
|
94
|
+
next if tag.ancestors.any? { |ancestor| %w[template script].include?(ancestor.name) }
|
|
95
|
+
|
|
96
|
+
tag.attribute_nodes.each_with_object({}) do |attribute, attributes|
|
|
97
|
+
attributes[attribute.name.downcase] = attribute.value
|
|
94
98
|
end
|
|
95
|
-
attributes
|
|
96
99
|
end
|
|
97
100
|
end
|
|
98
101
|
|
|
@@ -27,13 +27,20 @@ module FetchUtil
|
|
|
27
27
|
name = attributes["name"].to_s.strip
|
|
28
28
|
next if name.empty?
|
|
29
29
|
next if name.casecmp?("tdm-reservation") || name.casecmp?("tdm-policy")
|
|
30
|
-
next unless
|
|
30
|
+
next unless robot_meta_name?(name)
|
|
31
31
|
|
|
32
32
|
signals.concat(extract_robot_directive_signals(attributes["content"], path: path, meta_name: name))
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
signals
|
|
36
36
|
end
|
|
37
|
+
|
|
38
|
+
def robot_meta_name?(name)
|
|
39
|
+
return true if name.casecmp?("robots")
|
|
40
|
+
return false if name.casecmp?("robot")
|
|
41
|
+
|
|
42
|
+
name.match?(/\A[a-z0-9][a-z0-9_.-]*bot(?:[-_.][a-z0-9]+)*\z/i)
|
|
43
|
+
end
|
|
37
44
|
end
|
|
38
45
|
end
|
|
39
46
|
end
|