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.
- checksums.yaml +4 -4
- data/README.md +41 -18
- data/html2rss.gemspec +1 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +121 -56
- data/lib/html2rss/auto_source/scraper.rb +13 -0
- data/lib/html2rss/auto_source.rb +34 -8
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +120 -117
- data/lib/html2rss/cli.rb +35 -17
- data/lib/html2rss/config/schema.rb +12 -0
- data/lib/html2rss/config/validator.rb +31 -8
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +24 -6
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +30 -12
- data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
- data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
- data/lib/html2rss/html/article_extractor.rb +95 -17
- data/lib/html2rss/html/article_rules/category.rb +28 -11
- data/lib/html2rss/html/article_rules/date.rb +60 -6
- data/lib/html2rss/html/article_rules/description.rb +122 -0
- data/lib/html2rss/html/card_walk.rb +42 -0
- data/lib/html2rss/html/feed_link.rb +34 -0
- data/lib/html2rss/html/navigator.rb +18 -0
- data/lib/html2rss/html/sst_article_extractor.rb +119 -32
- data/lib/html2rss/link_destination/path_classifier.rb +49 -35
- data/lib/html2rss/mcp/config_argument.rb +42 -0
- data/lib/html2rss/mcp/contract.rb +173 -0
- data/lib/html2rss/mcp/inspect.rb +241 -0
- data/lib/html2rss/mcp/outcome.rb +188 -0
- data/lib/html2rss/mcp/server.rb +253 -409
- data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
- data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +57 -6
- data/lib/html2rss/request_service.rb +6 -1
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +20 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +30 -6
- data/schema/html2rss-config.schema.json +26 -15
- 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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
execution_mode
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
def initialize(payload:, transport_status:)
|
|
57
|
+
# @param payload [Hash{String => Object}] parsed ScrapeSuccess object
|
|
58
|
+
def initialize(payload:)
|
|
64
59
|
@payload = payload
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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,
|
|
67
|
+
raise BotasaurusServiceError, 'Botasaurus scrape success requires html' unless value.is_a?(String)
|
|
95
68
|
|
|
96
|
-
value
|
|
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
|
|
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]
|
|
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 :
|
|
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}]
|
|
117
|
-
def transport_meta
|
|
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
|
|
137
|
-
|
|
138
|
-
def error = payload['error']
|
|
113
|
+
attr_reader :payload
|
|
139
114
|
|
|
140
|
-
def
|
|
115
|
+
def diagnostics = BotasaurusContract.diagnostics_from(payload)
|
|
141
116
|
|
|
142
|
-
def
|
|
143
|
-
|
|
144
|
-
|
|
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 [
|
|
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 (
|
|
274
|
+
# @return [Hash] payload for POST /scrape (explicit options plus budget clamp-downs)
|
|
216
275
|
def request_payload
|
|
217
|
-
payload =
|
|
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
|
-
|
|
279
|
+
clamp_for_budget(payload)
|
|
221
280
|
end
|
|
222
281
|
|
|
223
282
|
# @param transport_response [Faraday::Response] upstream HTTP response
|
|
224
|
-
# @return [
|
|
225
|
-
# @raise [BotasaurusServiceError] when payload is not
|
|
283
|
+
# @return [Success, Error]
|
|
284
|
+
# @raise [BotasaurusServiceError] when payload is not a scrape envelope
|
|
226
285
|
def parse_response(transport_response)
|
|
227
|
-
payload =
|
|
228
|
-
|
|
286
|
+
payload = json_object(transport_response.body.to_s)
|
|
287
|
+
return Success.new(payload:) if transport_response.status == 200
|
|
229
288
|
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
37
|
-
|
|
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: #{
|
|
45
|
+
raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}"
|
|
40
46
|
end
|
|
41
47
|
|
|
42
|
-
def
|
|
43
|
-
return unless
|
|
48
|
+
def raise_if_timed_out!(error)
|
|
49
|
+
return unless error.timeout?
|
|
44
50
|
|
|
45
|
-
|
|
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
|
|
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,
|
|
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?
|
|
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
|
-
|
|
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
|