html2rss 0.32.3 → 0.32.4
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/lib/html2rss/error.rb +49 -0
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +56 -53
- data/lib/html2rss/request_service/local_file_strategy.rb +3 -0
- data/lib/html2rss/request_service/strategy.rb +2 -2
- data/lib/html2rss/request_service.rb +26 -2
- data/lib/html2rss/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a53d8374b6b4ce7551c9f1bffd0e14960995f7df7380554f1292e0a70317cf58
|
|
4
|
+
data.tar.gz: 90e91d4275c03c120924cca66230a7aecb3606f2f50c6224d019c138cae244e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2543d4431b2790c89c19ab207d0cdbbc7416493ea1184347746bc26e21068415d68f025e0cc253082224ca5191a3984163e3e48ef3e727cb3b78c19cddd3d64
|
|
7
|
+
data.tar.gz: ac588fece229b58ab2ea4de91bb467fdbfaf199f43febfd8be6b39d817fc9b9b2867c21cb472949e886ad4dd50b436d9c5c149c0976a87b8cd67d09a6a62f9f6
|
data/lib/html2rss/error.rb
CHANGED
|
@@ -3,11 +3,60 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
# The Html2rss::Error base class.
|
|
5
5
|
class Error < StandardError
|
|
6
|
+
# Upstream transport_meta keys promoted into error diagnostics.
|
|
7
|
+
TRANSPORT_META_KEYS = %i[request_id strategy_used render_ms error_category timeout_phase].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
##
|
|
11
|
+
# Builds a sparse diagnostics hash from attempt hashes and optional error metadata.
|
|
12
|
+
#
|
|
13
|
+
# @param attempts [Array<Hash>, nil] tier attempt diagnostics
|
|
14
|
+
# @param transport_meta [Hash, nil] direct error transport metadata
|
|
15
|
+
# @param timeout_phase [String, nil] timeout stage when known
|
|
16
|
+
# @param surface_category [Symbol, nil] page surface classification when known
|
|
17
|
+
# @return [Hash{Symbol => Object}]
|
|
18
|
+
def diagnostics_from_attempts(attempts, transport_meta: nil, timeout_phase: nil, surface_category: nil)
|
|
19
|
+
list = Array(attempts)
|
|
20
|
+
meta = list.filter_map { |item| item[:transport_meta] || item['transport_meta'] }.last || transport_meta
|
|
21
|
+
fields = extract_transport_fields(meta)
|
|
22
|
+
fields[:timeout_phase] ||= timeout_phase
|
|
23
|
+
fields[:surface_category] = surface_category
|
|
24
|
+
{ strategy_attempts: (list if list.any?), **fields }.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def extract_transport_fields(meta)
|
|
30
|
+
return {} unless meta
|
|
31
|
+
|
|
32
|
+
TRANSPORT_META_KEYS.to_h { |key| [key, meta[key] || meta[key.to_s]] }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
6
36
|
# @return [Array<Hash{Symbol => Object}>, nil] tier attempt diagnostics when set by AutoFallback
|
|
7
37
|
attr_accessor :attempts
|
|
8
38
|
|
|
9
39
|
# @return [Hash{String => Object}, nil] allowlisted upstream transport telemetry when attached
|
|
10
40
|
attr_accessor :transport_meta
|
|
41
|
+
|
|
42
|
+
# @return [String, nil] scrape-api timeout stage when known (overridden by RequestTimedOut)
|
|
43
|
+
def timeout_phase = nil
|
|
44
|
+
|
|
45
|
+
# @return [Symbol, nil] surface classification when known (overridden by NoFeedItemsExtracted)
|
|
46
|
+
def surface_category = nil
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Sparse telemetry hash for observability consumers.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash{Symbol => Object}]
|
|
52
|
+
def diagnostics
|
|
53
|
+
self.class.diagnostics_from_attempts(
|
|
54
|
+
attempts,
|
|
55
|
+
transport_meta:,
|
|
56
|
+
timeout_phase:,
|
|
57
|
+
surface_category:
|
|
58
|
+
)
|
|
59
|
+
end
|
|
11
60
|
end
|
|
12
61
|
|
|
13
62
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
@@ -37,7 +37,7 @@ Retries share the feed's request/session policy. Pin a concrete strategy when yo
|
|
|
37
37
|
|
|
38
38
|
### Terminal fallback outcome
|
|
39
39
|
|
|
40
|
-
A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut` or `
|
|
40
|
+
A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut`, `BlockedSurfaceDetected`, or `ConnectionFailed`). `AutoFallback` remembers that error across later hops and, when no later hop yields items, re-raises it with `attempts` attached. A later hop that extracts items still succeeds. Two zero-item hops with no terminal transport error still raise `NoFeedItemsExtracted`. True empty stays the web policy already in force: listing `EXTRACTION_EMPTY` is cacheable; `app_shell` and `high_entropy_surface` are not. `html2rss-web` `ErrorClassifier` maps the raised error to HTTP/cache policy; it does not invent a second cache rule for empty extraction.
|
|
41
41
|
|
|
42
42
|
## Success signals
|
|
43
43
|
|
|
@@ -32,7 +32,8 @@ module Html2rss
|
|
|
32
32
|
# decisions instead of EXTRACTION_EMPTY.
|
|
33
33
|
TERMINAL_TRANSPORT_ERRORS = [
|
|
34
34
|
RequestService::RequestTimedOut,
|
|
35
|
-
RequestService::BlockedSurfaceDetected
|
|
35
|
+
RequestService::BlockedSurfaceDetected,
|
|
36
|
+
RequestService::ConnectionFailed
|
|
36
37
|
].freeze
|
|
37
38
|
|
|
38
39
|
##
|
|
@@ -79,12 +80,11 @@ module Html2rss
|
|
|
79
80
|
|
|
80
81
|
# @param strategy [Symbol] strategy that raised
|
|
81
82
|
# @param error [Exception] caught error
|
|
83
|
+
# @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
|
|
82
84
|
# @return [void]
|
|
83
|
-
def record_error(strategy:, error:)
|
|
84
|
-
attempt = { strategy:, items_count: nil, error_class: error.class.name }
|
|
85
|
+
def record_error(strategy:, error:, elapsed_ms: nil)
|
|
85
86
|
meta = error.is_a?(Html2rss::Error) ? error.transport_meta : nil
|
|
86
|
-
|
|
87
|
-
@attempts << attempt
|
|
87
|
+
append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
|
|
88
88
|
@last_error = error
|
|
89
89
|
return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
90
90
|
|
|
@@ -94,12 +94,11 @@ module Html2rss
|
|
|
94
94
|
# @param strategy [Symbol] strategy that returned a response
|
|
95
95
|
# @param items_count [Integer] extracted article count
|
|
96
96
|
# @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
|
|
97
|
+
# @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
|
|
97
98
|
# @return [void]
|
|
98
|
-
def record_items(strategy:, items_count:, transport_meta: nil)
|
|
99
|
+
def record_items(strategy:, items_count:, transport_meta: nil, elapsed_ms: nil)
|
|
99
100
|
@last_error = nil
|
|
100
|
-
|
|
101
|
-
attempt[:transport_meta] = transport_meta if transport_meta&.any?
|
|
102
|
-
@attempts << attempt
|
|
101
|
+
append_attempt(strategy:, items_count:, transport_meta:, elapsed_ms:)
|
|
103
102
|
end
|
|
104
103
|
|
|
105
104
|
# @param response [RequestService::Response] last response that reached extraction
|
|
@@ -131,6 +130,37 @@ module Html2rss
|
|
|
131
130
|
entry_resolution:
|
|
132
131
|
)
|
|
133
132
|
end
|
|
133
|
+
|
|
134
|
+
# Raises the terminal transport error, last error, or NoFeedItemsExtracted with attempts attached.
|
|
135
|
+
#
|
|
136
|
+
# @return [void] always raises
|
|
137
|
+
# @raise [Html2rss::Error, StandardError]
|
|
138
|
+
def raise_failure!
|
|
139
|
+
if (error = failure_error)
|
|
140
|
+
error.attempts = attempts if error.is_a?(Html2rss::Error)
|
|
141
|
+
raise error
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
response = @last_response
|
|
145
|
+
surface_category = PageRecon.surface_category_for(response:, url: response.url) if response
|
|
146
|
+
raise NoFeedItemsExtracted.new(attempts:, surface_category:)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def append_attempt(strategy:, items_count: nil, error_class: nil, transport_meta: nil, elapsed_ms: nil)
|
|
152
|
+
attempt = { strategy:, items_count:, error_class: }
|
|
153
|
+
attempt[:elapsed_ms] = elapsed_ms unless elapsed_ms.nil?
|
|
154
|
+
attempt[:transport_meta] = transport_meta if transport_meta&.any?
|
|
155
|
+
@attempts << attempt
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def failure_error
|
|
159
|
+
return @terminal_transport_error if @terminal_transport_error
|
|
160
|
+
return if @last_error.nil? || @last_error.is_a?(RequestService::BotasaurusConfigurationError)
|
|
161
|
+
|
|
162
|
+
@last_error
|
|
163
|
+
end
|
|
134
164
|
end
|
|
135
165
|
|
|
136
166
|
##
|
|
@@ -155,7 +185,7 @@ module Html2rss
|
|
|
155
185
|
state = run_attempts
|
|
156
186
|
return state.result if state.succeeded?
|
|
157
187
|
|
|
158
|
-
|
|
188
|
+
state.raise_failure!
|
|
159
189
|
end
|
|
160
190
|
|
|
161
191
|
private
|
|
@@ -171,37 +201,33 @@ module Html2rss
|
|
|
171
201
|
end
|
|
172
202
|
end
|
|
173
203
|
|
|
204
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- hop timing + NON_FALLBACK rescue stay co-located
|
|
174
205
|
def attempt(strategy:, next_strategy:, state:)
|
|
206
|
+
started_ms = monotonic_ms
|
|
175
207
|
request_session = pipeline.request_session_for(
|
|
176
208
|
config, strategy:, resources:, scrape_url: scrape_target.effective_url
|
|
177
209
|
)
|
|
178
|
-
response =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
188
|
-
request_session.fetch_initial_response
|
|
189
|
-
rescue *NON_FALLBACK_ERRORS
|
|
190
|
-
raise
|
|
210
|
+
response = request_session.fetch_initial_response
|
|
211
|
+
process_response(
|
|
212
|
+
response:, strategy:, next_strategy:, request_session:, state:,
|
|
213
|
+
elapsed_ms: monotonic_ms - started_ms
|
|
214
|
+
)
|
|
215
|
+
rescue *NON_FALLBACK_ERRORS => error
|
|
216
|
+
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
217
|
+
state.abort!
|
|
191
218
|
rescue StandardError => error
|
|
192
|
-
state.record_error(strategy:, error:)
|
|
193
|
-
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
|
|
194
|
-
nil
|
|
219
|
+
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
220
|
+
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy && request_session
|
|
195
221
|
end
|
|
196
222
|
|
|
197
|
-
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
|
|
198
|
-
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
223
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists -- extract + resolution delegate
|
|
224
|
+
def process_response(response:, strategy:, next_strategy:, request_session:, state:, elapsed_ms: nil)
|
|
199
225
|
articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
|
|
200
226
|
config:, response:, request_session:
|
|
201
227
|
)
|
|
202
228
|
state.remember_response(response)
|
|
203
229
|
items_count = articles.size
|
|
204
|
-
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
230
|
+
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta, elapsed_ms:)
|
|
205
231
|
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
206
232
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
207
233
|
"budget_remaining=#{budget_remaining_label}")
|
|
@@ -249,30 +275,7 @@ module Html2rss
|
|
|
249
275
|
}.to_json)
|
|
250
276
|
end
|
|
251
277
|
|
|
252
|
-
def
|
|
253
|
-
error = failure_error(state)
|
|
254
|
-
if error
|
|
255
|
-
error.attempts = state.attempts if error.is_a?(Html2rss::Error)
|
|
256
|
-
raise error
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
response = state.last_response
|
|
260
|
-
raise NoFeedItemsExtracted.new(
|
|
261
|
-
attempts: state.attempts,
|
|
262
|
-
surface_category: (PageRecon.surface_category_for(response:, url: response.url) if response)
|
|
263
|
-
)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
# Prefer a remembered timeout/blocked-surface over a later zero-item hop so
|
|
267
|
-
# callers do not cache EXTRACTION_EMPTY for a transport failure.
|
|
268
|
-
def failure_error(state)
|
|
269
|
-
return state.terminal_transport_error if state.terminal_transport_error
|
|
270
|
-
|
|
271
|
-
error = state.last_error
|
|
272
|
-
return if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
|
|
273
|
-
|
|
274
|
-
error
|
|
275
|
-
end
|
|
278
|
+
def monotonic_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
276
279
|
|
|
277
280
|
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- shared payload then info/warn dispatch
|
|
278
281
|
def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
|
|
@@ -15,6 +15,9 @@ module Html2rss
|
|
|
15
15
|
|
|
16
16
|
def skip_postflight? = true
|
|
17
17
|
|
|
18
|
+
# Local files never open a socket; keep filesystem errors as wire classes.
|
|
19
|
+
def connection_error?(_error) = false
|
|
20
|
+
|
|
18
21
|
##
|
|
19
22
|
# @return [Response] the mock response wrapped around the file contents
|
|
20
23
|
# @raise [ArgumentError] if the local file path is missing
|
|
@@ -133,9 +133,9 @@ module Html2rss
|
|
|
133
133
|
|
|
134
134
|
# @param error [StandardError]
|
|
135
135
|
# @return [void]
|
|
136
|
-
# @raise [
|
|
136
|
+
# @raise [ConnectionFailed]
|
|
137
137
|
def translate_connection_error(error)
|
|
138
|
-
raise error
|
|
138
|
+
raise ConnectionFailed, error.message
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
# @param error [StandardError]
|
|
@@ -28,6 +28,8 @@ module Html2rss
|
|
|
28
28
|
class RedirectLimitReached < Html2rss::Error; end
|
|
29
29
|
# Raised when blocked content surfaces are detected.
|
|
30
30
|
class BlockedSurfaceDetected < Html2rss::Error; end
|
|
31
|
+
# Raised when the target connection fails before a response document exists.
|
|
32
|
+
class ConnectionFailed < Html2rss::Error; end
|
|
31
33
|
|
|
32
34
|
# Raised when a request times out.
|
|
33
35
|
class RequestTimedOut < Html2rss::Error
|
|
@@ -76,6 +78,12 @@ module Html2rss
|
|
|
76
78
|
# @return [Symbol]
|
|
77
79
|
DEFAULT_STRATEGY_NAME = :default
|
|
78
80
|
|
|
81
|
+
# Maximum distinct [strategy, host] deprecation warnings tracked per process.
|
|
82
|
+
MAX_DEPRECATION_WARNINGS = 256
|
|
83
|
+
# Mutex guarding process-local deprecation warning deduplication.
|
|
84
|
+
DEPRECATION_MUTEX = Mutex.new
|
|
85
|
+
private_constant :DEPRECATION_MUTEX
|
|
86
|
+
|
|
79
87
|
class << self
|
|
80
88
|
# @return [Symbol] the default strategy name
|
|
81
89
|
def default_strategy_name = DEFAULT_STRATEGY_NAME
|
|
@@ -107,6 +115,13 @@ module Html2rss
|
|
|
107
115
|
strategy_class.new(ctx).execute
|
|
108
116
|
end
|
|
109
117
|
|
|
118
|
+
##
|
|
119
|
+
# Clears process-local deprecation warning state (used by specs).
|
|
120
|
+
# @return [void]
|
|
121
|
+
def reset_deprecation_warnings!
|
|
122
|
+
DEPRECATION_MUTEX.synchronize { @warned_deprecations = Set.new }
|
|
123
|
+
end
|
|
124
|
+
|
|
110
125
|
private
|
|
111
126
|
|
|
112
127
|
##
|
|
@@ -125,14 +140,15 @@ module Html2rss
|
|
|
125
140
|
end
|
|
126
141
|
|
|
127
142
|
##
|
|
128
|
-
# Emits a structured +strategy_deprecated+ warning once per
|
|
143
|
+
# Emits a structured +strategy_deprecated+ warning once per +(strategy, host)+ per process
|
|
144
|
+
# on initial session requests (+relation == :initial+). Multi-worker servers may emit once per worker.
|
|
129
145
|
# @param strategy [Symbol] deprecated strategy alias
|
|
130
146
|
# @param replacement [Symbol] preferred strategy name
|
|
131
147
|
# @param relation [Symbol] request relation
|
|
132
148
|
# @param host [String, nil] request host
|
|
133
149
|
# @return [void]
|
|
134
150
|
def warn_migration_strategy(strategy, replacement, relation:, host:)
|
|
135
|
-
return unless relation == :initial
|
|
151
|
+
return unless relation == :initial && claim_deprecation_warning?(strategy, host)
|
|
136
152
|
|
|
137
153
|
Log.warn({
|
|
138
154
|
event_name: 'strategy_deprecated',
|
|
@@ -142,6 +158,14 @@ module Html2rss
|
|
|
142
158
|
message: "#{name}: strategy=#{strategy.inspect} is deprecated; using #{replacement.inspect} instead."
|
|
143
159
|
}.to_json)
|
|
144
160
|
end
|
|
161
|
+
|
|
162
|
+
def claim_deprecation_warning?(strategy, host)
|
|
163
|
+
DEPRECATION_MUTEX.synchronize do
|
|
164
|
+
@warned_deprecations ||= Set.new
|
|
165
|
+
@warned_deprecations.clear if @warned_deprecations.size >= MAX_DEPRECATION_WARNINGS
|
|
166
|
+
!@warned_deprecations.add?([strategy, host]).nil?
|
|
167
|
+
end
|
|
168
|
+
end
|
|
145
169
|
end
|
|
146
170
|
end
|
|
147
171
|
end
|
data/lib/html2rss/version.rb
CHANGED
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.32.
|
|
4
|
+
version: 0.32.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gil Desmarais
|
|
@@ -478,7 +478,7 @@ licenses:
|
|
|
478
478
|
- MIT
|
|
479
479
|
metadata:
|
|
480
480
|
allowed_push_host: https://rubygems.org
|
|
481
|
-
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.
|
|
481
|
+
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.4
|
|
482
482
|
rubygems_mfa_required: 'true'
|
|
483
483
|
rdoc_options: []
|
|
484
484
|
require_paths:
|