html2rss 0.22.2 → 0.23.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -108
  3. data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
  4. data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
  5. data/lib/html2rss/article.rb +206 -0
  6. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
  7. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
  8. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
  9. data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
  10. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
  11. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
  12. data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
  13. data/lib/html2rss/auto_source/discovery.rb +14 -0
  14. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
  15. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
  16. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
  17. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
  18. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
  19. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
  20. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
  21. data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
  22. data/lib/html2rss/auto_source/scraper/html.rb +22 -33
  23. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  24. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
  25. data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
  27. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  28. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  29. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  30. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  31. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  32. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  33. data/lib/html2rss/auto_source/scraper.rb +10 -3
  34. data/lib/html2rss/auto_source.rb +30 -35
  35. data/lib/html2rss/channel.rb +118 -0
  36. data/lib/html2rss/cli.rb +5 -6
  37. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  38. data/lib/html2rss/config/request_controls.rb +136 -0
  39. data/lib/html2rss/config/request_headers.rb +1 -1
  40. data/lib/html2rss/config/schema.rb +65 -114
  41. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  42. data/lib/html2rss/config/validator.rb +6 -6
  43. data/lib/html2rss/config.rb +176 -19
  44. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  45. data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
  46. data/lib/html2rss/feed_builder/json_feed.rb +61 -0
  47. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  48. data/lib/html2rss/feed_builder/rss.rb +116 -0
  49. data/lib/html2rss/feed_builder.rb +23 -0
  50. data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
  51. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  52. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  53. data/lib/html2rss/feed_pipeline.rb +52 -65
  54. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  55. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  56. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  57. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  59. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  60. data/lib/html2rss/html/article_extractor.rb +134 -0
  61. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  62. data/lib/html2rss/html/navigator.rb +146 -0
  63. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  64. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  65. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  66. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  67. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  68. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering.rb +25 -0
  70. data/lib/html2rss/html.rb +8 -0
  71. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
  74. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  75. data/lib/html2rss/request_service/budget.rb +90 -16
  76. data/lib/html2rss/request_service/context.rb +1 -3
  77. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  78. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  79. data/lib/html2rss/request_service/network_guard.rb +125 -0
  80. data/lib/html2rss/request_service/policy.rb +10 -82
  81. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  82. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  83. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  84. data/lib/html2rss/request_service/response_guard.rb +1 -1
  85. data/lib/html2rss/request_service/strategy.rb +102 -6
  86. data/lib/html2rss/request_service.rb +7 -2
  87. data/lib/html2rss/request_session/pager/base.rb +83 -0
  88. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  89. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  90. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  91. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  92. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  93. data/lib/html2rss/request_session/pager.rb +75 -0
  94. data/lib/html2rss/request_session.rb +25 -16
  95. data/lib/html2rss/selectors/item_scope.rb +30 -0
  96. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  97. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  98. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  99. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  100. data/lib/html2rss/selectors.rb +63 -65
  101. data/lib/html2rss/version.rb +1 -3
  102. data/lib/html2rss.rb +19 -19
  103. data/schema/html2rss-config.schema.json +109 -9
  104. metadata +68 -42
  105. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  106. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  107. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  108. data/lib/html2rss/blocked_surface.rb +0 -65
  109. data/lib/html2rss/category_extractor.rb +0 -116
  110. data/lib/html2rss/config/class_methods.rb +0 -183
  111. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  112. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  113. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  114. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  115. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  116. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  117. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  118. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  119. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  120. data/lib/html2rss/html_extractor.rb +0 -190
  121. data/lib/html2rss/html_navigator.rb +0 -71
  122. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  123. data/lib/html2rss/json_feed_builder.rb +0 -59
  124. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  125. data/lib/html2rss/rendering/description_builder.rb +0 -87
  126. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  127. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  128. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  129. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  130. data/lib/html2rss/rendering.rb +0 -23
  131. data/lib/html2rss/request_controls.rb +0 -133
  132. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  133. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  134. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  135. data/lib/html2rss/rss_builder/article.rb +0 -208
  136. data/lib/html2rss/rss_builder/channel.rb +0 -118
  137. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  138. data/lib/html2rss/rss_builder.rb +0 -101
