html2rss 0.25.0 → 0.27.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -5,35 +5,40 @@ require 'json'
5
5
  module Html2rss
6
6
  class RequestService
7
7
  ##
8
- # Maps html2rss request/response handling to the botasaurus-scrape-api contract.
9
- class BotasaurusContract
10
- # Default Botasaurus scrape options when no explicit config is provided.
11
- DEFAULT_OPTIONS = {
12
- navigation_mode: 'auto',
13
- max_retries: 1,
14
- headless: false
15
- }.freeze
16
-
17
- # Allowlisted request.botasaurus keys forwarded to upstream.
18
- OPTION_KEYS = %i[
19
- navigation_mode
20
- max_retries
21
- wait_for_selector
22
- wait_timeout_seconds
23
- block_images
24
- block_images_and_css
25
- wait_for_complete_page_load
26
- headless
27
- proxy
28
- user_agent
29
- window_size
30
- lang
8
+ # Maps html2rss request/response handling to botasaurus-scrape-api OpenAPI 2.0
9
+ # +ScrapeRequest+ / +ScrapeSuccess+ / +ScrapeError+ (sibling +openapi.yaml+).
10
+ class BotasaurusContract # rubocop:disable Metrics/ClassLength -- Success/Error envelopes stay with the owner
11
+ # Closed set from OpenAPI ExecutionMode.
12
+ EXECUTION_MODES = %w[auto request browser].freeze
13
+ # Closed set from OpenAPI NavigationMode.
14
+ NAVIGATION_MODES = %w[auto get google_get google_get_bypass organic_get].freeze
15
+ # Closed set from OpenAPI ErrorCategory.
16
+ ERROR_CATEGORIES = %w[timeout challenge_block navigation_error metadata_error validation].freeze
17
+
18
+ # ScrapeRequest properties except +url+ (html2rss supplies the target URL).
19
+ REQUEST_OPTION_KEYS = %i[
20
+ execution_mode navigation_mode max_retries wait_for_selector wait_timeout_seconds
21
+ scroll block_images block_images_and_css block_trackers
22
+ wait_for_complete_page_load user_agent headers cookies window_size lang headless proxy
31
23
  ].freeze
32
24
 
33
- # Allowlisted upstream response keys exposed as Response#transport_meta.
34
- META_KEYS = %w[
35
- request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
36
- ].freeze
25
+ # OpenAPI WindowSize required keys (positive integers).
26
+ WINDOW_SIZE_PROPERTIES = %i[width height].freeze
27
+
28
+ # Published wait clamp floor (`[1, 20]` in OpenAPI description).
29
+ MIN_WAIT_TIMEOUT_SECONDS = 1
30
+ # Published wait clamp ceiling; YAML values above this are rejected.
31
+ MAX_WAIT_TIMEOUT_SECONDS = 20
32
+ # OpenAPI wait_timeout_seconds default (omitted from the client payload).
33
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 15
34
+
35
+ # OpenAPI max_retries maximum (default 2 is applied upstream when omitted).
36
+ MAX_RETRIES = 3
37
+
38
+ # Allowlisted ScrapeDiagnostics keys nested under diagnostics.
39
+ DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge].freeze
40
+ # Allowlisted ChallengeSignal keys nested under diagnostics.challenge.
41
+ CHALLENGE_KEYS = %w[blocked detected marker].freeze
37
42
 
38
43
  # Remaining seconds at or below which Botasaurus retries are disabled.
39
44
  TIGHT_BUDGET_SECONDS = 12
@@ -41,145 +46,311 @@ module Html2rss
41
46
  # Seconds reserved from remaining budget before setting wait_timeout_seconds.
42
47
  BUDGET_WAIT_RESERVE_SECONDS = 2
43
48
 
44
- # Parsed Botasaurus response wrapper.
45
- class ParsedResponse
46
- # Fallback headers when upstream omits response headers.
47
- DEFAULT_HEADERS = { 'content-type' => 'text/html' }.freeze
49
+ ##
50
+ # Parsed OpenAPI ScrapeSuccess envelope (HTTP 200).
51
+ class Success
52
+ # Per-body size cap for captured XHR JSON (defense-in-depth vs upstream).
53
+ MAX_XHR_BODY_BYTES = 500_000
54
+ # Aggregate size cap across all captured XHR bodies.
55
+ MAX_XHR_AGGREGATE_BYTES = 2_000_000
56
+
57
+ # @param payload [Hash{String => Object}] parsed ScrapeSuccess object
58
+ def initialize(payload:)
59
+ @payload = payload
60
+ html
61
+ diagnostics
62
+ end
63
+
64
+ # @return [String] rendered HTML body
65
+ def html
66
+ value = payload['html']
67
+ raise BotasaurusServiceError, 'Botasaurus scrape success requires html' unless value.is_a?(String)
68
+
69
+ value
70
+ end
71
+
72
+ # @return [Hash{String => String}] normalized response headers (null → {})
73
+ def headers
74
+ raw_headers = payload['headers']
75
+ return {} unless raw_headers.is_a?(Hash) && raw_headers.any?
76
+
77
+ raw_headers.to_h { |key, value| [key.to_s, value.to_s] }
78
+ end
79
+
80
+ # @return [Integer, nil] document status_code when present
81
+ def status
82
+ status_code = payload['status_code']
83
+ status_code.is_a?(Integer) ? status_code : nil
84
+ end
85
+
86
+ # @return [String, nil] final URL reported by upstream
87
+ def final_url = payload['final_url']
88
+
89
+ # @return [Hash{String => Object}] diagnostics plus success-only metadata_error (frozen)
90
+ def transport_meta
91
+ meta = diagnostics.dup
92
+ meta['metadata_error'] = metadata_error if metadata_error
93
+ meta.freeze
94
+ end
95
+
96
+ # @return [Array<Hash{String => Object}>] size-capped XHR/fetch captures (absent → [])
97
+ def xhr_responses
98
+ raw = payload['xhr_responses']
99
+ return [] unless raw.is_a?(Array)
100
+
101
+ aggregate_bytes = 0
102
+ raw.filter_map do |entry|
103
+ normalized = normalize_xhr_entry(entry)
104
+ next unless normalized
105
+ next unless (aggregate_bytes = advance_xhr_budget(normalized, aggregate_bytes))
106
+
107
+ normalized
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ attr_reader :payload
114
+
115
+ def diagnostics = BotasaurusContract.diagnostics_from(payload)
116
+
117
+ def metadata_error
118
+ value = payload['metadata_error']
119
+ value.is_a?(String) && !value.empty? ? value : nil
120
+ end
121
+
122
+ def normalize_xhr_entry(entry)
123
+ return unless entry.is_a?(Hash)
124
+
125
+ body = xhr_field(entry, :body)
126
+ return unless body.is_a?(String) && !body.empty?
127
+
128
+ {
129
+ 'url' => xhr_field(entry, :url).to_s,
130
+ 'body' => body,
131
+ 'headers' => xhr_headers(xhr_field(entry, :headers)),
132
+ 'status_code' => xhr_status_code(xhr_field(entry, :status_code))
133
+ }
134
+ end
135
+
136
+ # @return [Integer, nil] next aggregate byte total when accepted
137
+ def advance_xhr_budget(normalized, aggregate_bytes)
138
+ body_bytes = normalized.fetch('body').bytesize
139
+ return if body_bytes > MAX_XHR_BODY_BYTES
140
+ return if aggregate_bytes + body_bytes > MAX_XHR_AGGREGATE_BYTES
141
+
142
+ aggregate_bytes + body_bytes
143
+ end
144
+
145
+ def xhr_field(entry, key)
146
+ entry[key.to_s] || entry[key]
147
+ end
148
+
149
+ def xhr_headers(raw)
150
+ return {} unless raw.is_a?(Hash)
48
151
 
49
- # @param payload [Hash{String => Object}] parsed Botasaurus response payload
152
+ raw.to_h { |key, value| [key.to_s, value.to_s] }
153
+ end
154
+
155
+ def xhr_status_code(value)
156
+ value.is_a?(Integer) ? value : nil
157
+ end
158
+ end
159
+
160
+ ##
161
+ # Parsed OpenAPI ScrapeError envelope (HTTP 400/403/422/502/504).
162
+ class Error
163
+ # @param payload [Hash{String => Object}] parsed ScrapeError object
50
164
  # @param transport_status [Integer] HTTP status returned by Botasaurus
