html2rss 0.23.0 → 0.24.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.
@@ -5,9 +5,10 @@ module Html2rss
5
5
  # Retries feed extraction across concrete request strategies for the :auto plan.
6
6
  #
7
7
  # Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
8
+ # Hosted by the pipeline instance: session + extract call back into FeedPipeline.
8
9
  class AutoFallback
9
10
  # Ordered list of concrete request strategies attempted by the :auto plan.
10
- CHAIN = %i[faraday botasaurus browserless].freeze
11
+ CHAIN = %i[faraday botasaurus].freeze
11
12
 
12
13
  # Error classes that should abort auto fallback immediately.
13
14
  NON_FALLBACK_ERRORS = [
@@ -44,34 +45,49 @@ module Html2rss
44
45
 
45
46
  # @param strategy [Symbol] strategy that returned a response
46
47
  # @param items_count [Integer] extracted article count
48
+ # @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
47
49
  # @return [void]
48
- def record_items(strategy:, items_count:)
49
- @attempts << { strategy:, items_count:, error_class: nil }
50
+ def record_items(strategy:, items_count:, transport_meta: nil)
51
+ attempt = { strategy:, items_count:, error_class: nil }
52
+ attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
53
+ @attempts << attempt
50
54
  end
51
55
 
52
56
  # @param response [RequestService::Response] successful response
53
57
  # @param articles [Array] extracted articles
58
+ # @param dedup_dropped [Integer] articles removed by deduplication
59
+ # @param selected_strategy [Symbol] concrete strategy that produced items
60
+ # @param attempt_count [Integer] number of attempts recorded for this chain
54
61
  # @return [void]
55
- def succeed!(response:, articles:)
56
- @result = { response:, articles: }
62
+ def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:)
63
+ @result = PipelineOutcome.new(
64
+ response:,
65
+ articles:,
66
+ dedup_dropped:,
67
+ selected_strategy:,
68
+ attempt_count:,
69
+ strategy_attempts: attempts
70
+ )
57
71
  end
58
72
  end
59
73
 
60
74
  ##
61
75
  # @param strategies [Array<Symbol>] ordered concrete strategies for fallback
62
76
  # @param budget [RequestService::Budget] shared request budget across retries
63
- # @param session_for [Proc] request session factory proc
64
- # @param articles_for [Proc] article extraction proc
77
+ # @param pipeline [Html2rss::FeedPipeline] host for session + article extraction
78
+ # @param config [Html2rss::Config] validated feed config
79
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources] budget + policy
65
80
  # @return [void]
66
- def initialize(strategies:, budget:, session_for:, articles_for:)
81
+ def initialize(strategies:, budget:, pipeline:, config:, resources:)
67
82
  @strategies = strategies
68
83
  @budget = budget
69
- @session_for = session_for
70
- @articles_for = articles_for
84
+ @pipeline = pipeline
85
+ @config = config
86
+ @resources = resources
71
87
  end
72
88
 
73
89
  ##
74
- # @return [Hash{Symbol => Object}] pipeline state containing :response and :articles
90
+ # @return [Html2rss::FeedPipeline::PipelineOutcome] scrape-finished state for materialization
75
91
  def call
76
92
  state = run_attempts
77
93
  return state.result if state.succeeded?
@@ -81,7 +97,7 @@ module Html2rss
81
97
 
82
98
  private
83
99
 
84
- attr_reader :strategies, :budget, :session_for, :articles_for
100
+ attr_reader :strategies, :budget, :pipeline, :config, :resources
85
101
 
86
102
  def run_attempts
87
103
  AttemptState.new.tap do |state|
@@ -93,7 +109,7 @@ module Html2rss
93
109
  end
94
110
 
95
111
  def attempt(strategy:, next_strategy:, state:)
96
- request_session = session_for.call(strategy:, budget:)
112
+ request_session = pipeline.request_session_for(config, strategy:, resources:)
97
113
  response = fetch_response(request_session:, strategy:, next_strategy:, state:)