@@ -1,32 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ipaddr'
4
- require 'resolv'
5
3
  require 'socket'
6
4
 
7
5
  module Html2rss
8
6
  class RequestService
9
7
  ##
10
- # Describes the runtime request envelope for a single feed build.
11
- class Policy # rubocop:disable Metrics/ClassLength
8
+ # Enforcer for the runtime request envelope of a single feed build.
9
+ #
10
+ # Public façade for request limits, same-origin/follow-up rules, and
11
+ # network reachability. DNS/host/IP enforcement lives on {NetworkGuard}.
12
+ class Policy
13
+ # Hard ceiling for configured max_requests during a feed build.
12
14
  MAX_REQUESTS_CEILING = 10
13
- # Hostnames treated as local/private surfaces.
14
- LOCAL_HOSTS = %w[localhost localhost.localdomain metadata.google.internal].to_set.freeze
15
- # IP ranges blocked when private networks are disabled.
16
- BLOCKED_IP_RANGES = [
17
- IPAddr.new('0.0.0.0/8'),
18
- IPAddr.new('10.0.0.0/8'),
19
- IPAddr.new('127.0.0.0/8'),
20
- IPAddr.new('169.254.0.0/16'),
21
- IPAddr.new('172.16.0.0/12'),
22
- IPAddr.new('192.168.0.0/16'),
23
- IPAddr.new('224.0.0.0/4'),
24
- IPAddr.new('::/128'),
25
- IPAddr.new('::1/128'),
26
- IPAddr.new('fe80::/10'),
27
- IPAddr.new('fc00::/7'),
28
- IPAddr.new('ff00::/8')
29
- ].freeze
30
15
 
31
16
  # Default policy values used when request controls are not explicitly set.
