html2rss 0.32.1 → 0.32.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1d74d19e935fcf05c7bd0893f2113dfac1e6639de926ef8844159118ad13ff5
4
- data.tar.gz: 3999a5e55251f993b6721d9d3f8c03b8c7588fa0834499a53c808789bb7a3ded
3
+ metadata.gz: 8d37540b2f300af8a1db631e16cf6cabf4cfa76069000a71c7723737225bd6c3
4
+ data.tar.gz: bd8f656ae629587336257d790da183c759b9c709c1ba411e5c0041156d35dd6b
5
5
  SHA512:
6
- metadata.gz: 1c7bea03d8553b2e8ec76c8216b57e0f07b20157cb1c1710c31b4730281b410c95b61541fae56bc4d3853cedf5e62854e0255b920607e28cfa902e941e779953
7
- data.tar.gz: 6911fdce7df3238bbe27b28b6a8f747077b12a5cd949b12e4cc0bddadff1433f0ef56662cc361f7d80c90924fe38813ac6584ef0ec08a6b39b0859bc4067d2d4
6
+ metadata.gz: 802b88abad9bbadfa6753a5c62694a66c6eec3774e84d85191beabdfdd2bc8df3e37fdf325bc14c1d6fda0b90528aca2484df15d0d985b6e328c07f4c27a49b9
7
+ data.tar.gz: 827d398c34732c10600ced1002525702392448fcb4e8b585f7c27048c8fae38e9c2652307705c8a91e82fba295dbbfa8bfb0cc4dc3a71c4d70cdf9773722a4cf
@@ -116,6 +116,7 @@ module Html2rss
116
116
  end
117
117
 
118
118
  # @return [Hash{Symbol => Object}] oEmbed fields hash
119
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- structured warn keeps fields local
119
120
  def fetch_oembed_data
120
121
  return {} unless request_session && (link_node = oembed_link_node)
121
122
  return {} unless (oembed_url = resolve_url(link_node['href']))
@@ -123,7 +124,12 @@ module Html2rss
123
124
  response = request_session.follow_up(url: oembed_url, relation: :auto_source, origin_url: url)
124
125
  parse_oembed_response(response)
125
126
  rescue Html2rss::Error, RequestService::UnsupportedResponseContentType, JSON::ParserError => error
126
- Log.warn("#{self.class}: failed to fetch oEmbed payload (#{error.class}: #{error.message})")
127
+ Log.warn({
128
+ event_name: 'meta_oembed.fetch_failed',
129
+ host: url.host,
130
+ error_class: error.class.name,
131
+ message: "#{self.class}: failed to fetch oEmbed payload (#{error.class}: #{error.message})"
132
+ }.to_json)
127
133
  {}
128
134
  end
129
135
 
@@ -57,16 +57,25 @@ module Html2rss
57
57
  ##
58
58
  # @yieldparam article [Hash{Symbol => Object}]
59
59
  # @return [Enumerator, void]
60
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- structured empty/items logs
60
61
  def each(&)
61
62
  return enum_for(:each) unless block_given?
62
63
 
63
64
  articles = fetch_articles
64
65
  if articles.empty?
65
- Log.info("#{self.class}: host=#{url.host} item_count=0 fallback=true")
66
+ Log.info({
67
+ event_name: 'auto_source.native_feed.empty',
68
+ host: url.host,
69
+ message: "#{self.class}: host=#{url.host} item_count=0 fallback=true"
70
+ }.to_json)
66
71
  return
67
72
  end
68
73
 
69
- Log.info("#{self.class}: host=#{url.host} item_count=#{articles.size} fallback=false")
74
+ Log.info({
75
+ event_name: 'auto_source.native_feed.items',
76
+ host: url.host,
77
+ message: "#{self.class}: host=#{url.host} item_count=#{articles.size} fallback=false"
78
+ }.to_json)
70
79
  articles.each(&)
71
80
  end
72
81
 
@@ -74,7 +83,8 @@ module Html2rss
74
83
 
75
84
  attr_reader :parsed_body, :url, :request_session
76
85
 
77
- def fetch_articles # rubocop:disable Metrics/MethodLength -- discovery + parse path
86
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- discovery + parse + structured warn
87
+ def fetch_articles
78
88
  return [] unless request_session
79
89
 