98
114
  return unless response
99
115
 
@@ -106,38 +122,65 @@ module Html2rss
106
122
  raise
107
123
  rescue StandardError => error
108
124
  state.record_error(strategy:, error:)
109
- log_warn_fallback_error(strategy:, next_strategy:, error:) if next_strategy
110
- Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message}")
125
+ log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
111
126
  nil
112
127
  end
113
128
 
114
129
  def process_response(response:, strategy:, next_strategy:, request_session:, state:)
115
- articles = articles_for.call(response:, request_session:)
130
+ articles, dedup_dropped = articles_for(response:, request_session:)
116
131
  items_count = articles.size
117
- state.record_items(strategy:, items_count:)
118
- Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count}")
119
- return record_success(response:, strategy:, articles:, state:) if items_count.positive?
132
+ state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
133
+ Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
134
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
135
+ "budget_remaining=#{budget_remaining_label}")
136
+ return record_success(response:, strategy:, articles:, dedup_dropped:, state:) if items_count.positive?
120
137
 
121
- log_info_fallback_zero_items(strategy:, next_strategy:) if next_strategy
138
+ log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
122
139
  end
123
140
 
124
- def record_success(response:, strategy:, articles:, state:)
125
- state.succeed!(response:, articles:)
126
- return unless state.attempts.size > 1
141
+ def articles_for(response:, request_session:)
142
+ pipeline.deduplicated_articles(config:, response:, request_session:)
143
+ end
144
+
145
+ def record_success(response:, strategy:, articles:, dedup_dropped:, state:)
146
+ attempt_count = state.attempts.size
147
+ state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy, attempt_count:)
148
+ return unless attempt_count > 1
127
149
 
128
- Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{state.attempts.size}")
150
+ Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
151
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
152
+ "budget_remaining=#{budget_remaining_label}")
129
153
  end
130
154
 
131
155
  def finalize_failure(attempts:)
132
156
  raise NoFeedItemsExtracted.new(attempts:)
133
157
  end
134
158
 
135
- def log_warn_fallback_error(strategy:, next_strategy:, error:)
136
- Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after error=#{error.class}")
159
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
160
+ def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
161
+ host = request_session.url.host
162
+ detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
163
+ "budget_remaining=#{budget_remaining_label}"
164
+ if error.is_a?(RequestService::RequestTimedOut)
165
+ Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
166
+ "after timeout=#{error.class} #{detail}")
167
+ else
168
+ Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
169
+ "after error=#{error.class} #{detail}")
170
+ end
171
+ Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
172
+ end
173
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
174
+
175
+ def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
176
+ Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
177
+ "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
178
+ "budget_remaining=#{budget_remaining_label}")
137
179
  end
138
180
 
139
- def log_info_fallback_zero_items(strategy:, next_strategy:)
140
- Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items")
181
+ def budget_remaining_label
182
+ remaining = budget.remaining_timeout_seconds
183
+ remaining.nil? ? 'untracked' : format('%.3f', remaining)
141
184
  end
142
185
  end
143
186
  end
@@ -4,8 +4,13 @@ module Html2rss
4
4
  ##
5
5
  # Builds feeds from validated config through request, extraction, and rendering stages.
6
6
  class FeedPipeline
7
- # Bundle of inputs shared by selector and auto-source article collection.
8
- ExtractionContext = Data.define(:config, :response, :request_session)
7
+ # Scrape-finished facts after request + extraction + dedup (before Channel/Status materialize).
8
+ # selected_strategy: set on :auto success; nil otherwise.
9
+ # attempt_count: auto attempts attempted; 0 outside :auto.
10
+ # strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
11
+ PipelineOutcome = Data.define(
12
+ :response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts
13
+ )
9
14
 
10
15
  ##
11
16
  # @param raw_config [Hash{Symbol => Object}] user-provided feed config
@@ -14,34 +19,55 @@ module Html2rss
14
19
  end
15
20
 
