html2rss 0.26.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -18
  3. data/html2rss.gemspec +1 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +121 -56
  6. data/lib/html2rss/auto_source/scraper.rb +13 -0
  7. data/lib/html2rss/auto_source.rb +34 -8
  8. data/lib/html2rss/capture/README.md +61 -0
  9. data/lib/html2rss/capture.rb +120 -117
  10. data/lib/html2rss/cli.rb +35 -17
  11. data/lib/html2rss/config/schema.rb +12 -0
  12. data/lib/html2rss/config/validator.rb +31 -8
  13. data/lib/html2rss/config.rb +28 -0
  14. data/lib/html2rss/error.rb +24 -6
  15. data/lib/html2rss/feed_pipeline/README.md +42 -0
  16. data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
  17. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  18. data/lib/html2rss/feed_pipeline.rb +30 -12
  19. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  20. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  21. data/lib/html2rss/html/article_extractor.rb +95 -17
  22. data/lib/html2rss/html/article_rules/category.rb +28 -11
  23. data/lib/html2rss/html/article_rules/date.rb +60 -6
  24. data/lib/html2rss/html/article_rules/description.rb +122 -0
  25. data/lib/html2rss/html/card_walk.rb +42 -0
  26. data/lib/html2rss/html/feed_link.rb +34 -0
  27. data/lib/html2rss/html/navigator.rb +18 -0
  28. data/lib/html2rss/html/sst_article_extractor.rb +119 -32
  29. data/lib/html2rss/link_destination/path_classifier.rb +49 -35
  30. data/lib/html2rss/mcp/config_argument.rb +42 -0
  31. data/lib/html2rss/mcp/contract.rb +173 -0
  32. data/lib/html2rss/mcp/inspect.rb +241 -0
  33. data/lib/html2rss/mcp/outcome.rb +188 -0
  34. data/lib/html2rss/mcp/server.rb +253 -409
  35. data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
  36. data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
  37. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  38. data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
  39. data/lib/html2rss/request_service/policy.rb +1 -1
  40. data/lib/html2rss/request_service/response.rb +57 -6
  41. data/lib/html2rss/request_service.rb +6 -1
  42. data/lib/html2rss/selectors.rb +2 -1
  43. data/lib/html2rss/status.rb +27 -11
  44. data/lib/html2rss/url.rb +20 -0
  45. data/lib/html2rss/version.rb +1 -1
  46. data/lib/html2rss.rb +30 -6
  47. data/schema/html2rss-config.schema.json +26 -15
  48. metadata +15 -4
@@ -5,43 +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
- execution_mode: 'auto',
13
- navigation_mode: 'auto',
14
- max_retries: 1,
15
- headless: false
16
- }.freeze
17
-
18
- # Allowlisted request.botasaurus keys forwarded to upstream.
19
- OPTION_KEYS = %i[
20
- execution_mode
21
- navigation_mode
22
- max_retries
23
- wait_for_selector
24
- wait_timeout_seconds
25
- scroll
26
- scroll_to_bottom
27
- block_images
28
- block_images_and_css
29
- block_trackers
30
- wait_for_complete_page_load
31
- headless
32
- proxy
33
- user_agent
34
- window_size
35
- lang
36
- headers
37
- cookies
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
38
23
  ].freeze
39
24
 
40
- # Allowlisted upstream response keys exposed as Response#transport_meta.
41
- META_KEYS = %w[
42
- request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
43
- execution_tier detected_challenge
44
- ].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
45
42
 
46
43
  # Remaining seconds at or below which Botasaurus retries are disabled.
47
44
  TIGHT_BUDGET_SECONDS = 12
@@ -49,72 +46,52 @@ module Html2rss
49
46
  # Seconds reserved from remaining budget before setting wait_timeout_seconds.
50
47
  BUDGET_WAIT_RESERVE_SECONDS = 2
51
48
 