80
90
  response = Syndication::Discovery.best_feed_response(
@@ -87,7 +97,12 @@ module Html2rss
87
97
 
88
98
  Syndication::Parser.parse_response(response)
89
99
  rescue Html2rss::Error, ArgumentError => error
90
- Log.warn("#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})")
100
+ Log.warn({
101
+ event_name: 'auto_source.native_feed.failed',
102
+ host: url.host,
103
+ error_class: error.class.name,
104
+ message: "#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})"
105
+ }.to_json)
91
106
  []
92
107
  end
93
108
  end
@@ -12,7 +12,7 @@ module Html2rss
12
12
  #
13
13
  # @see https://schema.org/docs/full.html
14
14
  # @see https://developers.google.com/search/docs/appearance/structured-data/article#microdata
15
- class Schema
15
+ class Schema # rubocop:disable Metrics/ClassLength -- scraper + structured warn paths
16
16
  include Enumerable
17
17
 
18
18
  # Matches a leading schema.org URL prefix on @type values (http or https).
@@ -156,7 +156,11 @@ module Html2rss
156
156
  def parse_script_tag(script_tag)
157
157
  JSON.parse(script_tag.text, symbolize_names: true)
158
158
  rescue JSON::ParserError => error
159
- Log.warn("#{name}: failed to parse JSON", error: error.message)
159
+ Log.warn({
160
+ event_name: 'schema.parse_failed',
161
+ error_class: error.class.name,
162
+ message: "#{name}: failed to parse JSON (#{error.message})"
163
+ }.to_json)
160
164
  []
161
165
  end
162
166
  end
@@ -7,7 +7,7 @@ module Html2rss
7
7
  module Scraper
8
8
  ##
9
9
  # Scrapes articles from XML sitemaps explicitly linked in HTML or discovered via standard sitemap paths.
10
- class Sitemap
10
+ class Sitemap # rubocop:disable Metrics/ClassLength -- scraper + structured warn paths
11
11
  include Enumerable
12
12
 
13
13
  # Selector for sitemap link tags in HTML head
@@ -78,6 +78,7 @@ module Html2rss
78
78
 
79
79
  attr_reader :parsed_body, :body, :url, :request_session, :min_priority, :max_age_days
80
80
 
81
+ # rubocop:disable-next Metrics/MethodLength -- structured budget-exhausted warn
81
82
  def fetch_and_parse_entries
82
83
  entries = parse_direct_sitemap
83
84
  return entries unless entries.empty?
@@ -87,7 +88,11 @@ module Html2rss
87
88
 
88
89
  parse_sitemap_xml(xml_body)
89
90
  rescue Html2rss::RequestService::RequestBudgetExceeded
90
- Log.warn("#{self.class}: sitemap fetch stopped — request budget exhausted")
91
+ Log.warn({
92
+ event_name: 'sitemap.budget_exhausted',
93
+ host: url.host,
94
+ message: "#{self.class}: sitemap fetch stopped — request budget exhausted"
95
+ }.to_json)
91
96
  []
92
97
  end
93
98
 
@@ -122,10 +127,15 @@ module Html2rss
122
127
 
123
128
  Scraper::Sitemap::Parser.call(xml, min_priority:, max_age_days:).entries
124
129
  rescue Html2rss::RequestService::RequestBudgetExceeded
125
- Log.warn("#{self.class}: sitemap fan-out stopped — request budget exhausted")
130
+ Log.warn({
131
+ event_name: 'sitemap.fanout_budget_exhausted',
132
+ host: url.host,
133
+ message: "#{self.class}: sitemap fan-out stopped — request budget exhausted"
134
+ }.to_json)
126
135
  nil
127
136
  end
128
137
 
138
+ # rubocop:disable-next Metrics/MethodLength -- structured fetch-failed warn
129
139
  def fetch_xml(target_url)
130
140
  return unless request_session && target_url
131
141
 
@@ -134,7 +144,12 @@ module Html2rss
134
144
  rescue Html2rss::RequestService::RequestBudgetExceeded
135
145
  raise
136
146
  rescue Html2rss::Error => error
137
- Log.warn("#{self.class}: failed to fetch sitemap (#{error.class}: #{error.message})")
147
+ Log.warn({
148
+ event_name: 'sitemap.fetch_failed',
149
+ host: url.host,
150
+ error_class: error.class.name,
151
+ message: "#{self.class}: failed to fetch sitemap (#{error.class}: #{error.message})"
152
+ }.to_json)
138
153
  nil
139
154
  end
140
155
 
@@ -59,7 +59,7 @@ module Html2rss
59
59
  nil
60
60
  end
61
61
 
62
- def fetchable_page_scope?
62
+ def fetchable_page_scope? # rubocop:disable Metrics/MethodLength -- scope checks + structured warn
63
63
  return true if page_scope.fetchable?
64
64
 
65
65
  if page_scope.reason == :non_archive
@@ -70,7 +70,11 @@ module Html2rss
70
70
  return false
71
71
  end
72
72
 
73
- logger.warn("#{WordpressApi}: unable to derive safe WordPress archive scope for #{page_url}")
73
+ logger.warn({
74
+ event_name: 'wordpress_api.unsafe_archive_scope',
75
+ host: page_url.host,
76
+ message: "#{WordpressApi}: unable to derive safe WordPress archive scope for #{page_url}"
77
+ }.to_json)
74
78
  false
75
79
  end
76
80
 
@@ -75,19 +75,31 @@ module Html2rss
75
75
 
76
76
  attr_reader :parsed_body, :url, :request_session, :page_scope
77
77
 
78
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- structured rescue logs
78
79
  def fetch_posts
79
80
  response = posts_response
80
81
  return unless response
81
82
 
82
83
  Array(response.parsed_body)
83
84
  rescue RequestService::UnsupportedResponseContentType => error
84
- Log.warn("#{self.class}: unsupported WordPress API posts content type (#{error.message})")
85
+ Log.warn({
86
+ event_name: 'wordpress_api.unsupported_content_type',
87
+ host: url.host,
88
+ error_class: error.class.name,
89
+ message: "#{self.class}: unsupported WordPress API posts content type (#{error.message})"
90
+ }.to_json)
85
91
  nil
86
92
  rescue JSON::ParserError => error
87
- Log.warn("#{self.class}: failed to parse WordPress API posts JSON (#{error.message})")
93
+ Log.warn({
94
+ event_name: 'wordpress_api.parse_failed',
95
+ host: url.host,
96
+ error_class: error.class.name,
97
+ message: "#{self.class}: failed to parse WordPress API posts JSON (#{error.message})"
98
+ }.to_json)
88
99
  nil
89
100
  end
90
101
 
102
+ # rubocop:disable-next Metrics/MethodLength -- structured fetch-failed warn
91
103
  def posts_response
92
104
  return unless request_session
93
105
  return unless (resolved_posts_url = posts_endpoint_url)
@@ -98,7 +110,12 @@ module Html2rss
98
110
  origin_url: url
99
111
  )
