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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httpx'
|
|
4
|
+
require 'httpx/plugins/follow_redirects'
|
|
5
|
+
require 'httpx/plugins/callbacks'
|
|
6
|
+
require 'httpx/plugins/ssrf_filter'
|
|
7
|
+
require 'httpx/plugins/brotli'
|
|
8
|
+
require 'httpx/plugins/fiber_concurrency'
|
|
9
|
+
require 'httpx/plugins/retries'
|
|
10
|
+
|
|
11
|
+
module Html2rss
|
|
12
|
+
class RequestService
|
|
13
|
+
##
|
|
14
|
+
# Strategy to use HTTPX for HTTP requests.
|
|
15
|
+
# Provides native HTTP/2 with ALPN, built-in SSRF protection, streaming byte limits,
|
|
16
|
+
# and redirect handling without monkey-patching.
|
|
17
|
+
# rubocop:disable-next Metrics/ClassLength -- terminal redirect retry colocated with HTTPX transport
|
|
18
|
+
class HttpxStrategy < Strategy
|
|
19
|
+
class << self
|
|
20
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
21
|
+
# @return [HTTPX::Session]
|
|
22
|
+
def base_session
|
|
23
|
+
@base_sessions ||= {}
|
|
24
|
+
@base_sessions[HTTPX::Session] ||= HTTPX
|
|
25
|
+
.plugin(:follow_redirects)
|
|
26
|
+
.plugin(:callbacks)
|
|
27
|
+
.plugin(:brotli)
|
|
28
|
+
.plugin(:fiber_concurrency)
|
|
29
|
+
.plugin(:retries)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [HTTPX::Session]
|
|
33
|
+
def base_ssrf_session
|
|
34
|
+
@base_ssrf_sessions ||= {}
|
|
35
|
+
@base_ssrf_sessions[HTTPX::Session] ||= base_session.plugin(:ssrf_filter)
|
|
36
|
+
end
|
|
37
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# @return [ResponseGuard]
|
|
42
|
+
attr_reader :response_guard
|
|
43
|
+
|
|
44
|
+
# Executes the request with runtime policy enforcement, returning the normalized response.
|
|
45
|
+
#
|
|
46
|
+
# @return [Response] normalized response
|
|
47
|
+
def perform_execute
|
|
48
|
+
deadline = request_deadline
|
|
49
|
+
@response_guard = ResponseGuard.new(policy: ctx.policy)
|
|
50
|
+
reset_redirect_tracking!
|
|
51
|
+
raw_response = request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
52
|
+
build_response(raw_response)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def request_deadline
|
|
58
|
+
monotonic_now + ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reset_redirect_tracking!
|
|
62
|
+
@last_redirect_to = nil
|
|
63
|
+
@terminal_redirect_retried = false
|
|
64
|
+
@request_url_override = nil
|
|
65
|
+
@current_url = request_url
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def request_with_terminal_redirect_retry(response_guard, deadline:)
|
|
69
|
+
raw_response = execute_http_request(response_guard, deadline:)
|
|
70
|
+
return raw_response unless redirect_limit_reached?(raw_response)
|
|
71
|
+
|
|
72
|
+
unless terminal_redirect_retryable?(raw_response)
|
|
73
|
+
raise RedirectLimitReached, "Too many redirects (status #{raw_response.status})"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
retry_from_terminal_redirect!(raw_response, response_guard, deadline:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def redirect_limit_reached?(response)
|
|
80
|
+
return false unless response.is_a?(HTTPX::Response)
|
|
81
|
+
return false if response.status == 304
|
|
82
|
+
return false unless (300..399).cover?(response.status)
|
|
83
|
+
|
|
84
|
+
location = response.headers['location']
|
|
85
|
+
!location.nil? && !location.strip.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def terminal_redirect_url(raw_response)
|
|
89
|
+
location = raw_response.headers['location']
|
|
90
|
+
if location && !location.empty?
|
|
91
|
+
base = raw_response.uri || request_url
|
|
92
|
+
return normalize_url(URI.join(base.to_s, location))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@last_redirect_to
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def terminal_redirect_retryable?(raw_response)
|
|
99
|
+
return false if @terminal_redirect_retried
|
|
100
|
+
|
|
101
|
+
target = terminal_redirect_url(raw_response)
|
|
102
|
+
target && target.to_s != request_url.to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def retry_from_terminal_redirect!(raw_response, response_guard, deadline:)
|
|
106
|
+
terminal_url = terminal_redirect_url(raw_response)
|
|
107
|
+
@terminal_redirect_retried = true
|
|
108
|
+
Log.debug("#{self.class}: redirect limit reached; retrying once from #{terminal_url}")
|
|
109
|
+
begin_terminal_url_request!(terminal_url)
|
|
110
|
+
new_response = execute_http_request(response_guard, deadline:, consume_budget: false)
|
|
111
|
+
if redirect_limit_reached?(new_response)
|
|
112
|
+
raise RedirectLimitReached, "Too many redirects (status #{new_response.status})"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
new_response
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def begin_terminal_url_request!(terminal_url)
|
|
119
|
+
ctx.policy.validate_request!(url: terminal_url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
120
|
+
@request_url_override = terminal_url
|
|
121
|
+
@last_redirect_to = nil
|
|
122
|
+
@current_url = terminal_url
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def request_url
|
|
126
|
+
@request_url_override || ctx.url
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def execute_http_request(response_guard, deadline:, consume_budget: true)
|
|
130
|
+
preflight!(consume_budget:)
|
|
131
|
+
session = build_session(response_guard, deadline:)
|
|
132
|
+
response = session.get(request_url.to_s, headers: ctx.headers)
|
|
133
|
+
raise response.error if response.is_a?(HTTPX::ErrorResponse)
|
|
134
|
+
|
|
135
|
+
response
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_session(_response_guard, deadline:)
|
|
139
|
+
session_client(deadline)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def session_client(deadline)
|
|
143
|
+
session = ctx.policy.allow_private_networks? ? self.class.base_session : self.class.base_ssrf_session
|
|
144
|
+
session.with(session_options(deadline))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def session_options(deadline)
|
|
148
|
+
remaining = remaining_timeout_seconds(deadline)
|
|
149
|
+
{
|
|
150
|
+
timeout: session_timeouts(remaining),
|
|
151
|
+
max_redirects: ctx.policy.max_redirects,
|
|
152
|
+
follow_insecure_redirects: false,
|
|
153
|
+
max_response_body_size: ctx.policy.max_response_bytes,
|
|
154
|
+
resolver_class: :system,
|
|
155
|
+
redirect_on: redirect_callback
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def session_timeouts(remaining)
|
|
160
|
+
{
|
|
161
|
+
connect_timeout: [ctx.policy.connect_timeout_seconds, remaining].min,
|
|
162
|
+
read_timeout: [ctx.policy.read_timeout_seconds, remaining].min,
|
|
163
|
+
operation_timeout: remaining,
|
|
164
|
+
request_timeout: remaining,
|
|
165
|
+
total_request_timeout: remaining
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def redirect_callback
|
|
170
|
+
lambda do |redirect_uri|
|
|
171
|
+
to_url = normalize_url(redirect_uri)
|
|
172
|
+
from_url = @current_url
|
|
173
|
+
@last_redirect_to = to_url
|
|
174
|
+
ctx.policy.validate_redirect!(from_url:, to_url:, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
175
|
+
@current_url = to_url
|
|
176
|
+
true
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def build_response(response)
|
|
181
|
+
headers = response.headers.to_h
|
|
182
|
+
Response.new(
|
|
183
|
+
body: CompressedBody.decode(response.body.to_s, headers:),
|
|
184
|
+
headers:,
|
|
185
|
+
url: response_url(response),
|
|
186
|
+
status: response.status
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def response_url(response)
|
|
191
|
+
return ctx.url unless (uri = response.uri)
|
|
192
|
+
|
|
193
|
+
Html2rss::Url.from_absolute(uri.to_s)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalize_url(url)
|
|
197
|
+
Html2rss::Url.from_absolute(url.to_s)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def remaining_timeout_seconds(deadline)
|
|
201
|
+
remaining = deadline - monotonic_now
|
|
202
|
+
raise RequestTimedOut, 'Request timed out' if remaining <= 0
|
|
203
|
+
|
|
204
|
+
remaining
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def monotonic_now
|
|
208
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def timeout_error?(error)
|
|
212
|
+
error.is_a?(HTTPX::TimeoutError) || super
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def ssrf_error?(error)
|
|
216
|
+
error.is_a?(HTTPX::ServerSideRequestForgeryError) || super
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def response_too_large_error?(error)
|
|
220
|
+
(error.is_a?(HTTPX::Error) && error.message.include?('maximum response body size exceeded')) || super
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def connection_error?(error)
|
|
224
|
+
error.is_a?(HTTPX::ConnectionError) || error.is_a?(HTTPX::TLSError) || super
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -9,22 +9,6 @@ module Html2rss
|
|
|
9
9
|
# @param policy [Policy] request policy that defines byte ceilings
|
|
10
10
|
def initialize(policy:)
|
|
11
11
|
@policy = policy
|
|
12
|
-
@streamed_bytes = 0
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
##
|
|
16
|
-
# Validates response headers and streamed byte count.
|
|
17
|
-
#
|
|
18
|
-
# @param total_bytes [Integer] cumulative byte count received so far
|
|
19
|
-
# @param headers [Hash, nil] response headers if known
|
|
20
|
-
# @return [void]
|
|
21
|
-
# @raise [ResponseTooLarge] if the response exceeds configured limits
|
|
22
|
-
def inspect_chunk!(total_bytes:, headers: nil)
|
|
23
|
-
header_length = headers&.fetch('content-length', headers&.fetch('Content-Length', nil))
|
|
24
|
-
raise_if_too_large!(header_length.to_i, policy.max_response_bytes) if header_length
|
|
25
|
-
|
|
26
|
-
@streamed_bytes = total_bytes
|
|
27
|
-
raise_if_too_large!(@streamed_bytes, policy.max_response_bytes)
|
|
28
12
|
end
|
|
29
13
|
|
|
30
14
|
##
|
|
@@ -97,11 +97,16 @@ module Html2rss
|
|
|
97
97
|
# @param error [StandardError]
|
|
98
98
|
# @return [void]
|
|
99
99
|
# @raise [StandardError]
|
|
100
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- error translation dispatch
|
|
100
101
|
def handle_error(error)
|
|
101
102
|
if timeout_error?(error)
|
|
102
103
|
log_timeout!(reason: 'transport')
|
|
103
104
|
Log.debug("#{self.class}: transport timeout message=#{error.message}")
|
|
104
105
|
raise RequestTimedOut, error.message
|
|
106
|
+
elsif ssrf_error?(error)
|
|
107
|
+
raise PrivateNetworkDenied, error.message
|
|
108
|
+
elsif response_too_large_error?(error)
|
|
109
|
+
raise ResponseTooLarge, "Response exceeded #{ctx.policy.max_response_bytes} bytes"
|
|
105
110
|
elsif connection_error?(error)
|
|
106
111
|
translate_connection_error(error)
|
|
107
112
|
else
|
|
@@ -135,14 +140,25 @@ module Html2rss
|
|
|
135
140
|
# @param error [StandardError]
|
|
136
141
|
# @return [Boolean]
|
|
137
142
|
def timeout_error?(error)
|
|
138
|
-
error.is_a?(
|
|
139
|
-
|
|
143
|
+
error.is_a?(Timeout::Error)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @param _error [StandardError]
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def ssrf_error?(_error)
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @param _error [StandardError]
|
|
153
|
+
# @return [Boolean]
|
|
154
|
+
def response_too_large_error?(_error)
|
|
155
|
+
false
|
|
140
156
|
end
|
|
141
157
|
|
|
142
158
|
# @param error [StandardError]
|
|
143
159
|
# @return [Boolean]
|
|
144
160
|
def connection_error?(error)
|
|
145
|
-
error.is_a?(
|
|
161
|
+
error.is_a?(SocketError) || error.is_a?(SystemCallError)
|
|
146
162
|
end
|
|
147
163
|
end
|
|
148
164
|
end
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'singleton'
|
|
4
|
-
require 'forwardable'
|
|
5
|
-
|
|
6
3
|
module Html2rss
|
|
7
4
|
##
|
|
8
5
|
# Requests website URLs to retrieve their HTML for further processing.
|
|
9
|
-
# Provides concrete transport strategies (e.g.
|
|
6
|
+
# Provides concrete transport strategies (e.g. HttpxStrategy, BotasaurusStrategy).
|
|
10
7
|
#
|
|
11
8
|
# Feed-level +:auto+ is not registered here — {FeedPipeline::StrategyPlan} resolves
|
|
12
9
|
# it to a concrete strategy (or {FeedPipeline::AutoFallback} chain) before execute.
|
|
13
10
|
class RequestService
|
|
14
|
-
include Singleton
|
|
15
|
-
|
|
16
11
|
# Raised when an unknown request strategy is requested.
|
|
17
12
|
class UnknownStrategy < Html2rss::Error; end
|
|
18
13
|
# Raised when a URL cannot be parsed or validated.
|
|
@@ -59,94 +54,60 @@ module Html2rss
|
|
|
59
54
|
# Raised when Botasaurus responds but the scrape fails (upstream error, bad payload).
|
|
60
55
|
class BotasaurusServiceError < Html2rss::Error; end
|
|
61
56
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
execute].each do |method|
|
|
72
|
-
def_delegator :instance, method
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def initialize
|
|
77
|
-
@strategies = {
|
|
78
|
-
faraday: FaradayStrategy,
|
|
79
|
-
botasaurus: BotasaurusStrategy,
|
|
80
|
-
local_file: LocalFileStrategy
|
|
81
|
-
}
|
|
82
|
-
@default_strategy_name = :faraday
|
|
83
|
-
end
|
|
57
|
+
# Map of supported strategy names to their implementation classes.
|
|
58
|
+
# @return [Hash{Symbol => Class<Strategy>}]
|
|
59
|
+
STRATEGIES = {
|
|
60
|
+
default: HttpxStrategy,
|
|
61
|
+
httpx: HttpxStrategy,
|
|
62
|
+
faraday: HttpxStrategy,
|
|
63
|
+
botasaurus: BotasaurusStrategy,
|
|
64
|
+
local_file: LocalFileStrategy
|
|
65
|
+
}.freeze
|
|
84
66
|
|
|
85
|
-
#
|
|
86
|
-
|
|
67
|
+
# Canonical default strategy symbol.
|
|
68
|
+
# @return [Symbol]
|
|
69
|
+
DEFAULT_STRATEGY_NAME = :default
|
|
87
70
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# @return [Symbol] the selected default strategy name
|
|
92
|
-
# @raise [UnknownStrategy] if the strategy is not registered
|
|
93
|
-
def default_strategy_name=(strategy)
|
|
94
|
-
raise UnknownStrategy unless strategy_registered?(strategy)
|
|
95
|
-
|
|
96
|
-
@default_strategy_name = strategy.to_sym
|
|
97
|
-
end
|
|
71
|
+
class << self
|
|
72
|
+
# @return [Symbol] the default strategy name
|
|
73
|
+
def default_strategy_name = DEFAULT_STRATEGY_NAME
|
|
98
74
|
|
|
99
|
-
|
|
100
|
-
|
|
75
|
+
# @return [Array<String>] the names of the registered strategies
|
|
76
|
+
def strategy_names = STRATEGIES.keys.map(&:to_s)
|
|
101
77
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def register_strategy(name, strategy_class)
|
|
109
|
-
unless strategy_class.is_a?(Class)
|
|
110
|
-
raise ArgumentError, "Expected a Class for strategy, got #{strategy_class.class}"
|
|
78
|
+
##
|
|
79
|
+
# Checks if a strategy is registered.
|
|
80
|
+
# @param name [Symbol, String] the name of the strategy
|
|
81
|
+
# @return [Boolean] true if the strategy is registered, false otherwise.
|
|
82
|
+
def strategy_registered?(name)
|
|
83
|
+
STRATEGIES.key?(name.to_sym)
|
|
111
84
|
end
|
|
112
85
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def unregister_strategy(name) # rubocop:disable Naming/PredicateMethod
|
|
130
|
-
name_sym = name.to_sym
|
|
131
|
-
raise ArgumentError, 'Cannot unregister the default strategy.' if name_sym == @default_strategy_name
|
|
86
|
+
##
|
|
87
|
+
# Executes the request using the specified strategy.
|
|
88
|
+
# @param ctx [Context] the context for the request.
|
|
89
|
+
# @param strategy [Symbol, String] the strategy to use (defaults to the default strategy).
|
|
90
|
+
# @return [Response] the response from the executed strategy.
|
|
91
|
+
# @raise [UnknownStrategy] if the strategy is not registered.
|
|
92
|
+
def execute(ctx, strategy: default_strategy_name)
|
|
93
|
+
strategy_sym = strategy.to_sym
|
|
94
|
+
strategy_class = STRATEGIES.fetch(strategy_sym) do
|
|
95
|
+
raise UnknownStrategy,
|
|
96
|
+
"The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
warn_migration_strategy(strategy_sym) if strategy_sym == :faraday
|
|
100
|
+
strategy_class.new(ctx).execute
|
|
101
|
+
end
|
|
132
102
|
|
|
133
|
-
|
|
134
|
-
end
|
|
103
|
+
private
|
|
135
104
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# @raise [ArgumentError] if the context is nil.
|
|
142
|
-
# @raise [UnknownStrategy] if the strategy is not registered.
|
|
143
|
-
def execute(ctx, strategy: default_strategy_name)
|
|
144
|
-
strategy_class = @strategies.fetch(strategy.to_sym) do
|
|
145
|
-
raise UnknownStrategy,
|
|
146
|
-
"The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
|
|
105
|
+
def warn_migration_strategy(strategy)
|
|
106
|
+
message = "RequestService: strategy ':#{strategy}' is deprecated for migration and will be removed " \
|
|
107
|
+
"in a future release. Use ':default' instead."
|
|
108
|
+
warn(message, category: :deprecated)
|
|
109
|
+
Log.warn(message)
|
|
147
110
|
end
|
|
148
|
-
|
|
149
|
-
strategy_class.new(ctx).execute
|
|
150
111
|
end
|
|
151
112
|
end
|
|
152
113
|
end
|
data/lib/html2rss/test.rb
CHANGED
|
@@ -197,7 +197,8 @@ module Html2rss
|
|
|
197
197
|
|
|
198
198
|
channel_title = feed_result.channel_title
|
|
199
199
|
channel_url = raw_config.dig(:channel, :url).to_s
|
|
200
|
-
strategy_used = feed_result.status.selected_strategy || raw_config[:strategy] ||
|
|
200
|
+
strategy_used = feed_result.status.selected_strategy || raw_config[:strategy] ||
|
|
201
|
+
RequestService.default_strategy_name
|
|
201
202
|
min_items_passed = item_count >= min_items
|
|
202
203
|
quality_failed = strict_quality && min_items_passed && quality_failure?(quality_report)
|
|
203
204
|
passed = min_items_passed && !quality_failed
|
data/lib/html2rss/version.rb
CHANGED
data/lib/html2rss.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
33
33
|
# Golden path step 1 (optional); use {.recon} for verdict and native_feed.
|
|
34
34
|
#
|
|
35
35
|
# @param url [String] source page URL
|
|
36
|
-
# @param strategy [Symbol] request strategy (:auto, :
|
|
36
|
+
# @param strategy [Symbol] request strategy (:auto, :default, :botasaurus)
|
|
37
37
|
# @param deep [Boolean] when true and strategy is :auto, one Botasaurus hop if configured
|
|
38
38
|
# @return [Html2rss::PageRecon::Diagnostics::Report]
|
|
39
39
|
def self.inspect(url, strategy: :auto, deep: false, **)
|
|
@@ -45,7 +45,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
45
45
|
# Golden path step 2 (optional); adds verdict beyond {.inspect}.
|
|
46
46
|
#
|
|
47
47
|
# @param url [String, Html2rss::Url] source page URL
|
|
48
|
-
# @param strategy [Symbol] request strategy (:auto, :
|
|
48
|
+
# @param strategy [Symbol] request strategy (:auto, :default, :botasaurus)
|
|
49
49
|
# @return [Html2rss::Recon::Result]
|
|
50
50
|
def self.recon(url, strategy: :auto, **)
|
|
51
51
|
Recon.call(url, strategy:, **)
|
|
@@ -56,7 +56,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
56
56
|
# Golden path step 3.
|
|
57
57
|
#
|
|
58
58
|
# @param url [String] source page URL
|
|
59
|
-
# @param strategy [Symbol] request strategy (+:auto+, +:
|
|
59
|
+
# @param strategy [Symbol] request strategy (+:auto+, +:default+, +:botasaurus+)
|
|
60
60
|
# @option options [String, nil] :items_selector optional CSS selector hint for items
|
|
61
61
|
# @option options [Array<String>, nil] :topics optional directory topics override
|
|
62
62
|
# @option options [String, nil] :title optional title override
|
|
@@ -140,7 +140,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
140
140
|
# Scrapes multiple URLs in parallel using auto-source article discovery.
|
|
141
141
|
#
|
|
142
142
|
# @param urls [Enumerable<String>] list of URLs to scrape
|
|
143
|
-
# @param strategy [Symbol] request strategy (:auto, :
|
|
143
|
+
# @param strategy [Symbol] request strategy (:auto, :default, :botasaurus)
|
|
144
144
|
# @param limit [Integer] max articles to keep per URL (default: 10)
|
|
145
145
|
# @param concurrency [Integer] max worker threads (default: 5)
|
|
146
146
|
# @return [Html2rss::Batch::BatchResult]
|
|
@@ -152,7 +152,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
152
152
|
# Inspects multiple URLs in parallel with per-URL error isolation.
|
|
153
153
|
#
|
|
154
154
|
# @param urls [Enumerable<String>] list of URLs to inspect
|
|
155
|
-
# @param strategy [Symbol] request strategy (:auto, :
|
|
155
|
+
# @param strategy [Symbol] request strategy (:auto, :default, :botasaurus)
|
|
156
156
|
# @param concurrency [Integer] max worker threads (default: 5)
|
|
157
157
|
# @return [Html2rss::Batch::BatchResult]
|
|
158
158
|
def self.batch_inspect(urls, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
|
|
@@ -163,7 +163,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
|
|
|
163
163
|
# Runs recon across multiple URLs in parallel with per-URL error isolation.
|
|
164
164
|
#
|
|
165
165
|
# @param urls [Enumerable<String>] list of URLs to recon
|
|
166
|
-
# @param strategy [Symbol] request strategy (:auto, :
|
|
166
|
+
# @param strategy [Symbol] request strategy (:auto, :default, :botasaurus)
|
|
167
167
|
# @param concurrency [Integer] max worker threads (default: 5)
|
|
168
168
|
# @option options [String, nil] :cache_dir optional HTML cache directory
|
|
169
169
|
# @return [Html2rss::Batch::BatchResult]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: html2rss
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gil Desmarais
|
|
@@ -52,53 +52,19 @@ dependencies:
|
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '0'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
|
-
name:
|
|
56
|
-
requirement: !ruby/object:Gem::Requirement
|
|
57
|
-
requirements:
|
|
58
|
-
- - ">"
|
|
59
|
-
- !ruby/object:Gem::Version
|
|
60
|
-
version: 2.0.1
|
|
61
|
-
- - "<"
|
|
62
|
-
- !ruby/object:Gem::Version
|
|
63
|
-
version: '3.0'
|
|
64
|
-
type: :runtime
|
|
65
|
-
prerelease: false
|
|
66
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
67
|
-
requirements:
|
|
68
|
-
- - ">"
|
|
69
|
-
- !ruby/object:Gem::Version
|
|
70
|
-
version: 2.0.1
|
|
71
|
-
- - "<"
|
|
72
|
-
- !ruby/object:Gem::Version
|
|
73
|
-
version: '3.0'
|
|
74
|
-
- !ruby/object:Gem::Dependency
|
|
75
|
-
name: faraday-follow_redirects
|
|
76
|
-
requirement: !ruby/object:Gem::Requirement
|
|
77
|
-
requirements:
|
|
78
|
-
- - ">="
|
|
79
|
-
- !ruby/object:Gem::Version
|
|
80
|
-
version: '0'
|
|
81
|
-
type: :runtime
|
|
82
|
-
prerelease: false
|
|
83
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
-
requirements:
|
|
85
|
-
- - ">="
|
|
86
|
-
- !ruby/object:Gem::Version
|
|
87
|
-
version: '0'
|
|
88
|
-
- !ruby/object:Gem::Dependency
|
|
89
|
-
name: faraday-gzip
|
|
55
|
+
name: httpx
|
|
90
56
|
requirement: !ruby/object:Gem::Requirement
|
|
91
57
|
requirements:
|
|
92
58
|
- - "~>"
|
|
93
59
|
- !ruby/object:Gem::Version
|
|
94
|
-
version: '
|
|
60
|
+
version: '1.8'
|
|
95
61
|
type: :runtime
|
|
96
62
|
prerelease: false
|
|
97
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
98
64
|
requirements:
|
|
99
65
|
- - "~>"
|
|
100
66
|
- !ruby/object:Gem::Version
|
|
101
|
-
version: '
|
|
67
|
+
version: '1.8'
|
|
102
68
|
- !ruby/object:Gem::Dependency
|
|
103
69
|
name: kramdown
|
|
104
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -431,7 +397,7 @@ files:
|
|
|
431
397
|
- lib/html2rss/request_service/budget.rb
|
|
432
398
|
- lib/html2rss/request_service/compressed_body.rb
|
|
433
399
|
- lib/html2rss/request_service/context.rb
|
|
434
|
-
- lib/html2rss/request_service/
|
|
400
|
+
- lib/html2rss/request_service/httpx_strategy.rb
|
|
435
401
|
- lib/html2rss/request_service/local_file_strategy.rb
|
|
436
402
|
- lib/html2rss/request_service/network_guard.rb
|
|
437
403
|
- lib/html2rss/request_service/policy.rb
|
|
@@ -502,7 +468,7 @@ licenses:
|
|
|
502
468
|
- MIT
|
|
503
469
|
metadata:
|
|
504
470
|
allowed_push_host: https://rubygems.org
|
|
505
|
-
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.
|
|
471
|
+
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.30.0
|
|
506
472
|
rubygems_mfa_required: 'true'
|
|
507
473
|
rdoc_options: []
|
|
508
474
|
require_paths:
|