32
17
  DEFAULTS = {
@@ -71,7 +56,7 @@ module Html2rss
71
56
  @max_requests = [validate_positive_integer!(:max_requests, max_requests), MAX_REQUESTS_CEILING].min
72
57
  @allow_private_networks = allow_private_networks ? true : false
73
58
  @allow_cross_origin_followups = allow_cross_origin_followups ? true : false
74
- @resolver = resolver
59
+ @network_guard = NetworkGuard.new(allow_private_networks: @allow_private_networks, resolver:)
75
60
  freeze
76
61
  end
77
62
 
@@ -116,7 +101,7 @@ module Html2rss
116
101
  # @raise [PrivateNetworkDenied] if the target resolves to a private address
117
102
  def validate_request!(url:, origin_url:, relation:)
118
103
  enforce_same_origin!(url, origin_url, relation)
119
- enforce_public_network!(url)
104
+ network_guard.enforce_public_network!(url)
120
105
  end
121
106
 
122
107
  ##
@@ -144,19 +129,12 @@ module Html2rss
144
129
  # @return [void]
145
130
  # @raise [PrivateNetworkDenied] if the response came from a blocked address
146
131
  def validate_remote_ip!(ip:, url:)
147
- return if allow_private_networks?
148
- return if ip.nil? || ip.empty?
149
-
150
- parsed_ip = parse_ip(ip)
151
- raise PrivateNetworkDenied, "Remote IP could not be validated for #{url}" unless parsed_ip
152
- return unless blocked_ip?(parsed_ip)
153
-
154
- raise PrivateNetworkDenied, "Private network target denied for #{url}"
132
+ network_guard.validate_remote_ip!(ip:, url:)
155
133
  end
156
134
 
157
135
  private
158
136
 
159
- attr_reader :resolver
137
+ attr_reader :network_guard
160
138
 
161
139
  def validate_positive_integer!(name, value)
162
140
  raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
@@ -194,56 +172,6 @@ module Html2rss
194
172
 
195
173
  url.scheme == 'https' ? 443 : 80
196
174
  end
197
-
198
- def enforce_public_network!(url)
199
- host = url.host
200
- return if allow_private_networks?
201
- return unless blocked_host?(host) || resolved_ip_addresses(host).any? { |address| blocked_ip?(address) }
202
-
203
- raise PrivateNetworkDenied, "Private network target denied for #{url}"
204
- end
205
-
206
- def blocked_host?(host)
207
- LOCAL_HOSTS.include?(host.to_s.downcase)
208
- end
209
-
210
- def resolved_ip_addresses(host)
211
- literal = parse_ip(host)
212
- return [literal] if literal
213
-
214
- if resolver.respond_to?(:each_address)
215
- addresses_from_each_address(host)
216
- else
217
- addresses_from_getaddrinfo(host)
218
- end
219
- rescue Resolv::ResolvError, SocketError, SystemCallError
220
- []
221
- end
222
-
223
- def addresses_from_each_address(host)
224
- [].tap do |addresses|
225
- resolver.each_address(host) do |address|
226
- parsed = parse_ip(address)
227
- addresses << parsed if parsed
228
- end
229
- end
230
- end
231
-
232
- def addresses_from_getaddrinfo(host)
233
- resolver.getaddrinfo(host, nil).filter_map do |entry|
234
- parse_ip(entry[3])
235
- end
236
- end
237
-
238
- def parse_ip(value)
239
- IPAddr.new(value)
240
- rescue IPAddr::AddressFamilyError, IPAddr::InvalidAddressError
241
- nil
242
- end
243
-
244
- def blocked_ip?(address)
245
- BLOCKED_IP_RANGES.any? { |range| range.include?(address) }
246
- end
247
175
  end
248
176
 
249
177
  # Shared immutable policy instance used for default request execution.
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ class PuppetCommander
6
+ ##
7
+ # Owns Puppeteer request interception, deferred navigation errors, main-frame
8
+ # filtering, and redirect-chain validation via {Policy}.
9
+ #
10
+ # Does not re-own Policy rules — only calls +validate_*!+ on +ctx.policy+.
11
+ class NavigationGuards
12
+ class << self
13
+ ##
14
+ # Resolves the final response URL for Response building and IP validation.
15
+ #
16
+ # @param navigation_response [Puppeteer::HTTPResponse, nil]
17
+ # @param fallback_url [String, Html2rss::Url]
18
+ # @return [Html2rss::Url]
19
+ def response_url(navigation_response, fallback_url)
20
+ raw_url = navigation_response&.url || fallback_url.to_s
21
+ Html2rss::Url.from_absolute(raw_url)
22
+ end
23
+ end
24
+
25
+ ##
26
+ # @param ctx [Context] request context providing policy and origin
27
+ # @param skip_request_resources [Set<String>] resource types to abort
28
+ def initialize(ctx:, skip_request_resources:)
29
+ @ctx = ctx
30
+ @skip_request_resources = skip_request_resources
31
+ @navigation_error = nil
32
+ @latest_navigation_response = nil
33
+ @main_frame = nil
34
+ end
35
+
36
+ ##
37
+ # Captures the main frame and wires request/response interceptors.
38
+ #
39
+ # @param page [Puppeteer::Page] browser page
40
+ # @return [void]
41
+ def install!(page)
42
+ @main_frame = page.main_frame if page.respond_to?(:main_frame)
43
+ page.request_interception = true
44
+ page.on('request') { |request| handle_request(request) }
45
+ page.on('response') { |response| handle_response(response) }
46
+ end
47
+
48
+ ##
49
+ # Clears deferred error and latest navigation response before goto.
50
+ #
51
+ # @return [void]
52
+ def begin_navigation!
53
+ @navigation_error = nil
54
+ @latest_navigation_response = nil
55
+ end
56
+
57
+ ##
58
+ # Re-raises a deferred navigation error when one was captured.
59
+ #
60
+ # @return [void]
61
+ # @raise [Html2rss::Error] when a navigation request or response validation failed
62
+ def raise_deferred_error!
63
+ raise @navigation_error if @navigation_error
64
+ end
65
+
66
+ ##
67
+ # @return [Puppeteer::HTTPResponse, nil] latest main-frame navigation response
68
+ attr_reader :latest_navigation_response
69
+
70
+ ##
71
+ # Validates the remote IP of a navigation response via Policy.
72
+ #
73
+ # @param navigation_response [Puppeteer::HTTPResponse, nil]
74
+ # @return [void]
75
+ def validate_final!(navigation_response)
76
+ final_url = self.class.response_url(navigation_response, ctx.url)
77
+ ctx.policy.validate_remote_ip!(ip: remote_ip(navigation_response), url: final_url)
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :ctx, :skip_request_resources, :main_frame
83
+
84
+ def handle_request(request)
85
+ validate_request!(request)
86
+
87
+ skip_request_resources.member?(request.resource_type) ? request.abort : request.continue
88
+ rescue Html2rss::Error => error
89
+ store_navigation_error(error, navigation_request: request.navigation_request?)
90
+ request.abort
91
+ end
92
+
93
+ def handle_response(response)
94
+ @latest_navigation_response = response if main_frame_navigation_response?(response)
95
+ validate_final!(response)
96
+ rescue Html2rss::Error => error
97
+ store_navigation_error(error, navigation_request: response.request.navigation_request?)
98
+ end
99
+
100
+ def validate_request!(request)
101
+ validate_navigation_redirect_chain!(request)
102
+ validate_navigation_target!(request)
103
+ end
104
+
105
+ def main_frame_navigation_response?(response)
106
+ request = response.request
107
+ return false unless request.navigation_request?
108
+ return true unless request.respond_to?(:frame)
109
+
110
+ frame = request.frame
111
+ return true if frame.nil?
112
+ return frame == main_frame unless main_frame.nil?
113
+ return true unless frame.respond_to?(:parent_frame)
114
+
115
+ frame.parent_frame.nil?
116
+ end
117
+
118
+ def remote_ip(navigation_response)
119
+ navigation_response.remote_address&.ip
120
+ end
121
+
122
+ def request_chain(request)
123
+ (request.redirect_chain + [request]).map { |entry| request_url(entry) }
124
+ end
125
+
126
+ def request_url(request)
127
+ Html2rss::Url.from_absolute(request.url)
128
+ end
129
+
130
+ def validate_navigation_redirect_chain!(request)
131
+ request_chain(request).each_cons(2) do |from_url, to_url|
132
+ ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
133
+ end
134
+ end
135
+
136
+ def validate_navigation_target!(request)
137
+ ctx.policy.validate_request!(url: request_url(request), origin_url: ctx.origin_url, relation: ctx.relation)
138
+ end
139
+
140
+ def store_navigation_error(error, navigation_request:)
141
+ return unless navigation_request
142
+
143
+ @navigation_error = error if @navigation_error.nil?
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ class PuppetCommander
6
+ ##
7
+ # Runs Browserless preload choreography: wait, click, and scroll against
8
+ # the interaction budget on {Context#budget}.
9
+ class PreloadRunner
10
+ ##
11
+ # @param ctx [Context] request context providing preload config and budget
12
+ def initialize(ctx:)
13
+ @ctx = ctx
14
+ end
15
+
16
+ ##
17
+ # Executes configured preload actions on the page, if any.
18
+ #
19
+ # @param page [Puppeteer::Page] browser page after initial navigation
20
+ # @return [void]
21
+ def call(page)
22
+ preload_config = ctx.browserless_preload
23
+ return unless preload_config
24
+
25
+ wait_after(page, preload_config[:wait_after_ms])
26
+ click_selectors(page, preload_config[:click_selectors]) if preload_config[:click_selectors]
27
+ scroll_down(page, preload_config[:scroll_down]) if preload_config[:scroll_down]
28
+ wait_after(page, preload_config[:wait_after_ms])
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :ctx
34
+
35
+ def wait_after(page, timeout_ms)
36
+ return unless timeout_ms
37
+
38
+ ctx.budget.consume_interaction!
39
+ page.wait_for_timeout(timeout_ms)
40
+ end
41
+
42
+ def click_selectors(page, selectors)
43
+ selectors.each { |selector_config| click_selector(page, selector_config) }
44
+ end
45
+
46
+ def scroll_down(page, config)
47
+ iterations = config.fetch(:iterations, 1)
48
+ wait_after_ms = config[:wait_after_ms]
49
+ previous_height = nil
50
+
51
+ iterations.times do
52
+ updated_height = perform_scroll_iteration(page, wait_after_ms, previous_height)
53
+ break unless updated_height
54
+
55
+ previous_height = updated_height
56
+ end
57
+ end
58
+
59
+ def click_selector(page, config)
60
+ selector = config.fetch(:selector)
61
+ max_clicks = config.fetch(:max_clicks, 1)
62
+ wait_after_ms = config[:wait_after_ms]
63
+
64
+ max_clicks.times do
65
+ break unless (element = page.query_selector(selector))
66
+
67
+ ctx.budget.consume_interaction!
68
+ element.click
69
+ wait_after(page, wait_after_ms)
70
+ end
71
+ end
72
+
73
+ def perform_scroll_iteration(page, wait_after_ms, previous_height)
74
+ ctx.budget.consume_interaction!
75
+ page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)')
76
+ wait_after(page, wait_after_ms)
77
+
78
+ current_height = page.evaluate('() => document.body.scrollHeight')
79
+ return if previous_height && current_height <= previous_height
80
+
81
+ current_height
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -4,7 +4,11 @@ module Html2rss
4
4
  class RequestService
