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.
- checksums.yaml +4 -4
- data/README.md +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +206 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +118 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
- data/lib/html2rss/feed_builder/json_feed.rb +61 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +23 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +52 -65
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
- data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +90 -16
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +102 -6
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +25 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +63 -65
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +19 -19
- data/schema/html2rss-config.schema.json +109 -9
- metadata +68 -42
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- 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
|
-
#
|
|
11
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
final_navigation_response = latest_navigation_response || navigation_response
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
navigation_guards.raise_deferred_error!
|
|
84
70
|
end
|
|
85
71
|
rescue StandardError
|
|
86
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
49
|
+
signature = BlockedSurface.interstitial_signature_for(body)
|
|
50
50
|
return unless signature
|
|
51
51
|
|
|
52
52
|
raise BlockedSurfaceDetected, signature.fetch(:message)
|