100
112
  rescue Html2rss::Error => error
101
- Log.warn("#{self.class}: failed to fetch WordPress API posts (#{error.class}: #{error.message})")
113
+ Log.warn({
114
+ event_name: 'wordpress_api.fetch_failed',
115
+ host: url.host,
116
+ error_class: error.class.name,
117
+ message: "#{self.class}: failed to fetch WordPress API posts (#{error.class}: #{error.message})"
118
+ }.to_json)
102
119
  nil
103
120
  end
104
121
 
@@ -277,7 +277,12 @@ module Html2rss
277
277
  def run_scraper(instance)
278
278
  instance.map { |item| article_from_scraper_item(item, instance) }
279
279
  rescue *OPERATIONAL_ERRORS => error
280
- Log.warn("#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}")
280
+ Log.warn({
281
+ event_name: 'auto_source.quarantined',
282
+ host: url.host,
283
+ error_class: error.class.name,
284
+ message: "#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}"
285
+ }.to_json)
281
286
  []
282
287
  end
283
288
 
@@ -35,6 +35,10 @@ These abort immediately (`AutoFallback::NON_FALLBACK_ERRORS`): unknown strategy,
35
35
 
36
36
  Retries share the feed's request/session policy. Pin a concrete strategy when you need a single-hop budget profile.
37
37
 