5
5
  ##
6
6
  # Commands the Puppeteer Browser to the website and builds the Response.
7
- class PuppetCommander # rubocop:disable Metrics/ClassLength
7
+ #
8
+ # Public interface is {#call}; page setup, navigation, and body collection
9
+ # are private orchestration steps.
10
+ class PuppetCommander
11
+ # Request header names that must not be forwarded to the browser session.
8
12
  BROWSER_UNSAFE_HEADERS = %w[
9
13
  host connection content-length transfer-encoding
10
14
  sec-fetch-dest sec-fetch-mode sec-fetch-site sec-fetch-user
@@ -21,8 +25,9 @@ module Html2rss
21
25
  referer: [ctx.url.scheme, ctx.url.host].join('://'))
22
26
  @ctx = ctx
23
27
  @browser = browser
24
- @skip_request_resources = skip_request_resources
25
28
  @referer = referer
29
+ @navigation_guards = NavigationGuards.new(ctx:, skip_request_resources:)
30
+ @preload_runner = PreloadRunner.new(ctx:)
26
31
  end
27
32
 
28
33
  ##
@@ -32,233 +37,59 @@ module Html2rss
32
37
  def call
33
38
  page = new_page
34
39
  navigation_response = navigate_to_destination(page, ctx.url)
35
- perform_preload(page)
36
- raise_navigation_error_if_any
37
- final_navigation_response = latest_navigation_response || navigation_response
38
- validate_navigation_response!(final_navigation_response)
40
+ preload_runner.call(page)
41
+ navigation_guards.raise_deferred_error!
42
+ final_navigation_response = navigation_guards.latest_navigation_response || navigation_response
43
+ navigation_guards.validate_final!(final_navigation_response)
39
44
  build_response(page, final_navigation_response)
