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.
Files changed (146) 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 +213 -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/category_extractor.rb +29 -114
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
  29. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  30. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  32. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  33. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  35. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  36. data/lib/html2rss/auto_source/scraper.rb +10 -3
  37. data/lib/html2rss/auto_source.rb +30 -35
  38. data/lib/html2rss/channel.rb +198 -0
  39. data/lib/html2rss/cli.rb +5 -6
  40. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  41. data/lib/html2rss/config/request_controls.rb +136 -0
  42. data/lib/html2rss/config/request_headers.rb +1 -1
  43. data/lib/html2rss/config/schema.rb +65 -114
  44. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  45. data/lib/html2rss/config/validator.rb +6 -6
  46. data/lib/html2rss/config.rb +176 -19
  47. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  48. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  49. data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
  50. data/lib/html2rss/feed_builder/json_feed.rb +81 -0
  51. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  52. data/lib/html2rss/feed_builder/rss.rb +116 -0
  53. data/lib/html2rss/feed_builder.rb +15 -0
  54. data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
  55. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  56. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  57. data/lib/html2rss/feed_pipeline.rb +83 -89
  58. data/lib/html2rss/feed_result.rb +92 -0
  59. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  60. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  61. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  62. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  63. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  64. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  65. data/lib/html2rss/html/article_extractor.rb +134 -0
  66. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  67. data/lib/html2rss/html/navigator.rb +146 -0
  68. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  70. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  71. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  72. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  73. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  74. data/lib/html2rss/html/rendering.rb +25 -0
  75. data/lib/html2rss/html.rb +8 -0
  76. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  77. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
  78. data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
  79. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  80. data/lib/html2rss/request_service/budget.rb +97 -18
  81. data/lib/html2rss/request_service/context.rb +1 -3
  82. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  83. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  84. data/lib/html2rss/request_service/network_guard.rb +125 -0
  85. data/lib/html2rss/request_service/policy.rb +10 -82
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  88. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  89. data/lib/html2rss/request_service/response.rb +9 -1
  90. data/lib/html2rss/request_service/response_guard.rb +1 -1
  91. data/lib/html2rss/request_service/strategy.rb +123 -5
  92. data/lib/html2rss/request_service.rb +7 -2
  93. data/lib/html2rss/request_session/pager/base.rb +83 -0
  94. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  95. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  96. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  97. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  98. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  99. data/lib/html2rss/request_session/pager.rb +75 -0
  100. data/lib/html2rss/request_session.rb +29 -16
  101. data/lib/html2rss/selectors/item_scope.rb +30 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  104. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  105. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  106. data/lib/html2rss/selectors.rb +84 -64
  107. data/lib/html2rss/status.rb +132 -0
  108. data/lib/html2rss/version.rb +1 -3
  109. data/lib/html2rss.rb +34 -22
  110. data/schema/html2rss-config.schema.json +109 -9
  111. metadata +71 -43
  112. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  113. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  114. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
  115. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  116. data/lib/html2rss/blocked_surface.rb +0 -65
  117. data/lib/html2rss/category_extractor.rb +0 -116
  118. data/lib/html2rss/config/class_methods.rb +0 -183
  119. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  120. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  121. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  122. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  123. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  124. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  125. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  126. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  127. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  128. data/lib/html2rss/html_extractor.rb +0 -190
  129. data/lib/html2rss/html_navigator.rb +0 -71
  130. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  131. data/lib/html2rss/json_feed_builder.rb +0 -59
  132. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  133. data/lib/html2rss/rendering/description_builder.rb +0 -87
  134. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  135. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  136. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  137. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  138. data/lib/html2rss/rendering.rb +0 -23
  139. data/lib/html2rss/request_controls.rb +0 -133
  140. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  141. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  142. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  143. data/lib/html2rss/rss_builder/article.rb +0 -208
  144. data/lib/html2rss/rss_builder/channel.rb +0 -118
  145. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  146. 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
- 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
@@ -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
- def initialize(body:, url:, headers: {}, status: nil)
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 = 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)
@@ -3,7 +3,10 @@
3
3
  module Html2rss
4
4
  class RequestService
5
5
  ##
6
- # Defines the interface of every request strategy.
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 method is not implemented by the subclass
21
+ # @raise [NotImplementedError] if the subclass does not implement `#fetch`
18
22
  def execute
19
- raise NotImplementedError, 'Subclass must implement #execute'
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
- raise RequestTimedOut, 'Request timed out' if remaining && remaining <= 0
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, e.g. integrating Browserless.io.
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