38
+ ### Terminal fallback outcome
39
+
40
+ A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut` or `BlockedSurfaceDetected`). `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`. `html2rss-web` `ErrorClassifier` maps the raised error to HTTP/cache policy; it does not invent a second cache rule for empty extraction.
41
+
38
42
  ## Success signals
39
43
 
40
44
  - `Html2rss::RequestService::Response` includes transport metadata for the strategy that produced it.
@@ -27,16 +27,26 @@ module Html2rss
27
27
  RequestService::RedirectLimitReached
28
28
  ].freeze
29
29
 
30
+ # Transport failures preserved across a later hop that extracts zero items.
31
+ # Re-raised from finalize_failure so web maps them to uncacheable gateway/blocked
32
+ # decisions instead of EXTRACTION_EMPTY.
33
+ TERMINAL_TRANSPORT_ERRORS = [
34
+ RequestService::RequestTimedOut,
35
+ RequestService::BlockedSurfaceDetected
36
+ ].freeze
37
+
30
38
  ##
31
39
  # Mutable run state for one auto-fallback chain: attempts plus selected result.
32
40
  class AttemptState
33
- attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution
41
+ attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution,
42
+ :terminal_transport_error
34
43
 
35
44
  def initialize
36
45
  @attempts = []
37
46
  @result = nil
38
47
  @last_response = nil
39
48
  @last_error = nil
49
+ @terminal_transport_error = nil
40
50
  @entry_resolution = nil
41
51
  @resolution_tried = false
42
52
  @aborted = false
@@ -76,6 +86,9 @@ module Html2rss
76
86
  attempt[:transport_meta] = meta if meta&.any?
77
87
  @attempts << attempt
78
88
  @last_error = error
89
+ return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
90
+
91
+ @terminal_transport_error = error
79
92
  end
80
93
 
81
94
  # @param strategy [Symbol] strategy that returned a response
@@ -85,7 +98,7 @@ module Html2rss
85
98
  def record_items(strategy:, items_count:, transport_meta: nil)
86
99
  @last_error = nil
87
100
  attempt = { strategy:, items_count:, error_class: nil }
88
- attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
101
+ attempt[:transport_meta] = transport_meta if transport_meta&.any?
89
102
  @attempts << attempt
90
103
  end
91
104
 
@@ -183,7 +196,9 @@ module Html2rss
183
196
 
184
197
  # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
185
198
  def process_response(response:, strategy:, next_strategy:, request_session:, state:)
186
- articles, dedup_dropped, admission_drops = articles_for(response:, request_session:)
199
+ articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
200
+ config:, response:, request_session:
201
+ )
187
202
  state.remember_response(response)
188
203
  items_count = articles.size
189
204
  state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
@@ -204,7 +219,7 @@ module Html2rss
204
219
  state:)
205
220
  end
206
221
 
207
- if deterministic_status?(response)
222
+ if DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
208
223
  state.abort!
209
224
  Log.debug("#{self.class}: deterministic HTTP #{response.status}; aborting auto fallback")
210
225
  return
@@ -213,15 +228,7 @@ module Html2rss
213
228
  log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
214
229
  end
215
230
 
216
- def deterministic_status?(response)
217
- DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
218
- end
219
-
220
- def articles_for(response:, request_session:)
221
- pipeline.deduplicated_articles(config:, response:, request_session:)
222
- end
223
-
224
- # rubocop:disable-next Metrics/ParameterLists -- success kwargs match PipelineOutcome
231
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- success kwargs + structured selected log
225
232
  def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
226
233
  attempt_count = state.attempts.size
227
234
  state.succeed!(
@@ -231,63 +238,72 @@ module Html2rss
231
238
  )
232
239
  return unless attempt_count > 1
233
240
 
234
- Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
235
- "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
236
- "budget_remaining=#{budget_remaining_label}")
241
+ Log.info({
242
+ event_name: 'auto_fallback.selected',
243
+ host: response.url.host,
244
+ strategy: strategy.to_s,
245
+ attempt_count:,
246
+ message: "#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
247
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
248
+ "budget_remaining=#{budget_remaining_label}"
249
+ }.to_json)
237
250
  end
238
251
 
239
252
  def finalize_failure(state)
240
- if raise_last_error?(state)
241
- error = state.last_error
253
+ error = failure_error(state)
254
+ if error
242
255
  error.attempts = state.attempts if error.is_a?(Html2rss::Error)
243
256
  raise error
244
257
  end
245
258
 
259
+ response = state.last_response
246
260
  raise NoFeedItemsExtracted.new(
247
261
  attempts: state.attempts,
248
- surface_category: surface_category_for(state.last_response)
262
+ surface_category: (PageRecon.surface_category_for(response:, url: response.url) if response)
249
263
  )
250
264
  end
251
265
 
252
- def raise_last_error?(state)
253
- error = state.last_error
254
- return false if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
255
-
256
- true
257
- end
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
258
270
 
259
- def surface_category_for(response)
260
- return unless response
271
+ error = state.last_error
272
+ return if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
261
273
 
262
- PageRecon.surface_category_for(response:, url: response.url)
274
+ error
263
275
  end
264
276
 
265
- # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
277
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- shared payload then info/warn dispatch
266
278
  def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
267
279
  host = request_session.url.host
268
280
  detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
269
281
  "budget_remaining=#{budget_remaining_label}"
270
- if error.is_a?(RequestService::RequestTimedOut)
271
- Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
272
- "after timeout=#{error.class} #{detail}")
273
- else
274
- Log.warn({
275
- event_name: 'auto_fallback.error',
276
- host:,
277
- strategy: strategy.to_s,
278
- next_strategy: next_strategy.to_s,
279
- error_class: error.class.name,
280
- message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
281
- "after error=#{error.class} #{detail}"
282
- }.to_json)
283
- end
282
+ timed_out = error.is_a?(RequestService::RequestTimedOut)
283
+ payload = {
284
+ event_name: timed_out ? 'auto_fallback.timeout' : 'auto_fallback.error',
285
+ host:,
286
+ strategy: strategy.to_s,
287
+ next_strategy: next_strategy.to_s,
288
+ error_class: error.class.name,
289
+ message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
290
+ "after #{timed_out ? "timeout=#{error.class}" : "error=#{error.class}"} #{detail}"
291
+ }.to_json
292
+ timed_out ? Log.info(payload) : Log.warn(payload)
284
293
  Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
285
294
  end
286
295
 
287
296
  def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
288
- Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
289
- "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
290
- "budget_remaining=#{budget_remaining_label}")
297
+ host = response.url.host
298
+ Log.info({
299
+ event_name: 'auto_fallback.zero_items',
300
+ host:,
301
+ strategy: strategy.to_s,
302
+ next_strategy: next_strategy.to_s,
303
+ message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
304
+ "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
305
+ "budget_remaining=#{budget_remaining_label}"
306
+ }.to_json)
291
307
  end
292
308
 
293
309
  def budget_remaining_label
@@ -285,10 +285,13 @@ module Html2rss
285
285
 
286
286
  def result(applied:, reason:, scrape_url:, probe_count:, winner_score:)
287
287
  scrape_host = Html2rss::Url.from_absolute(scrape_url).host
288
- Log.info(
289
- "FeedResolution: entry_host=#{entry_url.host} scrape_host=#{scrape_host} " \
290
- "applied=#{applied} probe_count=#{probe_count} winner_score=#{winner_score.inspect} reason=#{reason}"
291
- )
288
+ Log.info({
289
+ event_name: 'feed_resolution.result',
290
+ host: entry_url.host,
291
+ message: "FeedResolution: entry_host=#{entry_url.host} scrape_host=#{scrape_host} " \
292
+ "applied=#{applied} probe_count=#{probe_count} winner_score=#{winner_score.inspect} " \
293
+ "reason=#{reason}"
294
+ }.to_json)
292
295
  Result.new(entry_url: entry_url.to_s, scrape_url:, applied:, reason:, probe_count:, winner_score:)
293
296
  end
294
297
  end
@@ -21,9 +21,9 @@ module Html2rss
21
21
  # combined with ping! on a reused closed selector that never decrements retries,
22
22
  # that overflows the stack and kills Falcon workers.
23
23
  RETRY_AFTER_SECONDS = 0.25
24
- # Bounds passed to the retries plugin and every +session.with+.
25
- # HTTPX retries stay inside one {RequestService::Budget} slot and the shared
26
- # wall-clock deadline; do not consume a slot per attempt.
24
+ # Base-session retries plugin defaults (stack-overflow guard for ping reconnects).
25
+ # Per-request +session.with+ sets +max_retries: 0+; Ruby owns TimeoutError retries
26
+ # against the monotonic deadline so HTTPX cannot stack read timeouts past the cap.
27
27
  # Do not reload +:fiber_concurrency+: a reused closed selector made +ping!+
28
28
  # skip decrementing retries and recurse until +SystemStackError+.
29
29
  #
@@ -54,19 +54,19 @@ module Html2rss
54
54
  def perform_execute
55
55
  deadline = request_deadline
56
56
  reset_redirect_tracking!
57
- raw_response = request_with_terminal_redirect_retry(deadline:)
57
+ raw_response = request_with_deadline_retries(deadline:)
58
58
  build_response(raw_response)
59
59
  end
60
60
 
61
61
  private
62
62
 
63
- # Caps one HTTPX execute (including internal MAX_RETRIES) so a hanging
64
- # :default tier cannot consume the full shared auto-fallback budget.
63
+ # Caps one strategy execute (Ruby retries included) so a hanging :default tier
64
+ # cannot consume the full shared auto-fallback budget.
65
65
  #
66
66
  # @return [Float] monotonic deadline
67
67
  def request_deadline
68
68
  budget_timeout = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
69
- monotonic_now + [budget_timeout, attempt_timeout_ceiling].min
69
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + [budget_timeout, attempt_timeout_ceiling].min
70
70
  end
71
71
 
72
72
  # @return [Float] connect + read ceiling for a single attempt
@@ -81,8 +81,30 @@ module Html2rss
81
81
  @current_url = request_url
82
82
  end
83
83
 
84
- def request_with_terminal_redirect_retry(deadline:)
85
- raw_response = execute_http_request(deadline:)
84
+ # Retries HTTPX timeouts in Ruby while the shared monotonic deadline has time left.
85
+ # First try consumes the budget slot; retries do not.
86
+ def request_with_deadline_retries(deadline:)
87
+ state = { retries_remaining: MAX_RETRIES, consume_budget: true }
88
+
89
+ begin
90
+ request_with_terminal_redirect_retry(deadline:, consume_budget: state[:consume_budget])
91
+ rescue HTTPX::TimeoutError => error
92
+ prepare_deadline_retry!(deadline:, state:, error:)
93
+ retry
94
+ end
95
+ end
96
+
97
+ def prepare_deadline_retry!(deadline:, state:, error:)
98
+ raise error if state[:retries_remaining].zero?
99
+
100
+ remaining_timeout_seconds(deadline)
101
+ state[:retries_remaining] -= 1
102
+ state[:consume_budget] = false
103
+ reset_redirect_tracking!
104
+ end
105
+
106
+ def request_with_terminal_redirect_retry(deadline:, consume_budget: true)
107
+ raw_response = execute_http_request(deadline:, consume_budget:)
86
108
  return raw_response unless redirect_limit_reached?(raw_response)
87
109
 
88
110
  unless terminal_redirect_retryable?(raw_response)
@@ -164,6 +186,7 @@ module Html2rss
164
186
  session.with(session_options(deadline))
165
187
  end
166
188
 
189
+ # Plugin retries stay off per request; Ruby owns deadline-bounded TimeoutError retries.
167
190
  def session_options(deadline)
168
191
  remaining = remaining_timeout_seconds(deadline)
169
192
  {
@@ -173,7 +196,7 @@ module Html2rss
173
196
  max_response_body_size: ctx.policy.max_response_bytes,
174
197
  resolver_class: :system,
175
198
  redirect_on: redirect_callback,
176
- **RETRY_OPTIONS
199
+ **RETRY_OPTIONS.merge(max_retries: 0)
177
200
  }
178
201
  end
179
202
 
@@ -219,16 +242,12 @@ module Html2rss
219
242
  end
220
243
 
221
244
  def remaining_timeout_seconds(deadline)
222
- remaining = deadline - monotonic_now
245
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
246
  raise RequestTimedOut, 'Request timed out' if remaining <= 0
224
247
 
225
248
  remaining
226
249
  end
227
250
 
228
- def monotonic_now
229
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
- end
231
-
232
251
  def timeout_error?(error)
233
252
  error.is_a?(HTTPX::TimeoutError) || super
234
253
  end
@@ -111,18 +111,24 @@ module Html2rss
111
111
 
112
112
  # @param reason [String] timeout classification (budget_exhausted / transport)
113
113
  # @return [void]
114
- # rubocop:disable-next Metrics/AbcSize -- structured timeout fields stay in one log line
114
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- structured timeout fields stay in one log line
115
115
  def log_timeout!(reason:)
116
116
  remaining = ctx.budget.remaining_timeout_seconds
117
117
  remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
118
+ host = ctx.url.host
118
119
  detail = [
119
120
  "strategy=#{self.class.name}",
120
- "host=#{ctx.url.host}",
121
+ "host=#{host}",
121
122
  "elapsed=#{format('%.3f', ctx.budget.elapsed_seconds)}s",
122
123
  "budget_remaining=#{remaining_label}",
123
124
  "reason=#{reason}"
124
125
  ]
125
- Log.info("#{self.class}: request timeout #{detail.join(' ')}")
126
+ Log.info({
127
+ event_name: 'request_service.timeout',
128
+ host:,
129
+ strategy: self.class.name,
130
+ message: "#{self.class}: request timeout #{detail.join(' ')}"
131
+ }.to_json)
126
132
  end
127
133
 
128
134
  # @param error [StandardError]
@@ -119,17 +119,29 @@ module Html2rss
119
119
 
120
120
  body = parsed.at_css('body')
121
121
  if body
122
- Html2rss::Log.warn('sst.normalizer root fallback: body')
122
+ Html2rss::Log.warn({
123
+ event_name: 'sst.normalizer.root_fallback',
124
+ reason: 'body',
125
+ message: 'sst.normalizer root fallback: body'
126
+ }.to_json)
123
127
  return body
124
128
  end
125
129
 
126
130
  first = parsed.element_children.find { |child| element_root?(child) }
127
131
  if first
128
- Html2rss::Log.warn('sst.normalizer root fallback: first element child')
132
+ Html2rss::Log.warn({
133
+ event_name: 'sst.normalizer.root_fallback',
134
+ reason: 'first_element_child',
135
+ message: 'sst.normalizer root fallback: first element child'
136
+ }.to_json)
129
137
  return first
130
138
  end
131
139
 
132
- Html2rss::Log.warn('sst.normalizer root fallback: self')
140
+ Html2rss::Log.warn({
141
+ event_name: 'sst.normalizer.root_fallback',
142
+ reason: 'self',
143
+ message: 'sst.normalizer root fallback: self'
144
+ }.to_json)
133
145
  parsed
134
146
  end
135
147
 
@@ -146,7 +158,11 @@ module Html2rss
146
158
 
147
159
  if @node_count >= MAX_NODES && !@degraded
148
160
  @degraded = true
149
- Html2rss::Log.warn("sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only")
161
+ Html2rss::Log.warn({
162
+ event_name: 'sst.normalizer.max_nodes',
163
+ max_nodes: MAX_NODES,
164
+ message: "sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only"
165
+ }.to_json)
150
166
  end
151
167
 
152
168
  return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
@@ -60,7 +60,11 @@ module Html2rss
60
60
  ).tap do |response|
61
61
  next unless response
62
62
 
63
- Log.info("Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}")
63
+ Log.info({
64
+ event_name: 'syndication.discovery.selected',
65
+ host: page.host,
66
+ message: "Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}"
67
+ }.to_json)
64
68
  end
65
69
  end
66
70
 
@@ -6,7 +6,7 @@ module Html2rss
6
6
  module Syndication
7
7
  ##
8
8
  # Parses RSS 2.0 and Atom documents into AutoSource article hashes.
9
- module Parser
9
+ module Parser # rubocop:disable Metrics/ModuleLength -- feed formats + structured parse warn
10
10
  module_function
11
11
 
12
12
  ##
@@ -29,7 +29,11 @@ module Html2rss
29
29
 
30
30
  items_from(feed, base_url:).filter_map { |item| article_hash(item, base_url:) }
31
31
  rescue RSS::Error => error
32
- Log.warn("Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})")
32
+ Log.warn({
33
+ event_name: 'syndication.parse_failed',
34
+ error_class: error.class.name,
35
+ message: "Syndication::Parser: failed to parse feed (#{error.class}: #{error.message})"
36
+ }.to_json)
33
37
  []
34
38
  end
35
39
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.32.1'
5
+ VERSION = '0.32.3'
6
6
  public_constant :VERSION
7
7
  end
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.1
4
+ version: 0.32.3
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.1
481
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.3
482
482
  rubygems_mfa_required: 'true'
483
483
  rdoc_options: []
484
484
  require_paths: