html2rss 0.22.2 → 0.24.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 +213 -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/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- 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 +198 -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/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -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 +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- 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 +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- 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 +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- 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.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- 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 +29 -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 +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- 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/schema/list_item.rb +0 -28
- 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
|
@@ -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
|
|
@@ -7,12 +7,16 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# To be used by strategies to provide their response.
|
|
9
9
|
class Response
|
|
10
|
+
# Default when a strategy does not attach transport telemetry.
|
|
11
|
+
EMPTY_TRANSPORT_META = {}.freeze
|
|
12
|
+
|
|
10
13
|
##
|
|
11
14
|
# @param body [String] the body of the response
|
|
12
15
|
# @param url [Html2rss::Url] the final request URL
|
|
13
16
|
# @param headers [Hash] the headers of the response
|
|
14
17
|
# @param status [Integer, nil] the HTTP status code when available
|
|
15
|
-
|
|
18
|
+
# @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
|
|
19
|
+
def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META)
|
|
16
20
|
@body = body
|
|
17
21
|
|
|
18
22
|
headers = headers.dup
|
|
@@ -22,6 +26,7 @@ module Html2rss
|
|
|
22
26
|
@headers = headers
|
|
23
27
|
@status = status
|
|
24
28
|
@url = url
|
|
29
|
+
@transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
# @return [String] the raw body of the response
|
|
@@ -36,6 +41,9 @@ module Html2rss
|
|
|
36
41
|
# @return [Html2rss::Url] the URL of the response
|
|
37
42
|
attr_reader :url
|
|
38
43
|
|
|
44
|
+
# @return [Hash] allowlisted upstream transport telemetry
|
|
45
|
+
attr_reader :transport_meta
|
|
46
|
+
|
|
39
47
|
# @return [String] normalized content type header value
|
|
40
48
|
def content_type = header('content-type').to_s
|
|
41
49
|
|
|
@@ -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)
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
class RequestService
|
|
5
5
|
##
|
|
6
|
-
# Defines the interface
|
|
6
|
+
# Defines the guarded request interface for every strategy adapter.
|
|
7
|
+
#
|
|
8
|
+
# `#execute` always runs timeout check → budget/policy preflight → `#fetch`
|
|
9
|
+
# → ResponseGuard postflight so new adapters cannot skip shared controls.
|
|
7
10
|
class Strategy
|
|
8
11
|
##
|
|
9
12
|
# @param ctx [Context] the context for the request
|
|
@@ -12,11 +15,17 @@ module Html2rss
|
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
##
|
|
15
|
-
# Executes the request.
|
|
18
|
+
# Executes the request through the shared preflight/postflight path.
|
|
19
|
+
#
|
|
16
20
|
# @return [Response] the response from the strategy
|
|
17
|
-
# @raise [NotImplementedError] if the
|
|
21
|
+
# @raise [NotImplementedError] if the subclass does not implement `#fetch`
|
|
18
22
|
def execute
|
|
19
|
-
|
|
23
|
+
check_timeout!
|
|
24
|
+
response = perform_execute
|
|
25
|
+
postflight!(response, response_guard:)
|
|
26
|
+
response
|
|
27
|
+
rescue StandardError => error
|
|
28
|
+
handle_error(error)
|
|
20
29
|
end
|
|
21
30
|
|
|
22
31
|
private
|
|
@@ -24,9 +33,118 @@ module Html2rss
|
|
|
24
33
|
# @return [Context] the context for the request
|
|
25
34
|
attr_reader :ctx
|
|
26
35
|
|
|
36
|
+
##
|
|
37
|
+
# Adapter hook: perform the preflight, fetch, and optional retry lifecycle.
|
|
38
|
+
#
|
|
39
|
+
# @return [Response] normalized response
|
|
40
|
+
def perform_execute
|
|
41
|
+
preflight!
|
|
42
|
+
fetch
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# Adapter hook: perform the transport-specific fetch after preflight.
|
|
47
|
+
#
|
|
48
|
+
# @return [Response] normalized response
|
|
49
|
+
# @raise [NotImplementedError] when a subclass omits the hook
|
|
50
|
+
def fetch
|
|
51
|
+
raise NotImplementedError, 'Subclass must implement #fetch'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# Consumes a request slot and validates the destination before transport work.
|
|
56
|
+
#
|
|
57
|
+
# @param consume_budget [Boolean] whether to decrement the request budget
|
|
58
|
+
# @return [void]
|
|
59
|
+
def preflight!(consume_budget: true)
|
|
60
|
+
return if skip_preflight?
|
|
61
|
+
|
|
62
|
+
ctx.budget.consume! if consume_budget
|
|
63
|
+
ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# Enforces response-size and blocked-surface checks after transport work.
|
|
68
|
+
#
|
|
69
|
+
# @param response [Response, nil] adapter response
|
|
70
|
+
# @param response_guard [ResponseGuard, nil] existing guard to reuse (e.g. streaming)
|
|
71
|
+
# @return [void]
|
|
72
|
+
def postflight!(response, response_guard: nil)
|
|
73
|
+
return if skip_postflight? || response.nil?
|
|
74
|
+
|
|
75
|
+
guard = response_guard || ResponseGuard.new(policy: ctx.policy)
|
|
76
|
+
guard.inspect_body!(response.body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# @return [Boolean] true when remote budget/policy preflight must be skipped
|
|
81
|
+
def skip_preflight? = false
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# @return [Boolean] true when ResponseGuard postflight must be skipped
|
|
85
|
+
def skip_postflight? = false
|
|
86
|
+
|
|
27
87
|
def check_timeout!
|
|
88
|
+
ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
89
|
+
rescue RequestTimedOut
|
|
90
|
+
log_timeout!(reason: 'budget_exhausted')
|
|
91
|
+
raise
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [ResponseGuard, nil]
|
|
95
|
+
def response_guard = nil
|
|
96
|
+
|
|
97
|
+
# @param error [StandardError]
|
|
98
|
+
# @return [void]
|
|
99
|
+
# @raise [StandardError]
|
|
100
|
+
def handle_error(error)
|
|
101
|
+
if timeout_error?(error)
|
|
102
|
+
log_timeout!(reason: 'transport')
|
|
103
|
+
Log.debug("#{self.class}: transport timeout message=#{error.message}")
|
|
104
|
+
raise RequestTimedOut, error.message
|
|
105
|
+
elsif connection_error?(error)
|
|
106
|
+
translate_connection_error(error)
|
|
107
|
+
else
|
|
108
|
+
raise error
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @param reason [String] timeout classification (budget_exhausted / transport)
|
|
113
|
+
# @return [void]
|
|
114
|
+
# rubocop:disable Metrics/AbcSize -- structured timeout fields stay in one log line
|
|
115
|
+
def log_timeout!(reason:)
|
|
28
116
|
remaining = ctx.budget.remaining_timeout_seconds
|
|
29
|
-
|
|
117
|
+
remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
|
|
118
|
+
detail = [
|
|
119
|
+
"strategy=#{self.class.name}",
|
|
120
|
+
"host=#{ctx.url.host}",
|
|
121
|
+
"elapsed=#{format('%.3f', ctx.budget.elapsed_seconds)}s",
|
|
122
|
+
"budget_remaining=#{remaining_label}",
|
|
123
|
+
"reason=#{reason}"
|
|
124
|
+
]
|
|
125
|
+
Log.info("#{self.class}: request timeout #{detail.join(' ')}")
|
|
126
|
+
end
|
|
127
|
+
# rubocop:enable Metrics/AbcSize
|
|
128
|
+
|
|
129
|
+
# @param error [StandardError]
|
|
130
|
+
# @return [void]
|
|
131
|
+
# @raise [StandardError]
|
|
132
|
+
def translate_connection_error(error)
|
|
133
|
+
raise error
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @param error [StandardError]
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def timeout_error?(error)
|
|
139
|
+
error.is_a?(Faraday::TimeoutError) ||
|
|
140
|
+
error.is_a?(Timeout::Error) ||
|
|
141
|
+
(defined?(Puppeteer::TimeoutError) && error.is_a?(Puppeteer::TimeoutError))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param error [StandardError]
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def connection_error?(error)
|
|
147
|
+
error.is_a?(Faraday::ConnectionFailed) || error.is_a?(Faraday::SSLError)
|
|
30
148
|
end
|
|
31
149
|
end
|
|
32
150
|
end
|
|
@@ -6,7 +6,10 @@ require 'forwardable'
|
|
|
6
6
|
module Html2rss
|
|
7
7
|
##
|
|
8
8
|
# Requests website URLs to retrieve their HTML for further processing.
|
|
9
|
-
# Provides strategies
|
|
9
|
+
# Provides concrete transport strategies (e.g. Faraday, Browserless).
|
|
10
|
+
#
|
|
11
|
+
# Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
|
|
12
|
+
# it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
|
|
10
13
|
class RequestService
|
|
11
14
|
include Singleton
|
|
12
15
|
|
|
@@ -18,8 +21,10 @@ module Html2rss
|
|
|
18
21
|
class UnsupportedUrlScheme < Html2rss::Error; end
|
|
19
22
|
# Raised when a response type cannot be parsed.
|
|
20
23
|
class UnsupportedResponseContentType < Html2rss::Error; end
|
|
21
|
-
# Raised when request limits are exceeded.
|
|
24
|
+
# Raised when HTTP request slot limits are exceeded.
|
|
22
25
|
class RequestBudgetExceeded < Html2rss::Error; end
|
|
26
|
+
# Raised when Browserless preload interaction limits are exceeded.
|
|
27
|
+
class InteractionBudgetExceeded < Html2rss::Error; end
|
|
23
28
|
# Raised when policy denies private-network access.
|
|
24
29
|
class PrivateNetworkDenied < Html2rss::Error; end
|
|
25
30
|
# Raised when cross-origin follow-up requests are denied.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestSession
|
|
5
|
+
module Pager
|
|
6
|
+
##
|
|
7
|
+
# Abstract base class for pagination strategies.
|
|
8
|
+
class Base
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
# Default maximum number of pages to fetch if omitted.
|
|
12
|
+
# @return [Integer]
|
|
13
|
+
DEFAULT_MAX_PAGES = 5
|
|
14
|
+
|
|
15
|
+
# @param session [RequestSession] request session used to execute follow-ups
|
|
16
|
+
# @param initial_response [RequestService::Response] first page response
|
|
17
|
+
# @param config [Hash, Integer] pagination configuration or max_pages integer
|
|
18
|
+
# @param logger [Logger] logger used for pagination stop warnings
|
|
19
|
+
def initialize(session:, initial_response:, config: {}, logger: Html2rss::Log)
|
|
20
|
+
@session = session
|
|
21
|
+
@initial_response = initial_response
|
|
22
|
+
@config = config.is_a?(Hash) ? config : { max_pages: config }
|
|
23
|
+
@logger = logger
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @yield [RequestService::Response] each page response
|
|
27
|
+
# @return [Enumerator] enumerator when no block is given
|
|
28
|
+
def each
|
|
29
|
+
return enum_for(:each) unless block_given?
|
|
30
|
+
|
|
31
|
+
yield initial_response
|
|
32
|
+
|
|
33
|
+
current_response = initial_response
|
|
34
|
+
session.effective_page_budget(max_pages).pred.times do |index|
|
|
35
|
+
next_url = next_page_url(current_response, page_number: index + 2)
|
|
36
|
+
break unless follow_up_allowed?(next_url)
|
|
37
|
+
|
|
38
|
+
current_response = fetch_follow_up_response_or_stop(next_url, current_response.url)
|
|
39
|
+
break unless current_response
|
|
40
|
+
|
|
41
|
+
yield current_response
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :session, :initial_response, :config, :logger
|
|
48
|
+
|
|
49
|
+
# @return [Integer] configured maximum pages (always a positive Integer)
|
|
50
|
+
def max_pages
|
|
51
|
+
value = config.fetch(:max_pages, DEFAULT_MAX_PAGES)
|
|
52
|
+
value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_PAGES
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param next_url [Html2rss::Url, String, nil]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def follow_up_allowed?(next_url)
|
|
58
|
+
!next_url.nil? && !next_url.to_s.empty? && !session.visited?(next_url)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param next_url [Html2rss::Url, String]
|
|
62
|
+
# @param origin_url [Html2rss::Url, String]
|
|
63
|
+
# @return [RequestService::Response, nil]
|
|
64
|
+
def fetch_follow_up_response_or_stop(next_url, origin_url)
|
|
65
|
+
session.follow_up(url: next_url, relation: :pagination, origin_url:)
|
|
66
|
+
rescue RequestService::RequestBudgetExceeded => error
|
|
67
|
+
logger.warn(
|
|
68
|
+
"#{self.class}: pagination stopped at #{next_url} - #{error.message}. " \
|
|
69
|
+
"Retry with --max-requests #{session.max_requests + 1} or increase request.max_requests in the config."
|
|
70
|
+
)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param page_response [RequestService::Response]
|
|
75
|
+
# @param page_number [Integer] 1-based target page number (e.g. 2 for second page)
|
|
76
|
+
# @return [Html2rss::Url, String, nil]
|
|
77
|
+
def next_page_url(page_response, page_number:)
|
|
78
|
+
raise NotImplementedError, "#{self.class}#next_page_url must be implemented by subclass"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|