52
- # Parsed Botasaurus response wrapper.
53
- class ParsedResponse
54
- # Fallback headers when upstream omits response headers.
55
- DEFAULT_HEADERS = { 'content-type' => 'text/html' }.freeze
49
+ ##
50
+ # Parsed OpenAPI ScrapeSuccess envelope (HTTP 200).
51
+ class Success
56
52
  # Per-body size cap for captured XHR JSON (defense-in-depth vs upstream).
57
53
  MAX_XHR_BODY_BYTES = 500_000
58
54
  # Aggregate size cap across all captured XHR bodies.
59
55
  MAX_XHR_AGGREGATE_BYTES = 2_000_000
60
56
 
61
- # @param payload [Hash{String => Object}] parsed Botasaurus response payload
62
- # @param transport_status [Integer] HTTP status returned by Botasaurus
63
- def initialize(payload:, transport_status:)
57
+ # @param payload [Hash{String => Object}] parsed ScrapeSuccess object
58
+ def initialize(payload:)
64
59
  @payload = payload
65
- @transport_status = transport_status
66
- end
67
-
68
- # @return [Boolean] true when upstream classified request as challenge blocked
69
- def challenge_block? = error_category == 'challenge_block'
70
-
71
- # @return [Boolean] true when upstream returned non-200 or an error payload
72
- def upstream_failure?
73
- transport_status != 200 || status != 200 || error_message?
74
- end
75
-
76
- # @return [String] normalized challenge error message
77
- def challenge_message
78
- error || 'Botasaurus challenge block detected.'
79
- end
80
-
81
- # @return [String] actionable upstream failure summary
82
- def upstream_failure_message
83
- details = ["status=#{status}"]
84
- details << "error_category=#{error_category}" if error_category
85
- details << "error=#{error}" if error
86
- details << "request_id=#{request_id}" if request_id
87
- "Botasaurus scrape failed (#{details.join(', ')})."
60
+ html
61
+ diagnostics
88
62
  end
89
63
 
90
- # @return [String] rendered HTML body from Botasaurus
91
- # @raise [BotasaurusServiceError] when html is missing
64
+ # @return [String] rendered HTML body
92
65
  def html
93
66
  value = payload['html']
94
- raise BotasaurusServiceError, "Botasaurus response missing required 'html' field" if value.nil?
67
+ raise BotasaurusServiceError, 'Botasaurus scrape success requires html' unless value.is_a?(String)
95
68
 
96
- value.to_s
69
+ value
97
70
  end
98
71
 
99
- # @return [Hash{String => String}] normalized response headers
72
+ # @return [Hash{String => String}] normalized response headers (null → {})
100
73
  def headers
101
74
  raw_headers = payload['headers']
102
- return DEFAULT_HEADERS.dup unless raw_headers.is_a?(Hash) && raw_headers.any?
75
+ return {} unless raw_headers.is_a?(Hash) && raw_headers.any?
103
76
 
104
77
  raw_headers.to_h { |key, value| [key.to_s, value.to_s] }
105
78
  end
106
79
 
107
- # @return [Integer] resolved status code (payload status_code or transport status)
80
+ # @return [Integer, nil] document status_code when present
108
81
  def status
109
82
  status_code = payload['status_code']
110
- status_code.is_a?(Integer) ? status_code : transport_status
83
+ status_code.is_a?(Integer) ? status_code : nil
111
84
  end
112
85
 
113
86
  # @return [String, nil] final URL reported by upstream
114
87
  def final_url = payload['final_url']
115
88
 
116
- # @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
117
- def transport_meta = payload.slice(*META_KEYS).compact.freeze
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
118
95
 
119
96
  # @return [Array<Hash{String => Object}>] size-capped XHR/fetch captures (absent → [])
120
97
  def xhr_responses
@@ -133,17 +110,13 @@ module Html2rss
133
110
 
134
111
  private
135
112
 
136
- attr_reader :payload, :transport_status
137
-
138
- def error = payload['error']
113
+ attr_reader :payload
139
114
 
140
- def request_id = payload['request_id']
115
+ def diagnostics = BotasaurusContract.diagnostics_from(payload)
141
116
 