16
21
  ##
17
- # @return [RSS::Rss] generated RSS feed
18
- def to_rss
19
- run do |response:, config:, articles:|
20
- channel = Html2rss::Channel.new(response, overrides: config.channel)
21
- FeedBuilder.build(:rss, channel:, articles:, stylesheets: config.stylesheets)
22
- end
22
+ # Runs the pipeline once and returns an opaque, Marshal-cacheable result.
23
+ #
24
+ # @return [Html2rss::FeedResult]
25
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- Status kwargs stay co-located with Channel
26
+ def to_result
27
+ config = Config.from_hash(raw_config, params: raw_config[:params])
28
+ outcome = pipeline_outcome_for(config)
29
+ channel = Channel.from_response(outcome.response, overrides: config.channel)
30
+ status = Status.build(
31
+ articles: outcome.articles,
32
+ dedup_dropped: outcome.dedup_dropped,
33
+ selected_strategy: outcome.selected_strategy,
34
+ attempt_count: outcome.attempt_count,
35
+ strategy_attempts: outcome.strategy_attempts
36
+ )
37
+ FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
23
38
  end
39
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
24
40
 
25
- ##
26
- # @return [Hash] generated JSONFeed 1.1 payload
27
- def to_json_feed
28
- run do |response:, config:, articles:|
29
- channel = Html2rss::Channel.new(response, overrides: config.channel)
30
- FeedBuilder.build(:json_feed, channel:, articles:)
31
- end
41
+ # @api private Host seam for {AutoFallback} (and single-strategy path).
42
+ # @param config [Html2rss::Config]
43
+ # @param strategy [Symbol]
44
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
45
+ # @return [Html2rss::RequestSession]
46
+ def request_session_for(config, strategy:, resources:)
47
+ RequestSession.build(
48
+ config:,
49
+ strategy:,
50
+ budget: resources.budget,
51
+ policy: resources.policy
52
+ )
53
+ end
54
+
55
+ # @api private Host seam for {AutoFallback} (and single-strategy path).
56
+ # @param config [Html2rss::Config]
57
+ # @param response [Html2rss::RequestService::Response]
58
+ # @param request_session [Html2rss::RequestSession]
59
+ # @return [Array(Array<Html2rss::Article>, Integer)] unique articles and drop count
60
+ def deduplicated_articles(config:, response:, request_session:)
61
+ collected = collect_articles(config:, response:, request_session:)
62
+ unique = Article::Deduplicator.new(collected).call
63
+ [unique, collected.size - unique.size]
32
64
  end
33
65
 
34
66
  private
35
67
 
36
68
  attr_reader :raw_config
37
69
 
38
- def run
39
- config = Config.from_hash(raw_config, params: raw_config[:params])
40
- state = pipeline_state_for(config)
41
- yield response: state.fetch(:response), config:, articles: state.fetch(:articles)
42
- end
43
-
44
- def pipeline_state_for(config)
70
+ def pipeline_outcome_for(config)
45
71
  plan = StrategyPlan.resolve(config.strategy)
46
72
  resources = RuntimePolicy.resources_for(config)
47
73
  if plan.is_a?(StrategyPlan::Auto)
@@ -54,52 +80,33 @@ module Html2rss
54
80
  def run_pipeline_for_strategy(config, strategy:, resources:)
55
81
  request_session = request_session_for(config, strategy:, resources:)
56
82
  response = request_session.fetch_initial_response