51
165
  def initialize(payload:, transport_status:)
52
166
  @payload = payload
53
167
  @transport_status = transport_status
168
+ error
169
+ error_category
170
+ diagnostics
54
171
  end
55
172
 
56
173
  # @return [Boolean] true when upstream classified request as challenge blocked
57
174
  def challenge_block? = error_category == 'challenge_block'
58
175
 
59
- # @return [Boolean] true when upstream returned non-200 or an error payload
60
- def upstream_failure?
61
- status != 200 || error_message?
176
+ # @return [Boolean] true when the scrape envelope reports a timeout
177
+ def timeout?
178
+ transport_status == 504 || error_category == 'timeout'
62
179
  end
63
180
 
64
181
  # @return [String] normalized challenge error message
65
182
  def challenge_message
66
- error || 'Botasaurus challenge block detected.'
183
+ [error, challenge_marker].find { |value| value.is_a?(String) && !value.empty? } ||
184
+ 'Botasaurus challenge block detected.'
67
185
  end
68
186
 
69
187
  # @return [String] actionable upstream failure summary
70
- def upstream_failure_message
71
- details = ["status=#{status}"]
72
- details << "error_category=#{error_category}" if error_category
73
- details << "error=#{error}" if error
188
+ def failure_message
189
+ details = ["status=#{transport_status}", "error_category=#{error_category}", "error=#{error}"]
74
190
  details << "request_id=#{request_id}" if request_id
75
191
  "Botasaurus scrape failed (#{details.join(', ')})."
76
192
  end
77
193
 
78
- # @return [String] rendered HTML body from Botasaurus
79
- # @raise [BotasaurusConnectionFailed] when html is missing
80
- def html
81
- value = payload['html']
82
- raise BotasaurusConnectionFailed, "Botasaurus response missing required 'html' field" if value.nil?
83
-
84
- value.to_s
85
- end
194
+ private
86
195
 
87
- # @return [Hash{String => String}] normalized response headers
88
- def headers
89
- raw_headers = payload['headers']
90
- return DEFAULT_HEADERS.dup unless raw_headers.is_a?(Hash) && raw_headers.any?
196
+ attr_reader :payload, :transport_status
91
197
 
92
- raw_headers.to_h { |key, value| [key.to_s, value.to_s] }
93
- end
198
+ def error
199
+ value = payload['error']
200
+ raise BotasaurusServiceError, 'Botasaurus scrape error requires error' unless value.is_a?(String)
94
201
 
95
- # @return [Integer] resolved status code (payload status_code or transport status)
96
- def status
97
- status_code = payload['status_code']
98
- status_code.is_a?(Integer) ? status_code : transport_status
202
+ value
99
203
  end
100
204
 
101
- # @return [String, nil] final URL reported by upstream
102
- def final_url = payload['final_url']
205
+ def error_category
206
+ value = payload['error_category']
207
+ return value if ERROR_CATEGORIES.include?(value)
103
208
 
104
- # @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
105
- def transport_meta = payload.slice(*META_KEYS).compact.freeze
209
+ raise BotasaurusServiceError, 'Botasaurus scrape error requires error_category'
210
+ end
106
211
 
107
- private
212
+ def diagnostics = BotasaurusContract.diagnostics_from(payload)
108
213
 
109
- attr_reader :payload, :transport_status
214
+ def request_id = diagnostics['request_id']
110
215
 
111
- def error = payload['error']
216
+ def challenge_marker
217
+ challenge = diagnostics['challenge']
218
+ challenge.is_a?(Hash) ? challenge['marker'] : nil
219
+ end
220
+ end
112
221
 
113
- def request_id = payload['request_id']
222
+ ##
223
+ # @param payload [Hash{String => Object}] scrape envelope
224
+ # @return [Hash{String => Object}] allowlisted diagnostics (frozen)
225
+ # @raise [BotasaurusServiceError] when diagnostics or request_id are missing
226
+ def self.diagnostics_from(payload)
227
+ raw = payload['diagnostics']
228
+ raise BotasaurusServiceError, 'Botasaurus scrape envelope requires diagnostics' unless raw.is_a?(Hash)
229
+ raise BotasaurusServiceError, 'Botasaurus diagnostics require request_id' if raw['request_id'].to_s.empty?
230
+
231
+ sliced = raw.slice(*DIAGNOSTICS_KEYS)
232
+ sliced['challenge'] = compact_challenge(sliced['challenge'])
233
+ sliced.compact.freeze
234
+ end
114
235
 
115
- def error_category = payload['error_category']
236
+ # @param challenge [Object] diagnostics.challenge value
237
+ # @return [Hash{String => Object}, nil]
238
+ def self.compact_challenge(challenge)
239
+ return unless challenge.is_a?(Hash)
116
240
 
117
- def error_message?
118
- value = error
119
- value.is_a?(String) ? !value.empty? : !value.nil?
120
- end
241
+ challenge.slice(*CHALLENGE_KEYS).compact
121
242
  end
243
+ private_class_method :compact_challenge
122
244
 
123
245
  ##
124
246
  # @param url [Html2rss::Url] canonical URL to scrape
247
+ # @param headers [Hash] request headers from context
125
248
  # @param options [Hash] validated request.botasaurus options
126
249
  # @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
250
+ # @option options [String] :execution_mode
127
251
  # @option options [String] :navigation_mode
128
252
  # @option options [Integer] :max_retries
129
253
  # @option options [String] :wait_for_selector
130
254
  # @option options [Integer] :wait_timeout_seconds
255
+ # @option options [Boolean] :scroll
131
256
  # @option options [Boolean] :block_images
132
257
  # @option options [Boolean] :block_images_and_css
258
+ # @option options [Boolean] :block_trackers
133
259
  # @option options [Boolean] :wait_for_complete_page_load
134
260
  # @option options [Boolean] :headless
135
261
  # @option options [String] :proxy
136
262
  # @option options [String] :user_agent
137
- # @option options [Array<Integer>] :window_size
263
+ # @option options [Hash] :window_size
138
264
  # @option options [String] :lang
139
- def initialize(url:, options: {}, remaining_timeout_seconds: nil)
265
+ # @option options [Hash] :cookies
266
+ # @option options [Hash] :headers
267
+ def initialize(url:, headers: {}, options: {}, remaining_timeout_seconds: nil)
140
268
  @url = url
269
+ @headers = headers
141
270
  @options = options
142
271
  @remaining_timeout_seconds = remaining_timeout_seconds
143
272
  end
144
273
 
145
- # @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
274
+ # @return [Hash] payload for POST /scrape (explicit options plus budget clamp-downs)
146
275
  def request_payload
147
- DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s).then { clamp_for_budget(_1) }
276
+ payload = { url: url.to_s }.merge(filtered_options)
277
+ forwarded_headers = merged_headers
278
+ payload[:headers] = forwarded_headers if forwarded_headers&.any?
279
+ clamp_for_budget(payload)
148
280
  end
149
281
 
150
282
  # @param transport_response [Faraday::Response] upstream HTTP response
151
- # @return [ParsedResponse]
152
- # @raise [BotasaurusConnectionFailed] when payload is not valid JSON object
283
+ # @return [Success, Error]
284
+ # @raise [BotasaurusServiceError] when payload is not a scrape envelope
153
285
  def parse_response(transport_response)
154
- payload = JSON.parse(transport_response.body.to_s)
155
- raise BotasaurusConnectionFailed, 'Botasaurus response must be a JSON object' unless payload.is_a?(Hash)
286
+ payload = json_object(transport_response.body.to_s)
287
+ return Success.new(payload:) if transport_response.status == 200
156
288
 
157
- ParsedResponse.new(payload:, transport_status: transport_response.status)
158
- rescue JSON::ParserError => error
159
- raise BotasaurusConnectionFailed, "Botasaurus response JSON parse failed: #{error.message}"
289
+ Error.new(payload:, transport_status: transport_response.status)
160
290
  end
161
291
 
162
292
  private
163
293
 