142
- def error_category = payload['error_category']
143
-
144
- def error_message?
145
- value = error
146
- value.is_a?(String) ? !value.empty? : !value.nil?
117
+ def metadata_error
118
+ value = payload['metadata_error']
119
+ value.is_a?(String) && !value.empty? ? value : nil
147
120
  end
148
121
 
149
122
  def normalize_xhr_entry(entry)
@@ -184,6 +157,91 @@ module Html2rss
184
157
  end
185
158
  end
186
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
164
+ # @param transport_status [Integer] HTTP status returned by Botasaurus
165
+ def initialize(payload:, transport_status:)
166
+ @payload = payload
167
+ @transport_status = transport_status
168
+ error
169
+ error_category
170
+ diagnostics
171
+ end
172
+
173
+ # @return [Boolean] true when upstream classified request as challenge blocked
174
+ def challenge_block? = error_category == 'challenge_block'
175
+
176
+ # @return [Boolean] true when the scrape envelope reports a timeout
177
+ def timeout?
178
+ transport_status == 504 || error_category == 'timeout'
179
+ end
180
+
181
+ # @return [String] normalized challenge error message
182
+ def challenge_message
183
+ [error, challenge_marker].find { |value| value.is_a?(String) && !value.empty? } ||
184
+ 'Botasaurus challenge block detected.'
185
+ end
186
+
187
+ # @return [String] actionable upstream failure summary
188
+ def failure_message
189
+ details = ["status=#{transport_status}", "error_category=#{error_category}", "error=#{error}"]
190
+ details << "request_id=#{request_id}" if request_id
191
+ "Botasaurus scrape failed (#{details.join(', ')})."
192
+ end
193
+
194
+ private
195
+
196
+ attr_reader :payload, :transport_status
197
+
198
+ def error
199
+ value = payload['error']
200
+ raise BotasaurusServiceError, 'Botasaurus scrape error requires error' unless value.is_a?(String)
201
+
202
+ value
203
+ end
204
+
205
+ def error_category
206
+ value = payload['error_category']
207
+ return value if ERROR_CATEGORIES.include?(value)
208
+
209
+ raise BotasaurusServiceError, 'Botasaurus scrape error requires error_category'
210
+ end
211
+
212
+ def diagnostics = BotasaurusContract.diagnostics_from(payload)
213
+
214
+ def request_id = diagnostics['request_id']
215
+
216
+ def challenge_marker
217
+ challenge = diagnostics['challenge']
218
+ challenge.is_a?(Hash) ? challenge['marker'] : nil
219
+ end
220
+ end
221
+
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
235
+
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)
240
+
241
+ challenge.slice(*CHALLENGE_KEYS).compact
242
+ end
243
+ private_class_method :compact_challenge
244
+
187
245
  ##
188
246
  # @param url [Html2rss::Url] canonical URL to scrape
189
247
  # @param headers [Hash] request headers from context
@@ -194,6 +252,7 @@ module Html2rss
194
252
  # @option options [Integer] :max_retries
195
253
  # @option options [String] :wait_for_selector
196
254
  # @option options [Integer] :wait_timeout_seconds
255
+ # @option options [Boolean] :scroll
197
256
  # @option options [Boolean] :block_images
198
257
  # @option options [Boolean] :block_images_and_css
199
258
  # @option options [Boolean] :block_trackers
@@ -201,7 +260,7 @@ module Html2rss
201
260
  # @option options [Boolean] :headless
202
261
  # @option options [String] :proxy
203
262
  # @option options [String] :user_agent
204
- # @option options [Array<Integer>] :window_size
263
+ # @option options [Hash] :window_size
205
264
  # @option options [String] :lang
206
265
  # @option options [Hash] :cookies
207
266
  # @option options [Hash] :headers
@@ -212,30 +271,37 @@ module Html2rss
212
271
  @remaining_timeout_seconds = remaining_timeout_seconds
213
272
  end
214
273
 