40
45
  ensure
41
46
  page&.close
42
47
  end
43
48
 
44
- ##
45
- # @return [Puppeteer::Page]
46
- # @see https://yusukeiwaki.github.io/puppeteer-ruby-docs/Puppeteer/Page.html
49
+ private
50
+
51
+ attr_reader :ctx, :browser, :referer, :navigation_guards, :preload_runner
52
+
47
53
  def new_page
48
54
  page = browser.new_page
49
- @main_frame = page.main_frame if page.respond_to?(:main_frame)
50
55
  configure_page(page)
51
- configure_navigation_guards(page)
56
+ navigation_guards.install!(page)
52
57
  page
53
58
  end
54
59
 
55
- ##
56
- # @param page [Puppeteer::Page]
57
- # @return [void]
58
60
  def configure_page(page)
59
61
  page.extra_http_headers = browser_headers
60
62
  page.default_navigation_timeout = navigation_timeout_ms
61
63
  page.default_timeout = navigation_timeout_ms
62
64
  end
63
65
 
64
- ##
65
- # @param page [Puppeteer::Page]
66
- # @return [void]
67
- def configure_navigation_guards(page)
68
- page.request_interception = true
69
- page.on('request') do |request|
70
- handle_request(request)
71
- end
72
- page.on('response') { |response| handle_response(response) }
73
- end
74
-
75
- ##
76
- # @param page [Puppeteer::Page] browser page
77
- # @param url [Html2rss::Url] target URL
78
- # @return [Puppeteer::HTTPResponse, nil] the navigation response if one was produced
79
66
  def navigate_to_destination(page, url)