164
- attr_reader :url, :options, :remaining_timeout_seconds
294
+ attr_reader :url, :headers, :options, :remaining_timeout_seconds
295
+
296
+ def json_object(body)
297
+ payload = JSON.parse(body)
298
+ return payload if payload.is_a?(Hash)
299
+
300
+ raise BotasaurusServiceError, 'Botasaurus response must be a JSON object'
301
+ rescue JSON::ParserError => error
302
+ raise BotasaurusServiceError, "Botasaurus response JSON parse failed: #{error.message}"
303
+ end
304
+
305
+ def merged_headers
306
+ explicit = options[:headers] || {}
307
+ base = headers.is_a?(Hash) ? headers : {}
308
+ merged = base.merge(explicit).transform_keys(&:to_s).compact
309
+ merged.empty? ? nil : merged
310
+ end
165
311
 
166
312
  def filtered_options
167
- OPTION_KEYS.each_with_object({}) do |key, normalized|
313
+ REQUEST_OPTION_KEYS.each_with_object({}) do |key, normalized|
168
314
  normalized[key] = options[key] if options.key?(key)
169
315
  end
170
316
  end
171
317
 
172
318
  def clamp_for_budget(payload)
173
319
  remaining = remaining_timeout_seconds
174
- return payload if remaining.nil?
175
-
176
320
  clamped = payload.dup
