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
@@ -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.url || "<initialization>"
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
- @fetcher_factory = fetcher_factory || -> { Fetcher.new(**fetch_options) }
34
- @concurrency = [concurrency.to_i, 1].max
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).compact.map(&:to_s).reject(&:empty?)
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 = Array.new(worker_count) do
48
- Thread.new do
49
- fetcher = @fetcher_factory.call
50
-
51
- begin
52
- loop do
53
- index = mutex.synchronize do
54
- if next_index < work.length
55
- current = next_index
56
- next_index += 1
57
- current
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
- end
60
- break if index.nil?
100
+ break if index.nil?
61
101
 
62
- url = work[index]
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
- results[index] = fetcher.fetch(url)
66
- rescue StandardError => e
67
- mutex.synchronize { failures << Failure.new(index: index, url: url, error: e) }
112
+ fetcher.quit if fetcher.respond_to?(:quit)
113
+ rescue Ferrum::Error
114
+ nil
68
115
  end
69
116
  end
70
- ensure
71
- fetcher.quit if fetcher.respond_to?(:quit)
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 "regulatory/http_client"
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.to_i
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") || meta_value(document, "publish-date"),
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" => document.at_css("html")&.[]("lang") || "en",
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
- href = document.at_css('link[rel="canonical"]')&.[]("href")
103
- return strip_fragment(final_url) unless href && !href.empty?
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
- URI.join(final_url, href).to_s
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
- strip_fragment(final_url)
162
+ false
108
163
  end
109
164
 
110
165
  def fragment_id(url)
111
166
  fragment = URI.parse(url).fragment.to_s
112
- CGI.unescape(fragment)
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
- if node.name == "a" && node["name"] == id
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.name == "a" && sibling["name"]
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.name == "a" && node["name"] == id
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).each do |node|
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
- sections << ["```", node.text.rstrip, "```"].join("\n")
234
- when "li"
235
- sections << "- #{text}"
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
- write_cache(path, payload)
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
- return nil if Time.now.utc - cached_at > CACHE_TTL
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 Errno::ENOENT, JSON::ParserError, KeyError, TypeError, ArgumentError
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
- FileUtils.mkdir_p(File.dirname(path))
55
- File.write(path, JSON.generate({ "cached_at" => Time.now.utc.iso8601, "payload" => json_safe(payload) }))
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 ArgumentError, "unsupported url: #{value}"
68
+ raise InputError, "unsupported url: #{value}"
67
69
  end
68
70
 
69
71
  uri
70
72
  rescue URI::InvalidURIError
71
- raise ArgumentError, "unsupported url: #{value}"
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.scan(/<meta\b[^>]*>/im).map do |tag|
91
- attributes = {}
92
- tag.scan(/([A-Za-z_:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/).each do |name, quoted, single, bare|
93
- attributes[name.downcase] = CGI.unescapeHTML(quoted || single || bare || "")
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 name.casecmp?("robots") || name.match?(/bot/i)
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