80
- @navigation_error = nil
81
- @latest_navigation_response = nil
67
+ navigation_guards.begin_navigation!
82
68
  page.goto(url, wait_until: 'networkidle0', referer:, timeout: navigation_timeout_ms).tap do
83
- raise_navigation_error_if_any
69
+ navigation_guards.raise_deferred_error!
84
70
  end
85
71
  rescue StandardError
86
- raise_navigation_error_if_any
72
+ navigation_guards.raise_deferred_error!
87
73
 
88
74
  raise
89
75
  end
90
76
 
91
- ##
92
- # @param page [Puppeteer::Page] browser page
93
- # @return [String] rendered HTML content
94
- def body(page) = page.content
95
-
96
- private
97
-
98
- attr_reader :ctx, :browser, :skip_request_resources, :referer, :latest_navigation_response, :main_frame
99
-
100
- ##
101
- # Re-raises a deferred navigation error when one was captured.
102
- #
103
- # @raise [Html2rss::Error] when a navigation request or response validation failed
104
- def raise_navigation_error_if_any
105
- raise @navigation_error if @navigation_error
106
- end
107
-
108
77
  def navigation_timeout_ms
109
- ctx.policy.total_timeout_seconds * 1000
78
+ ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
110
79
  end
111
80
 
112
81
  def browser_headers
113
82
  ctx.headers.reject { |key, _| BROWSER_UNSAFE_HEADERS.include?(key.to_s.downcase) }
114
83
  end
115
84
 
116
- def handle_request(request)
117
- validate_request!(request)
118
-
119
- skip_request_resources.member?(request.resource_type) ? request.abort : request.continue
120
- rescue Html2rss::Error => error
121
- store_navigation_error(error, navigation_request: request.navigation_request?)
122
- request.abort
123
- end
124
-
125
- def handle_response(response)
126
- @latest_navigation_response = response if main_frame_navigation_response?(response)
127
- validate_response!(response)
128
- rescue Html2rss::Error => error
129
- store_navigation_error(error, navigation_request: response.request.navigation_request?)
130
- end
131
-
132
- def validate_request!(request)
133
- validate_navigation_redirect_chain!(request)
134
- validate_navigation_target!(request)
135
- end
136
-
137
- def main_frame_navigation_response?(response)
138
- request = response.request
139
- return false unless request.navigation_request?
140
- return true unless request.respond_to?(:frame)
141
-
142
- frame = request.frame
143
- return true if frame.nil?
144
- return frame == main_frame unless main_frame.nil?
145
- return true unless frame.respond_to?(:parent_frame)
146
-
147
- frame.parent_frame.nil?
148
- end
149
-
150
85
  def build_response(page, navigation_response)
151
- page_body = body(page)
152
- ResponseGuard.new(policy: ctx.policy).inspect_body!(page_body)
153
-
154
86
  Response.new(
155
- body: page_body,
87
+ body: page.content,
156
88
  headers: navigation_response&.headers || {},
157
- url: response_url(navigation_response, ctx.url),
89
+ url: NavigationGuards.response_url(navigation_response, ctx.url),
158
90
  status: navigation_response&.status
159
91
  )