177
- clamped[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
178
- budget_wait = [1, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
179
- configured = clamped[:wait_timeout_seconds]
180
- clamped[:wait_timeout_seconds] = configured ? [configured, budget_wait].min : budget_wait
321
+ unless remaining.nil?
322
+ apply_tight_retries!(clamped, remaining)
323
+ apply_budget_wait!(clamped, remaining)
324
+ end
325
+ cap_wait_timeout!(clamped)
181
326
  clamped
182
327
  end
328
+
329
+ def apply_tight_retries!(payload, remaining)
330
+ payload[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
331
+ end
332
+
333
+ def apply_budget_wait!(payload, remaining)
334
+ budget_wait = budget_wait_seconds(remaining)
335
+ configured = payload[:wait_timeout_seconds]
336
+ if configured
337
+ payload[:wait_timeout_seconds] = [configured, budget_wait].min
338
+ elsif budget_wait < DEFAULT_WAIT_TIMEOUT_SECONDS
339
+ payload[:wait_timeout_seconds] = budget_wait
340
+ end
341
+ end
342
+
343
+ def budget_wait_seconds(remaining)
344
+ reserved = [MIN_WAIT_TIMEOUT_SECONDS, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
345
+ [reserved, MAX_WAIT_TIMEOUT_SECONDS].min
346
+ end
347
+
348
+ def cap_wait_timeout!(payload)
349
+ wait = payload[:wait_timeout_seconds]
350
+ return unless wait
351
+
352
+ payload[:wait_timeout_seconds] = [wait, MAX_WAIT_TIMEOUT_SECONDS].min
353
+ end
183
354
  end
184
355
  end
185
356
  end
@@ -12,8 +12,8 @@ module Html2rss
12
12
 
13
13
  def fetch
14
14
  parsed_response = post_scrape_request
15
- raise_if_challenge_blocked!(parsed_response)
16
- raise_if_upstream_failed!(parsed_response)
15
+ raise_from_error!(parsed_response) if parsed_response.is_a?(BotasaurusContract::Error)
16
+
17
17
  build_response(parsed_response)
18
18
  end
19
19
 
@@ -28,20 +28,28 @@ module Html2rss
28
28
  headers: parsed_response.headers,
29
29
  url: response_url(parsed_response.final_url),
30
30
  status: parsed_response.status,
31
- transport_meta: parsed_response.transport_meta
31
+ transport_meta: parsed_response.transport_meta,
32
+ captured_responses: parsed_response.xhr_responses
32
33
  )
33
34
  end
34
35
 
35
- def raise_if_challenge_blocked!(parsed_response)
36
- return unless parsed_response.challenge_block?
36
+ def raise_from_error!(error)
37
+ raise_if_challenge_blocked!(error)
38
+ raise_if_timed_out!(error)
39
+ raise BotasaurusServiceError, error.failure_message
40
+ end
41
+
42
+ def raise_if_challenge_blocked!(error)
43
+ return unless error.challenge_block?
37
44
 
38
- raise BlockedSurfaceDetected, "Blocked surface detected: #{parsed_response.challenge_message}"
45
+ raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}"
39
46
  end
40
47
 
41
- def raise_if_upstream_failed!(parsed_response)
42
- return unless parsed_response.upstream_failure?
48
+ def raise_if_timed_out!(error)
49
+ return unless error.timeout?
43
50
 
44
- raise BotasaurusConnectionFailed, parsed_response.upstream_failure_message
51
+ log_timeout!(reason: 'botasaurus_upstream')
52
+ raise RequestTimedOut, error.failure_message
45
53
  end
46
54
 
47
55
  def response_url(final_url)
@@ -55,6 +63,7 @@ module Html2rss
55
63
  def contract
56
64
  @contract ||= BotasaurusContract.new(
57
65
  url: ctx.url,
66
+ headers: ctx.headers,
58
67
  options: ctx.request.fetch(:botasaurus, {}),
59
68
  remaining_timeout_seconds: attempt_timeout_seconds
60
69
  )
@@ -3,20 +3,17 @@
3
3
  module Html2rss
4
4
  class RequestService
5
5
  ##
6
- # Tracks request slots, interaction budget, and wall-clock deadline for one feed build.
6
+ # Tracks HTTP request slots and wall-clock deadline for one feed build.
7
7
  #
8
- # HTTP fetches consume request slots; Browserless preload clicks/scrolls/waits consume
9
- # interaction budget. Deadline remains on `#remaining_timeout_seconds`.
8
+ # HTTP fetches consume request slots. Deadline remains on `#remaining_timeout_seconds`.
10
9
  class Budget
11
10
  ##
12
11
  # @param max_requests [Integer] maximum HTTP request slots
13
- # @param max_interactions [Integer] maximum preload interaction slots (default 0)
14
12
  # @param total_timeout_seconds [Integer, nil] wall-clock timeout for the feed build
15
- def initialize(max_requests:, max_interactions: 0, total_timeout_seconds: nil)
16
- validate_slot_limits!(max_requests:, max_interactions:)
13
+ def initialize(max_requests:, total_timeout_seconds: nil)
14
+ validate_slot_limits!(max_requests:)
17
15
 
18
16
  @remaining_requests = max_requests
19
- @remaining_interactions = max_interactions
20
17
  @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
18
  @total_timeout_seconds = total_timeout_seconds
22
19
  @mutex = Mutex.new
@@ -44,21 +41,6 @@ module Html2rss
44
41
  end
45
42
  end
46
43
 
47
- ##
48
- # Consumes one Browserless preload interaction slot.
49
- #
50
- # Preload must not steal pagination HTTP request slots.
51
- #
52
- # @return [Integer] remaining interaction slots after consumption
53
- # @raise [InteractionBudgetExceeded] if no interaction slots remain
54
- def consume_interaction!
55
- @mutex.synchronize do
56
- raise InteractionBudgetExceeded, 'Interaction budget exhausted' if @remaining_interactions.zero?
57
-
58
- @remaining_interactions -= 1
59
- end
60
- end
61
-
62
44
  ##
63
45
  # @return [Integer] HTTP request slots still available
64
46
  def remaining
@@ -71,12 +53,6 @@ module Html2rss
71
53
  @mutex.synchronize { @remaining_requests }
72
54
  end
73
55
 
74
- ##
75
- # @return [Integer] preload interaction slots still available
76
- def remaining_interactions
77
- @mutex.synchronize { @remaining_interactions }
78
- end
79
-
80
56
  ##
81
57
  # @return [Float, nil] the remaining timeout in seconds, or nil if not tracked
82
58
  def remaining_timeout_seconds
@@ -118,14 +94,10 @@ module Html2rss
118
94
 
119
95
  private
120
96
 
121
- def validate_slot_limits!(max_requests:, max_interactions:)
122
- unless max_requests.is_a?(Integer) && max_requests.positive?
123
- raise ArgumentError,
124
- 'max_requests must be positive'
125
- end
126
- return if max_interactions.is_a?(Integer) && !max_interactions.negative?
97
+ def validate_slot_limits!(max_requests:)
98
+ return if max_requests.is_a?(Integer) && max_requests.positive?
127
99
 
128
- raise ArgumentError, 'max_interactions must be a non-negative integer'
100
+ raise ArgumentError, 'max_requests must be positive'
129
101
  end
130
102
  end
131
103
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'brotli'
4
+ require 'stringio'
5
+ require 'zlib'
6
+
7
+ module Html2rss
8
+ class RequestService
9
+ # Decodes gzip/brotli bodies when Content-Encoding is missing or the type is octet-stream.
10
+ module CompressedBody
11
+ # Gzip stream magic (RFC 1952).
12
+ GZIP_MAGIC = "\x1F\x8B".b.freeze
13
+ # HTML prefix for unlabeled brotli trial decode. Same object as {Response::HTML_BODY_SNIFF}.
14
+ HTML_BODY_SNIFF = Response::HTML_BODY_SNIFF
15
+
16
+ module_function
17
+
18
+ # @param body [String, nil] raw response body
19
+ # @param headers [Hash] response headers (Content-Encoding / Content-Type)
20
+ # @return [String, nil] decoded body when compressed; otherwise the original body
21
+ def decode(body, headers: {})
22
+ return body if body.nil? || body.empty?
23
+
24
+ raw = body.to_s.b
25
+ decode_labeled(raw, headers) || decode_unlabeled(raw, headers) || body
26
+ end
27
+
28
+ def decode_labeled(raw, headers)
29
+ encodings = content_encodings(headers)
30
+ return if encodings.empty?
31
+
32
+ encodings.reverse.reduce(raw) { |buffer, encoding| inflate(buffer, encoding) || buffer }
33
+ end
34
+ module_function :decode_labeled
35
+ private_class_method :decode_labeled
36
+
37
+ def decode_unlabeled(raw, headers)
38
+ return uncompress_gzip(raw) if raw.start_with?(GZIP_MAGIC)
39
+ return unless octet_stream?(headers)
40
+
41
+ try_brotli(raw)
42
+ end
43
+ module_function :decode_unlabeled
44
+ private_class_method :decode_unlabeled
45
+
46
+ def inflate(raw, encoding)
47
+ case encoding
48
+ when 'gzip' then uncompress_gzip(raw)
49
+ when 'deflate' then inflate_deflate(raw)
50
+ when 'br' then try_brotli(raw, require_html: false)
51
+ end
52
+ rescue Zlib::Error, Brotli::Error, ArgumentError
53
+ nil
54
+ end
55
+ module_function :inflate
56
+ private_class_method :inflate
57
+
58
+ def uncompress_gzip(raw)
59
+ Zlib::GzipReader.wrap(StringIO.new(raw), encoding: 'ASCII-8BIT', &:read)
60
+ end
61
+ module_function :uncompress_gzip
62
+ private_class_method :uncompress_gzip
63
+
64
+ def inflate_deflate(raw)
65
+ inflater = nil
66
+ Zlib::Inflate.inflate(raw)
67
+ rescue Zlib::DataError
68
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
69
+ inflater.inflate(raw)
70
+ ensure
71
+ inflater&.close
72
+ end
73
+ module_function :inflate_deflate
74
+ private_class_method :inflate_deflate
75
+
76
+ def try_brotli(raw, require_html: true)
77
+ inflated = Brotli.inflate(raw)
78
+ return inflated unless require_html
79
+ return inflated if inflated.b.match?(HTML_BODY_SNIFF)
80
+
81
+ nil
82
+ rescue Brotli::Error, ArgumentError
83
+ nil
84
+ end
85
+ module_function :try_brotli
86
+ private_class_method :try_brotli
87
+
88
+ def content_encodings(headers)
89
+ header(headers, 'content-encoding').to_s.split(',').map { |value| value.strip.downcase }.reject(&:empty?)
90
+ end
91
+ module_function :content_encodings
92
+ private_class_method :content_encodings
93
+
94
+ def octet_stream?(headers)
95
+ header(headers, 'content-type').to_s.downcase.include?('application/octet-stream')
96
+ end
97
+ module_function :octet_stream?
98
+ private_class_method :octet_stream?
99
+
100
+ def header(headers, name)
101
+ headers.fetch(name) do
102
+ headers.find { |key, _value| key.to_s.casecmp?(name) }&.last
103
+ end
104
+ end
105
+ module_function :header
106
+ private_class_method :header
107
+ end
108
+ end
109
+ end