215
- # @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)
216
275
  def request_payload
217
- payload = DEFAULT_OPTIONS.merge(filtered_options)
276
+ payload = { url: url.to_s }.merge(filtered_options)
218
277
  forwarded_headers = merged_headers
219
278
  payload[:headers] = forwarded_headers if forwarded_headers&.any?
220
- payload.merge(url: url.to_s).then { clamp_for_budget(_1) }
279
+ clamp_for_budget(payload)
221
280
  end
222
281
 
223
282
  # @param transport_response [Faraday::Response] upstream HTTP response
224
- # @return [ParsedResponse]
225
- # @raise [BotasaurusServiceError] when payload is not valid JSON object
283
+ # @return [Success, Error]
284
+ # @raise [BotasaurusServiceError] when payload is not a scrape envelope
226
285
  def parse_response(transport_response)
227
- payload = JSON.parse(transport_response.body.to_s)
228
- raise BotasaurusServiceError, '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
229
288
 
230
- ParsedResponse.new(payload:, transport_status: transport_response.status)
231
- rescue JSON::ParserError => error
232
- raise BotasaurusServiceError, "Botasaurus response JSON parse failed: #{error.message}"
289
+ Error.new(payload:, transport_status: transport_response.status)
233
290
  end
234
291
 
235
292
  private
236
293
 
237
294
  attr_reader :url, :headers, :options, :remaining_timeout_seconds
238
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
+
239
305
  def merged_headers
240
306
  explicit = options[:headers] || {}
241
307
  base = headers.is_a?(Hash) ? headers : {}
@@ -244,22 +310,47 @@ module Html2rss
244
310
  end
245
311
 
246
312
  def filtered_options
247
- OPTION_KEYS.each_with_object({}) do |key, normalized|
313
+ REQUEST_OPTION_KEYS.each_with_object({}) do |key, normalized|
248
314
  normalized[key] = options[key] if options.key?(key)
249
315
  end
250
316
  end
251
317
 
252
318
  def clamp_for_budget(payload)
253
319
  remaining = remaining_timeout_seconds
254
- return payload if remaining.nil?
255
-
256
320
  clamped = payload.dup
257
- clamped[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
258
- budget_wait = [1, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
259
- configured = clamped[:wait_timeout_seconds]
260
- 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)
261
326
  clamped
262
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
263
354
  end
264
355
  end
265
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
 
@@ -33,16 +33,23 @@ module Html2rss
33
33
  )
34
34
  end
35
35
 
36
- def raise_if_challenge_blocked!(parsed_response)
37
- 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?
38
44
 
39
- raise BlockedSurfaceDetected, "Blocked surface detected: #{parsed_response.challenge_message}"
45
+ raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}"
40
46
  end
41
47
 
42
- def raise_if_upstream_failed!(parsed_response)
43
- return unless parsed_response.upstream_failure?
48
+ def raise_if_timed_out!(error)
49
+ return unless error.timeout?
44
50
 
45
- raise BotasaurusServiceError, parsed_response.upstream_failure_message
51
+ log_timeout!(reason: 'botasaurus_upstream')
52
+ raise RequestTimedOut, error.failure_message
46
53
  end
47
54
 
48
55
  def response_url(final_url)
@@ -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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'brotli'
3
4
  require 'faraday'
4
5
  require 'faraday/follow_redirects'
5
6
  require 'faraday/gzip'
@@ -89,7 +90,8 @@ module Html2rss
89
90
  end
90
91
 
91
92
  def build_response(response)