160
92
  end
161
-
162
- def validate_navigation_response!(navigation_response)
163
- final_url = response_url(navigation_response, ctx.url)
164
- ctx.policy.validate_remote_ip!(ip: remote_ip(navigation_response), url: final_url)
165
- end
166
-
167
- def validate_response!(response)
168
- validate_navigation_response!(response)
169
- end
170
-
171
- def response_url(navigation_response, fallback_url)
172
- raw_url = navigation_response&.url || fallback_url.to_s
173
- Html2rss::Url.from_absolute(raw_url)
174
- end
175
-
176
- def remote_ip(navigation_response)
177
- navigation_response.remote_address&.ip
178
- end
179
-
180
- def request_chain(request)
181
- (request.redirect_chain + [request]).map { |entry| request_url(entry) }
182
- end
183
-
184
- def request_url(request)
185
- Html2rss::Url.from_absolute(request.url)
186
- end
187
-
188
- def validate_navigation_redirect_chain!(request)
189
- request_chain(request).each_cons(2) do |from_url, to_url|
190
- ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
191
- end
192
- end
193
-
194
- def validate_navigation_target!(request)
195
- ctx.policy.validate_request!(url: request_url(request), origin_url: ctx.origin_url, relation: ctx.relation)
196
- end
197
-
198
- def store_navigation_error(error, navigation_request:)
199
- return unless navigation_request
200
-
201
- @navigation_error = error if @navigation_error.nil?
202
- end
203
-
204
- def perform_preload(page)
205
- preload_config = ctx.browserless_preload
206
- return unless preload_config
207
-
208
- wait_after(page, preload_config[:wait_after_ms])
209
- click_selectors(page, preload_config[:click_selectors]) if preload_config[:click_selectors]
210
- scroll_down(page, preload_config[:scroll_down]) if preload_config[:scroll_down]
211
- wait_after(page, preload_config[:wait_after_ms])
212
- end
213
-
214
- def wait_after(page, timeout_ms)
215
- return unless timeout_ms
216
-
217
- ctx.budget.consume!
218
- page.wait_for_timeout(timeout_ms)
219
- end
220
-
221
- def click_selectors(page, selectors)
222
- selectors.each { |selector_config| click_selector(page, selector_config) }
223
- end
224
-
225
- def scroll_down(page, config)
226
- iterations = config.fetch(:iterations, 1)
227
- wait_after_ms = config[:wait_after_ms]
228
- previous_height = nil
229
-
230
- iterations.times do
231
- updated_height = perform_scroll_iteration(page, wait_after_ms, previous_height)
232
- break unless updated_height
233
-
234
- previous_height = updated_height
235
- end
236
- end
237
-
238
- def click_selector(page, config)
239
- selector = config.fetch(:selector)
240
- max_clicks = config.fetch(:max_clicks, 1)
241
- wait_after_ms = config[:wait_after_ms]
242
-
243
- max_clicks.times do
244
- break unless (element = page.query_selector(selector))
245
-
246
- ctx.budget.consume!
247
- element.click
248
- wait_after(page, wait_after_ms)
249
- end
250
- end
251
-
252
- def perform_scroll_iteration(page, wait_after_ms, previous_height)
253
- ctx.budget.consume!
254
- page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)')
255
- wait_after(page, wait_after_ms)
256
-
257
- current_height = page.evaluate('() => document.body.scrollHeight')
258
- return if previous_height && current_height <= previous_height
259
-
260
- current_height
261
- end
262
93
  end
263
94
  end
264
95
  end
@@ -46,7 +46,7 @@ module Html2rss
46
46
  attr_reader :policy
47
47
 
48
48
  def raise_if_blocked_surface!(body)
49
- signature = Html2rss::BlockedSurface.interstitial_signature_for(body)
49
+ signature = BlockedSurface.interstitial_signature_for(body)
50
50
  return unless signature
51
51
 
52
52
  raise BlockedSurfaceDetected, signature.fetch(:message)