57
- articles = deduplicated_articles(
58
- ExtractionContext.new(config:, response:, request_session:)
83
+ articles, dedup_dropped = deduplicated_articles(config:, response:, request_session:)
84
+ PipelineOutcome.new(
85
+ response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0, strategy_attempts: []
59
86
  )
60
- { response:, articles: }
61
87
  end
62
88
 
63
- def request_session_for(config, strategy:, resources:)
64
- RequestSession.build(
65
- config:,
66
- strategy:,
67
- budget: resources.budget,
68
- policy: resources.policy
69
- )
70
- end
71
-
72
- def deduplicated_articles(extraction)
73
- Article::Deduplicator.new(collect_articles(extraction)).call
74
- end
75
-
76
- # rubocop:disable Metrics/MethodLength
77
89
  def run_auto_pipeline(config, resources:)
78
90
  AutoFallback.new(
79
91
  strategies: AutoFallback::CHAIN,
80
92
  budget: resources.budget,
81
- session_for: lambda do |strategy:, budget:|
82
- budget.effective_timeout_seconds(fallback: resources.policy.total_timeout_seconds)
83
- request_session_for(config, strategy:, resources:)
84
- end,
85
- articles_for: lambda do |response:, request_session:|
86
- deduplicated_articles(ExtractionContext.new(config:, response:, request_session:))
87
- end
93
+ pipeline: self,
94
+ config:,
95
+ resources:
88
96
  ).call
89
97
  end
90
- # rubocop:enable Metrics/MethodLength
91
98
 
92
- def collect_articles(extraction)
93
- selector_articles(extraction) + auto_source_articles(extraction)
99
+ def collect_articles(config:, response:, request_session:)
100
+ selector_articles(config:, response:, request_session:) +
101
+ auto_source_articles(config:, response:, request_session:)
94
102
  end
95
103
 
96
104
  # rubocop:disable Metrics/MethodLength
97
- def selector_articles(extraction)
98
- config = extraction.config
105
+ def selector_articles(config:, response:, request_session:)
99
106
  return [] unless (selectors = config.selectors)
100
107
 
101
- page_responses = extraction.request_session.page_responses(
102
- extraction.response,
108
+ page_responses = request_session.page_responses(
109
+ response,
103
110
  pagination_config: selectors.dig(:items, :pagination)
104
111
  )
105
112
 
@@ -114,10 +121,10 @@ module Html2rss
114
121
  end
115
122
  # rubocop:enable Metrics/MethodLength
116
123
 
117
- def auto_source_articles(extraction)
118
- return [] unless (auto_source = extraction.config.auto_source)
124
+ def auto_source_articles(config:, response:, request_session:)
125
+ return [] unless (auto_source = config.auto_source)
119
126
 
120
- AutoSource.new(extraction.response, auto_source, request_session: extraction.request_session).articles
127
+ AutoSource.new(response, auto_source, request_session:).articles
121
128
  end
122
129
  end
123
130
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Closed Marshal-cacheable handle for one scrape.
6
+ #
7
+ # Frozen consumer query/render set (do not grow without an explicit contract change):
8
+ # {#empty?}, {#channel_title}, {#to_rss}, {#to_json_feed}, and {#status}.
9
+ #
10
+ # Non-goals: no Channel reader, no Articles reader, no peephole into scrape internals.
11
+ # html2rss-web and other consumers must use only this surface.
12
+ #
13
+ # @note Gem contract: instances must round-trip through +Marshal.dump+ / +Marshal.load+
14
+ # so web can cache one scrape and render RSS or JSON Feed on read. Loaded results
15
+ # are re-frozen via private {#marshal_load}. For trusted cache reads only,
16
+ # +Marshal.load(payload, freeze: true)+ is also safe.
17
+ class FeedResult
18
+ ##
19
+ # @param channel [Html2rss::Channel] channel metadata (internal; not exposed on the public API)
20
+ # @param articles [Array<Html2rss::Article>] extracted articles (deduplicated; not exposed)
21
+ # @param status [Html2rss::Status] pipeline telemetry
22
+ # @param stylesheets [Array<Hash>] optional RSS stylesheet configs
23
+ def initialize(channel:, articles:, status:, stylesheets: [])
24
+ raise ArgumentError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
25
+ raise ArgumentError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
26
+
27
+ articles = Array(articles)
28
+ raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
29
+
30
+ @channel = channel
31
+ @articles = articles.dup.freeze
32
+ @status = status
33
+ @stylesheets = freeze_stylesheets(stylesheets)
34
+ freeze
35
+ end
36
+
37
+ ##
38
+ # @return [Boolean] true when the scrape produced no items
39
+ def empty? = @articles.empty?
40
+
41
+ ##
42
+ # Channel title string only — does not expose the channel object.
43
+ #
44
+ # @return [String] title from the materialized channel
45
+ def channel_title = @channel.title
46
+
47
+ ##
48
+ # @return [RSS::Rss] RSS 2.0 document
49
+ def to_rss
50
+ FeedBuilder::Rss.new(
51
+ channel: @channel,
52
+ articles: @articles,
53
+ stylesheets: @stylesheets,
54
+ generator: status.to_generator_comment
55
+ ).call
56
+ end
57
+
58
+ ##
59
+ # @param feed_url [String, nil] optional self URL for JSON Feed (+feed_url+)
60
+ # @return [Hash] JSON Feed 1.1 hash
61
+ def to_json_feed(feed_url: nil)
62
+ FeedBuilder::JsonFeed.new(
63
+ channel: @channel,
64
+ articles: @articles,
65
+ feed_url:,
66
+ user_comment: status.to_generator_comment
67
+ ).call
68
+ end
69
+
70
+ ##
71
+ # @return [Html2rss::Status] frozen scraper tallies, dedup count, and generator formatter.
72
+ # {#to_h} on +status+ is a stable consumer contract for observability payloads.
73
+ attr_reader :status
74
+
75
+ private
76
+
77
+ # Constructor payload for Marshal (avoids default ivar thaw). Rebuilds via {#initialize}.
78
+ def marshal_dump = [@channel, @articles, @status, @stylesheets]
79
+
80
+ def marshal_load((channel, articles, status, stylesheets))
81
+ initialize(channel:, articles:, status:, stylesheets:)
82
+ end
83
+
84
+ def freeze_stylesheets(stylesheets)
85
+ Array(stylesheets).map do |sheet|
86
+ sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
87
+ value.is_a?(String) ? value.dup.freeze : value
88
+ end.freeze
89
+ end.freeze
90
+ end
91
+ end
92
+ end
@@ -10,7 +10,7 @@ module Html2rss
10
10
  # Default Botasaurus scrape options when no explicit config is provided.
11
11
  DEFAULT_OPTIONS = {
12
12
  navigation_mode: 'auto',
13
- max_retries: 2,
13
+ max_retries: 1,
14
14
  headless: false
15
15
  }.freeze
16
16
 
@@ -30,6 +30,17 @@ module Html2rss
30
30
  lang
31
31
  ].freeze
32
32
 
33
+ # Allowlisted upstream response keys exposed as Response#transport_meta.
34
+ META_KEYS = %w[
35
+ request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
36
+ ].freeze
37
+
38
+ # Remaining seconds at or below which Botasaurus retries are disabled.
39
+ TIGHT_BUDGET_SECONDS = 12
40
+
41
+ # Seconds reserved from remaining budget before setting wait_timeout_seconds.
42
+ BUDGET_WAIT_RESERVE_SECONDS = 2
43
+
33
44
  # Parsed Botasaurus response wrapper.
34
45
  class ParsedResponse
35
46
  # Fallback headers when upstream omits response headers.
@@ -90,6 +101,9 @@ module Html2rss
90
101
  # @return [String, nil] final URL reported by upstream
91
102
  def final_url = payload['final_url']
92
103
 
104
+ # @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
105
+ def transport_meta = payload.slice(*META_KEYS).compact.freeze
106
+
93
107
  private
94
108
 
95
109
  attr_reader :payload, :transport_status
@@ -109,6 +123,7 @@ module Html2rss
109
123
  ##
110
124
  # @param url [Html2rss::Url] canonical URL to scrape
111
125
  # @param options [Hash] validated request.botasaurus options
126
+ # @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
112
127
  # @option options [String] :navigation_mode
113
128
  # @option options [Integer] :max_retries
114
129
  # @option options [String] :wait_for_selector
@@ -121,14 +136,15 @@ module Html2rss
121
136
  # @option options [String] :user_agent
122
137
  # @option options [Array<Integer>] :window_size
123
138
  # @option options [String] :lang
124
- def initialize(url:, options: {})
139
+ def initialize(url:, options: {}, remaining_timeout_seconds: nil)
125
140
  @url = url
126
141
  @options = options
142
+ @remaining_timeout_seconds = remaining_timeout_seconds
127
143
  end
128
144
 
129
- # @return [Hash] payload for POST /scrape
145
+ # @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
130
146
  def request_payload
131
- DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s)
147
+ DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s).then { clamp_for_budget(_1) }
132
148
  end
