html2rss 0.29.1 → 0.30.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 +6 -6
- data/html2rss.gemspec +1 -3
- data/lib/html2rss/batch.rb +3 -3
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +1 -1
- data/lib/html2rss/config/request_headers.rb +5 -2
- data/lib/html2rss/config.rb +12 -0
- data/lib/html2rss/doctor/botasaurus.rb +13 -5
- data/lib/html2rss/feed_pipeline/README.md +8 -8
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +1 -1
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +1 -1
- data/lib/html2rss/mcp/README.md +3 -3
- data/lib/html2rss/mcp/contract.rb +9 -5
- data/lib/html2rss/mcp/outcome/playbook.rb +5 -5
- data/lib/html2rss/mcp/server/tools.rb +1 -1
- data/lib/html2rss/mcp/server.rb +1 -1
- data/lib/html2rss/recon.rb +1 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
- data/lib/html2rss/request_service/compressed_body.rb +13 -8
- data/lib/html2rss/request_service/httpx_strategy.rb +228 -0
- data/lib/html2rss/request_service/response_guard.rb +0 -16
- data/lib/html2rss/request_service/strategy.rb +19 -3
- data/lib/html2rss/request_service.rb +46 -85
- data/lib/html2rss/test.rb +2 -1
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +6 -6
- metadata +6 -40
- data/lib/html2rss/request_service/faraday_strategy.rb +0 -233
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'brotli'
|
|
4
|
-
require 'faraday'
|
|
5
|
-
require 'faraday/follow_redirects'
|
|
6
|
-
require 'faraday/gzip'
|
|
7
|
-
|
|
8
|
-
module Html2rss
|
|
9
|
-
class RequestService
|
|
10
|
-
##
|
|
11
|
-
# Strategy to use Faraday for the request.
|
|
12
|
-
# @see https://rubygems.org/gems/faraday
|
|
13
|
-
# rubocop:disable-next Metrics/ClassLength -- terminal redirect retry colocated with Faraday transport
|
|
14
|
-
class FaradayStrategy < Strategy
|
|
15
|
-
##
|
|
16
|
-
# Restores buffered streamed bytes so response middleware can process them.
|
|
17
|
-
class StreamingBodyMiddleware < Faraday::Middleware
|
|
18
|
-
# Request-context key used to store streamed chunks before middleware completion.
|
|
19
|
-
STREAM_BUFFER_KEY = :html2rss_stream_buffer
|
|
20
|
-
|
|
21
|
-
# @param env [Faraday::Env] completed response environment
|
|
22
|
-
# @return [void]
|
|
23
|
-
def on_complete(env)
|
|
24
|
-
buffer = env.request.context&.delete(STREAM_BUFFER_KEY)
|
|
25
|
-
return if buffer.nil? || buffer.empty?
|
|
26
|
-
|
|
27
|
-
env.body = buffer
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
##
|
|
32
|
-
# @return [ResponseGuard]
|
|
33
|
-
attr_reader :response_guard
|
|
34
|
-
|
|
35
|
-
# Executes the request with runtime policy enforcement, returning the normalized response.
|
|
36
|
-
#
|
|
37
|
-
# @return [Response] normalized response
|
|
38
|
-
def perform_execute
|
|
39
|
-
deadline = request_deadline
|
|
40
|
-
@response_guard = ResponseGuard.new(policy: ctx.policy)
|
|
41
|
-
reset_redirect_tracking!
|
|
42
|
-
raw_response = request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
43
|
-
raw_response = retry_without_streaming(response_guard, deadline:) if retry_without_streaming?(raw_response)
|
|
44
|
-
build_response(raw_response)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def request_deadline
|
|
50
|
-
monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def reset_redirect_tracking!
|
|
54
|
-
@last_redirect_to = nil
|
|
55
|
-
@terminal_redirect_retried = false
|
|
56
|
-
@request_url_override = nil
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
60
|
-
faraday_request(response_guard, deadline:, streaming_buffer: true)
|
|
61
|
-
rescue Faraday::FollowRedirects::RedirectLimitReached => error
|
|
62
|
-
raise RedirectLimitReached, error.message unless terminal_redirect_retryable?
|
|
63
|
-
|
|
64
|
-
begin
|
|
65
|
-
retry_from_terminal_redirect!(response_guard, deadline:)
|
|
66
|
-
rescue Faraday::FollowRedirects::RedirectLimitReached => retry_error
|
|
67
|
-
raise RedirectLimitReached, retry_error.message
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def terminal_redirect_retryable?
|
|
72
|
-
return false if @terminal_redirect_retried || @last_redirect_to.nil?
|
|
73
|
-
|
|
74
|
-
@last_redirect_to.to_s != request_url.to_s
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def retry_from_terminal_redirect!(response_guard, deadline:)
|
|
78
|
-
terminal_url = @last_redirect_to
|
|
79
|
-
@terminal_redirect_retried = true
|
|
80
|
-
Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
|
|
81
|
-
begin_terminal_url_request!(terminal_url)
|
|
82
|
-
faraday_request(response_guard, deadline:, streaming_buffer: true, consume_budget: false)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def begin_terminal_url_request!(terminal_url)
|
|
86
|
-
ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
87
|
-
@request_url_override = terminal_url
|
|
88
|
-
@client = nil
|
|
89
|
-
@last_redirect_to = nil
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def request_url
|
|
93
|
-
@request_url_override || ctx.url
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def build_response(response)
|
|
97
|
-
Response.new(body: CompressedBody.decode(response.body, headers: response.headers),
|
|
98
|
-
headers: response.headers, url: response_url(response),
|
|
99
|
-
status: response.status)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def faraday_request(response_guard, deadline:, streaming_buffer:, consume_budget: true)
|
|
103
|
-
preflight!(consume_budget:)
|
|
104
|
-
|
|
105
|
-
client.get do |req|
|
|
106
|
-
apply_timeouts(req, deadline:)
|
|
107
|
-
next unless streaming_buffer
|
|
108
|
-
|
|
109
|
-
buffer = prepare_stream_buffer(req)
|
|
110
|
-
req.options.on_data = on_data_callback(response_guard, buffer)
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def retry_without_streaming(response_guard, deadline:)
|
|
115
|
-
faraday_request(response_guard, deadline:, streaming_buffer: false, consume_budget: false)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
##
|
|
119
|
-
# Validates the remote socket peer IP during Net::HTTP start.
|
|
120
|
-
module PeerIpValidator
|
|
121
|
-
module_function
|
|
122
|
-
|
|
123
|
-
# @param http [Net::HTTP] connection to configure
|
|
124
|
-
# @param policy [Policy] request policy
|
|
125
|
-
# @return [void]
|
|
126
|
-
def install!(http, policy:)
|
|
127
|
-
orig_start = http.method(:start)
|
|
128
|
-
http.define_singleton_method(:start) do |&block|
|
|
129
|
-
orig_start.call do |opened_http|
|
|
130
|
-
PeerIpValidator.validate!(opened_http, policy:)
|
|
131
|
-
block.call(opened_http)
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# @param opened_http [Net::HTTP] active connection
|
|
137
|
-
# @param policy [Policy] request policy
|
|
138
|
-
# @return [void]
|
|
139
|
-
def validate!(opened_http, policy:)
|
|
140
|
-
scheme = opened_http.use_ssl? ? 'https' : 'http'
|
|
141
|
-
url = Html2rss::Url.from_absolute("#{scheme}://#{opened_http.address}:#{opened_http.port}")
|
|
142
|
-
policy.validate_remote_ip!(ip: peer_ip_for(opened_http), url:)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# @param opened_http [Net::HTTP]
|
|
146
|
-
# @return [String, nil]
|
|
147
|
-
def peer_ip_for(opened_http)
|
|
148
|
-
sock = opened_http.instance_variable_get(:@socket)
|
|
149
|
-
io = sock.respond_to?(:io) ? sock.io : sock
|
|
150
|
-
peeraddr = io.respond_to?(:peeraddr) ? io.peeraddr : nil
|
|
151
|
-
peeraddr&.[](3) || peeraddr&.[](2)
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# rubocop:disable-next Metrics/AbcSize
|
|
156
|
-
def client
|
|
157
|
-
@client ||= Faraday.new(url: request_url.to_s, headers: ctx.headers) do |faraday|
|
|
158
|
-
faraday.use Faraday::FollowRedirects::Middleware, limit: ctx.policy.max_redirects, callback: redirect_callback
|
|
159
|
-
faraday.request :gzip
|
|
160
|
-
faraday.use StreamingBodyMiddleware
|
|
161
|
-
faraday.adapter Faraday.default_adapter do |http|
|
|
162
|
-
PeerIpValidator.install!(http, policy: ctx.policy)
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def apply_timeouts(request, deadline:)
|
|
168
|
-
remaining_timeout = remaining_timeout_seconds(deadline)
|
|
169
|
-
request.options.timeout = remaining_timeout
|
|
170
|
-
request.options.open_timeout = [ctx.policy.connect_timeout_seconds, remaining_timeout].min
|
|
171
|
-
request.options.read_timeout = [ctx.policy.read_timeout_seconds, remaining_timeout].min
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def prepare_stream_buffer(request)
|
|
175
|
-
request.options.context ||= {}
|
|
176
|
-
request.options.context[StreamingBodyMiddleware::STREAM_BUFFER_KEY] = +''
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def on_data_callback(response_guard, buffer)
|
|
180
|
-
proc do |chunk, total_bytes, env|
|
|
181
|
-
response_guard.inspect_chunk!(total_bytes:, headers: env&.response_headers)
|
|
182
|
-
buffer&.<< chunk
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def remaining_timeout_seconds(deadline)
|
|
187
|
-
remaining = deadline - monotonic_now
|
|
188
|
-
raise RequestTimedOut, 'Request timed out' if remaining <= 0
|
|
189
|
-
|
|
190
|
-
remaining
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def retry_without_streaming?(response)
|
|
194
|
-
return false if response.body.to_s.empty? == false
|
|
195
|
-
return false unless response_success?(response)
|
|
196
|
-
|
|
197
|
-
final_url = response.env&.url
|
|
198
|
-
return false unless final_url
|
|
199
|
-
|
|
200
|
-
final_url.to_s != ctx.url.to_s
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def response_success?(response)
|
|
204
|
-
return true if response.status.nil?
|
|
205
|
-
|
|
206
|
-
response.status >= 200 && response.status < 300
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def response_url(response)
|
|
210
|
-
return ctx.url unless (url = response.env&.url)
|
|
211
|
-
|
|
212
|
-
Html2rss::Url.from_absolute(url.to_s)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def redirect_callback
|
|
216
|
-
lambda do |old_env, new_env|
|
|
217
|
-
from_url = normalize_url(old_env[:url])
|
|
218
|
-
to_url = normalize_url(new_env[:url])
|
|
219
|
-
@last_redirect_to = to_url
|
|
220
|
-
ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def normalize_url(url)
|
|
225
|
-
Html2rss::Url.from_absolute(url.to_s)
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def monotonic_now
|
|
229
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
end
|
|
233
|
-
end
|