92
- Response.new(body: response.body, headers: response.headers, url: response_url(response),
93
+ Response.new(body: CompressedBody.decode(response.body, headers: response.headers),
94
+ headers: response.headers, url: response_url(response),
93
95
  status: response.status)
94
96
  end
95
97
 
@@ -115,7 +115,7 @@ module Html2rss
115
115
  # @raise [UnsupportedUrlScheme] if the redirect downgrades from HTTPS to HTTP
116
116
  def validate_redirect!(from_url:, to_url:, origin_url:, relation:)
117
117
  if from_url.scheme == 'https' && to_url.scheme == 'http'
118
- raise UnsupportedUrlScheme, 'Redirect downgraded from https to http'
118
+ raise UnsupportedUrlScheme, "Redirect downgraded from #{from_url} to #{to_url}"
119
119
  end
120
120
 
121
121
  validate_request!(url: to_url, origin_url:, relation:)
@@ -12,6 +12,14 @@ module Html2rss
12
12
  # Default when a strategy does not capture sub-resource responses.
13
13
  EMPTY_CAPTURED_RESPONSES = [].freeze
14
14
 
15
+ # Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
16
+ HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
17
+ # Charset from Content-Type or a leading <meta charset>.
18
+ CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
19
+ # Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
20
+ META_CHARSET_BYTES = 2048
21
+ private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES
22
+
15
23
  ##
16
24
  # @param body [String] the body of the response
17
25
  # @param url [Html2rss::Url] the final request URL
@@ -64,18 +72,17 @@ module Html2rss
64
72
  # @return [Boolean] whether response content is JSON
65
73
  def json_response? = content_type.include?('application/json')
66
74
 
67
- # @return [Boolean] whether response content is HTML
68
- def html_response? = content_type.include?('text/html')
75
+ # @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
76
+ def html_response?
77
+ content_type.include?('text/html') || (!json_response? && html_looking_body?)
78
+ end
69
79
 
70
80
  ##
71
81
  # @return [Nokogiri::HTML::Document, Hash] the parsed body of the response, frozen object
72
82
  # @raise [UnsupportedResponseContentType] if the content type is not supported
73
83
  def parsed_body
74
84
  @parsed_body ||= if html_response?
75
- Nokogiri::HTML(body).tap do |doc|
76
- # Remove comments from the document to avoid processing irrelevant content
77
- doc.xpath('//comment()').each(&:remove)
78
- end.freeze
85
+ parse_html_document
79
86
  elsif json_response?
80
87
  JSON.parse(body, symbolize_names: true).freeze
81
88
  else
@@ -92,6 +99,50 @@ module Html2rss
92
99
  headers.find { |key, _value| key.casecmp?(name) }&.last
93
100
  end
94
101
  end
102
+
103
+ def parse_html_document
104
+ Nokogiri::HTML(decoded_html_body).tap do |doc|
105
+ doc.xpath('//comment()').each(&:remove)
106
+ end.freeze
107
+ end
108
+
109
+ def decoded_html_body
110
+ bytes = body.to_s.b
111
+ transcode_html(bytes, header_charset || meta_charset(bytes))
112
+ end
113
+
114
+ def transcode_html(bytes, charset)
115
+ encoding = html_encoding_for(charset)
116
+ return utf8_scrub(bytes) unless encoding
117
+
118
+ bytes.dup.force_encoding(encoding).encode(Encoding::UTF_8)
119
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
120
+ utf8_scrub(bytes)
121
+ end
122
+
123
+ def html_encoding_for(charset)
124
+ return if charset.nil? || utf8_charset?(charset)
125
+
126
+ Encoding.find(charset)
127
+ rescue ArgumentError
128
+ nil
129
+ end
130
+
131
+ def utf8_charset?(charset) = charset.to_s.downcase.gsub(/[\s_-]/, '') == 'utf8'
132
+
133
+ def utf8_scrub(bytes) = bytes.dup.force_encoding(Encoding::UTF_8).scrub
134
+
135
+ def header_charset
136
+ content_type[CHARSET_PARAMETER, 1]
137
+ end
138
+
139
+ def meta_charset(bytes)
140
+ bytes.byteslice(0, META_CHARSET_BYTES).to_s[CHARSET_PARAMETER, 1]
141
+ end
142
+
143
+ def html_looking_body?
144
+ body.to_s.b.match?(HTML_BODY_SNIFF)
145
+ end
95
146
  end
96
147
  end
97
148
  end