133
149
 
134
150
  # @param transport_response [Faraday::Response] upstream HTTP response
@@ -145,13 +161,25 @@ module Html2rss
145
161
 
146
162
  private
147
163
 
148
- attr_reader :url, :options
164
+ attr_reader :url, :options, :remaining_timeout_seconds
149
165
 
150
166
  def filtered_options
151
167
  OPTION_KEYS.each_with_object({}) do |key, normalized|
152
168
  normalized[key] = options[key] if options.key?(key)
153
169
  end
154
170
  end
171
+
172
+ def clamp_for_budget(payload)
173
+ remaining = remaining_timeout_seconds
174
+ return payload if remaining.nil?
175
+
176
+ clamped = payload.dup
177
+ clamped[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
178
+ budget_wait = [1, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
179
+ configured = clamped[:wait_timeout_seconds]
180
+ clamped[:wait_timeout_seconds] = configured ? [configured, budget_wait].min : budget_wait
181
+ clamped
182
+ end
155
183
  end
156
184
  end
157
185
  end
@@ -27,7 +27,8 @@ module Html2rss
27
27
  body: parsed_response.html,
28
28
  headers: parsed_response.headers,
29
29
  url: response_url(parsed_response.final_url),
30
- status: parsed_response.status
30
+ status: parsed_response.status,
31
+ transport_meta: parsed_response.transport_meta
31
32
  )
32
33
  end
33
34
 
@@ -52,7 +53,11 @@ module Html2rss
52
53
  end
53
54
 
54
55
  def contract
55
- @contract ||= BotasaurusContract.new(url: ctx.url, options: ctx.request.fetch(:botasaurus, {}))
56
+ @contract ||= BotasaurusContract.new(
57
+ url: ctx.url,
58
+ options: ctx.request.fetch(:botasaurus, {}),
59
+ remaining_timeout_seconds: attempt_timeout_seconds
60
+ )
56
61
  end
57
62
 
58
63
  def client
@@ -60,9 +65,13 @@ module Html2rss
60
65
  end
61
66
 
62
67
  def request_options
63
- timeout = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
68
+ { timeout: attempt_timeout_seconds.to_i }
69
+ end
64
70
 
65
- { timeout: timeout.to_i }
71
+ def attempt_timeout_seconds
72
+ @attempt_timeout_seconds ||= ctx.budget.effective_timeout_seconds(
73
+ fallback: ctx.policy.total_timeout_seconds
74
+ )
66
75
  end
67
76
 
68
77
  def content_type_header
@@ -82,11 +82,16 @@ module Html2rss
82
82
  def remaining_timeout_seconds
83
83
  return unless @total_timeout_seconds
84
84
 
85
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
86
- remaining = @total_timeout_seconds - elapsed
85
+ remaining = @total_timeout_seconds - elapsed_seconds
87
86
  [remaining, 0.0].max
88
87
  end
89
88
 
89
+ ##
90
+ # @return [Float] seconds since this budget was created
91
+ def elapsed_seconds
92
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
93
+ end
94
+
90
95
  ##
91
96
  # Resolves wall-clock seconds for the next adapter attempt.
92
97
  #
@@ -7,12 +7,16 @@ module Html2rss
7
7
  ##
8
8
  # To be used by strategies to provide their response.
9
9
  class Response
10
+ # Default when a strategy does not attach transport telemetry.
11
+ EMPTY_TRANSPORT_META = {}.freeze
12
+
10
13
  ##
11
14
  # @param body [String] the body of the response
12
15
  # @param url [Html2rss::Url] the final request URL
13
16
  # @param headers [Hash] the headers of the response
14
17
  # @param status [Integer, nil] the HTTP status code when available
15
- def initialize(body:, url:, headers: {}, status: nil)
18
+ # @param transport_meta [Hash] allowlisted upstream telemetry (frozen when present)
19
+ def initialize(body:, url:, headers: {}, status: nil, transport_meta: EMPTY_TRANSPORT_META)
16
20
  @body = body
17
21
 
18
22
  headers = headers.dup
@@ -22,6 +26,7 @@ module Html2rss
22
26
  @headers = headers
23
27
  @status = status
24
28
  @url = url
29
+ @transport_meta = transport_meta.nil? || transport_meta.empty? ? EMPTY_TRANSPORT_META : transport_meta.freeze
25
30
  end
26
31
 
27
32
  # @return [String] the raw body of the response
@@ -36,6 +41,9 @@ module Html2rss
36
41
  # @return [Html2rss::Url] the URL of the response
37
42
  attr_reader :url
38
43
 
44
+ # @return [Hash] allowlisted upstream transport telemetry
45
+ attr_reader :transport_meta
46
+
39
47
  # @return [String] normalized content type header value
40
48
  def content_type = header('content-type').to_s
41
49
 
@@ -86,6 +86,9 @@ module Html2rss
86
86
 
87
87
  def check_timeout!
88
88
  ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
89
+ rescue RequestTimedOut
90
+ log_timeout!(reason: 'budget_exhausted')
91
+ raise
89
92
  end
90
93
 
91
94
  # @return [ResponseGuard, nil]
@@ -96,6 +99,8 @@ module Html2rss
96
99
  # @raise [StandardError]
97
100
  def handle_error(error)
98
101
  if timeout_error?(error)
102
+ log_timeout!(reason: 'transport')
103
+ Log.debug("#{self.class}: transport timeout message=#{error.message}")
99
104
  raise RequestTimedOut, error.message
100
105
  elsif connection_error?(error)
101
106
  translate_connection_error(error)
@@ -104,6 +109,23 @@ module Html2rss
104
109
  end
105
110
  end
106
111
 
112
+ # @param reason [String] timeout classification (budget_exhausted / transport)
113
+ # @return [void]
114
+ # rubocop:disable Metrics/AbcSize -- structured timeout fields stay in one log line
115
+ def log_timeout!(reason:)
116
+ remaining = ctx.budget.remaining_timeout_seconds
117
+ remaining_label = remaining.nil? ? 'untracked' : format('%.3f', remaining)
118
+ detail = [
119
+ "strategy=#{self.class.name}",
120
+ "host=#{ctx.url.host}",
121
+ "elapsed=#{format('%.3f', ctx.budget.elapsed_seconds)}s",
122
+ "budget_remaining=#{remaining_label}",
123
+ "reason=#{reason}"
124
+ ]
125
+ Log.info("#{self.class}: request timeout #{detail.join(' ')}")
126
+ end
127
+ # rubocop:enable Metrics/AbcSize
128
+
107
129
  # @param error [StandardError]
108
130
  # @return [void]
109
131
  # @raise [StandardError]
@@ -109,6 +109,10 @@ module Html2rss
109
109
  visited_urls.add(normalize_url(url))
110
110
  end
111
111
 
112
+ ##
113
+ # @return [Html2rss::Url] the session's current request URL
114
+ def url = context.url
115
+
112
116
  private
113
117
 
114
118
  attr_reader :context, :strategy, :logger, :visited_urls