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.
- checksums.yaml +4 -4
- data/README.md +99 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +140 -49
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +47 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +41 -10
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +318 -0
- data/lib/html2rss/cli.rb +94 -17
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +79 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +46 -32
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +31 -1
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +40 -15
- data/lib/html2rss/hash_util.rb +17 -0
- 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 +147 -31
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +49 -32
- 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 +421 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
- data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
- data/lib/html2rss/request_service/policy.rb +2 -2
- data/lib/html2rss/request_service/response.rb +72 -7
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +10 -10
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +31 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +62 -7
- data/schema/html2rss-config.schema.json +428 -94
- metadata +65 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- 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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
max_retries
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
].
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
def
|
|
61
|
-
|
|
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
|
|
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
|
|
71
|
-
details = ["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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
198
|
+
def error
|
|
199
|
+
value = payload['error']
|
|
200
|
+
raise BotasaurusServiceError, 'Botasaurus scrape error requires error' unless value.is_a?(String)
|
|
94
201
|
|
|
95
|
-
|
|
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
|
-
|
|
102
|
-
|
|
205
|
+
def error_category
|
|
206
|
+
value = payload['error_category']
|
|
207
|
+
return value if ERROR_CATEGORIES.include?(value)
|
|
103
208
|
|
|
104
|
-
|
|
105
|
-
|
|
209
|
+
raise BotasaurusServiceError, 'Botasaurus scrape error requires error_category'
|
|
210
|
+
end
|
|
106
211
|
|
|
107
|
-
|
|
212
|
+
def diagnostics = BotasaurusContract.diagnostics_from(payload)
|
|
108
213
|
|
|
109
|
-
|
|
214
|
+
def request_id = diagnostics['request_id']
|
|
110
215
|
|
|
111
|
-
def
|
|
216
|
+
def challenge_marker
|
|
217
|
+
challenge = diagnostics['challenge']
|
|
218
|
+
challenge.is_a?(Hash) ? challenge['marker'] : nil
|
|
219
|
+
end
|
|
220
|
+
end
|
|
112
221
|
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
263
|
+
# @option options [Hash] :window_size
|
|
138
264
|
# @option options [String] :lang
|
|
139
|
-
|
|
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 (
|
|
274
|
+
# @return [Hash] payload for POST /scrape (explicit options plus budget clamp-downs)
|
|
146
275
|
def request_payload
|
|
147
|
-
|
|
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 [
|
|
152
|
-
# @raise [
|
|
283
|
+
# @return [Success, Error]
|
|
284
|
+
# @raise [BotasaurusServiceError] when payload is not a scrape envelope
|
|
153
285
|
def parse_response(transport_response)
|
|
154
|
-
payload =
|
|
155
|
-
|
|
286
|
+
payload = json_object(transport_response.body.to_s)
|
|
287
|
+
return Success.new(payload:) if transport_response.status == 200
|
|
156
288
|
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
36
|
-
|
|
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: #{
|
|
45
|
+
raise BlockedSurfaceDetected, "Blocked surface detected: #{error.challenge_message}"
|
|
39
46
|
end
|
|
40
47
|
|
|
41
|
-
def
|
|
42
|
-
return unless
|
|
48
|
+
def raise_if_timed_out!(error)
|
|
49
|
+
return unless error.timeout?
|
|
43
50
|
|
|
44
|
-
|
|
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
|
|
6
|
+
# Tracks HTTP request slots and wall-clock deadline for one feed build.
|
|
7
7
|
#
|
|
8
|
-
# HTTP fetches consume request slots
|
|
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:,
|
|
16
|
-
validate_slot_limits!(max_requests
|
|
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
|
|
122
|
-
|
|
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, '
|
|
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
|