html2rss 0.27.1 → 0.28.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/README.md +4 -1
  3. data/lib/html2rss/auto_source/scraper/native_feed.rb +90 -0
  4. data/lib/html2rss/auto_source/scraper.rb +4 -1
  5. data/lib/html2rss/auto_source.rb +7 -0
  6. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  7. data/lib/html2rss/config.rb +10 -1
  8. data/lib/html2rss/feed_pipeline/README.md +2 -0
  9. data/lib/html2rss/feed_pipeline/auto_fallback.rb +53 -13
  10. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  11. data/lib/html2rss/feed_pipeline.rb +24 -8
  12. data/lib/html2rss/feed_resolution/README.md +52 -0
  13. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  14. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  15. data/lib/html2rss/feed_resolution/options.rb +31 -0
  16. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  17. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  18. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  19. data/lib/html2rss/feed_resolution.rb +274 -0
  20. data/lib/html2rss/mcp/inspect.rb +18 -121
  21. data/lib/html2rss/mcp/outcome.rb +4 -2
  22. data/lib/html2rss/page_recon.rb +243 -0
  23. data/lib/html2rss/request_service/botasaurus_contract.rb +24 -47
  24. data/lib/html2rss/request_service/botasaurus_strategy.rb +34 -12
  25. data/lib/html2rss/request_service/response.rb +26 -2
  26. data/lib/html2rss/request_service.rb +13 -1
  27. data/lib/html2rss/request_session.rb +5 -2
  28. data/lib/html2rss/scrape_target.rb +24 -0
  29. data/lib/html2rss/status.rb +49 -22
  30. data/lib/html2rss/surface_category.rb +63 -0
  31. data/lib/html2rss/syndication/README.md +21 -0
  32. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  33. data/lib/html2rss/syndication/discovery.rb +217 -0
  34. data/lib/html2rss/syndication/parser.rb +137 -0
  35. data/lib/html2rss/syndication.rb +10 -0
  36. data/lib/html2rss/url.rb +22 -0
  37. data/lib/html2rss/version.rb +1 -1
  38. data/schema/html2rss-config.schema.json +39 -1
  39. metadata +19 -2
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Shared page recon for MCP Inspect and FeedResolution probes.
6
+ #
7
+ # Owns surface class, native feed hints, segment stats, and a cheap AutoSource
8
+ # article count — not request strategy selection or MCP next-step policy.
9
+ class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
10
+ ##
11
+ # Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
12
+ Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response) do
13
+ ##
14
+ # @return [Html2rss::SurfaceCategory]
15
+ def category = SurfaceCategory.coerce(surface_category)
16
+
17
+ ##
18
+ # @return [Boolean]
19
+ def weak? = category.weak?
20
+
21
+ ##
22
+ # @return [Boolean]
23
+ def blocked? = category.blocked?
24
+
25
+ ##
26
+ # @return [Boolean]
27
+ def listing_bonus? = category.listing_bonus?
28
+ end
29
+ ##
30
+ # Recon facts used by Inspect and FeedResolution.
31
+ Result = Data.define(
32
+ :requested_url,
33
+ :final_url,
34
+ :status,
35
+ :scheme_downgrade,
36
+ :alternate_feeds,
37
+ :surface_category,
38
+ :articles_count,
39
+ :admission_drops,
40
+ :segment_stats,
41
+ :html_response,
42
+ :content_type,
43
+ :blocked_surface,
44
+ :sst
45
+ ) do
46
+ ##
47
+ # @return [Hash{Symbol => Object}]
48
+ def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- omit-empty optional keys
49
+ {
50
+ requested_url:,
51
+ final_url:,
52
+ status:,
53
+ scheme_downgrade:,
54
+ alternate_feeds:,
55
+ surface_category:,
56
+ articles_count:,
57
+ html_response:,
58
+ content_type:,
59
+ **(admission_drops.any? ? { admission_drops: } : {}),
60
+ **(segment_stats ? { segment_stats: } : {}),
61
+ **(blocked_surface ? { blocked_surface: } : {}),
62
+ **(sst ? { sst: } : {})
63
+ }
64
+ end
65
+ end
66
+
67
+ ##
68
+ # @param response [Html2rss::RequestService::Response]
69
+ # @param url [String, Html2rss::Url] requested entry URL
70
+ # @param strategy [Symbol, nil] unused (reserved for callers that already chose a strategy)
71
+ # @return [Result]
72
+ def self.call(response:, url:, strategy: nil) # rubocop:disable Lint/UnusedMethodArgument
73
+ new(response:, url:).call
74
+ end
75
+
76
+ ##
77
+ # Cheap page assessment for policy gates and probe scoring (fixed AutoSource limit).
78
+ #
79
+ # @param response [Html2rss::RequestService::Response]
80
+ # @param url [String, Html2rss::Url]
81
+ # @return [Assessment]
82
+ def self.assess(response:, url:)
83
+ new(response:, url:).assess
84
+ end
85
+
86
+ ##
87
+ # Surface class only — no AutoSource extract (for empty-extract error labels).
88
+ #
89
+ # @param response [Html2rss::RequestService::Response]
90
+ # @param url [String, Html2rss::Url]
91
+ # @return [Symbol]
92
+ def self.surface_category_for(response:, url:)
93
+ new(response:, url:).surface_category_for
94
+ end
95
+
96
+ ##
97
+ # @param sst [Html2rss::SST::Document]
98
+ # @param url [String, Html2rss::Url]
99
+ # @return [Array]
100
+ def self.discover_segments(sst, url)
101
+ link_resolver = Scoring::LinkResolver.new(url)
102
+ AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
103
+ rescue StandardError
104
+ []
105
+ end
106
+
107
+ ##
108
+ # @param response [Html2rss::RequestService::Response]
109
+ # @param url [String, Html2rss::Url]
110
+ def initialize(response:, url:)
111
+ @response = response
112
+ @url = url
113
+ end
114
+
115
+ ##
116
+ # @return [Assessment]
117
+ def assess
118
+ return feed_assessment if response.feed_response?
119
+
120
+ parsed = html_parsed_body
121
+ articles_count, admission_drops = cheap_articles
122
+ Assessment.new(
123
+ surface_category: surface_category(parsed),
124
+ articles_count:,
125
+ admission_drops:,
126
+ html_response: true
127
+ )
128
+ end
129
+
130
+ ##
131
+ # @return [Symbol]
132
+ def surface_category_for
133
+ return :unsupported_surface if response.feed_response?
134
+
135
+ surface_category(html_parsed_body)
136
+ end
137
+
138
+ ##
139
+ # @return [Result]
140
+ def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- assemble recon Result
141
+ requested = Url.from_absolute(url)
142
+ final = response.url
143
+ parsed = html_parsed_body
144
+ assessment = assess
145
+ sst_payload, segment_stats = sst_payload_and_segments(requested)
146
+
147
+ Result.new(
148
+ requested_url: requested.to_s,
149
+ final_url: final.to_s,
150
+ status: response.status,
151
+ scheme_downgrade: scheme_downgrade?(requested, final),
152
+ alternate_feeds: alternate_feeds_from(parsed),
153
+ surface_category: assessment.surface_category,
154
+ articles_count: assessment.articles_count,
155
+ admission_drops: assessment.admission_drops,
156
+ segment_stats:,
157
+ html_response: response.html_response?,
158
+ content_type: response.content_type,
159
+ blocked_surface: blocked_surface_key,
160
+ sst: sst_payload
161
+ )
162
+ end
163
+
164
+ private
165
+
166
+ attr_reader :response, :url
167
+
168
+ def feed_assessment
169
+ Assessment.new(
170
+ surface_category: :unsupported_surface,
171
+ articles_count: 0,
172
+ admission_drops: {},
173
+ html_response: false
174
+ )
175
+ end
176
+
177
+ def html_parsed_body
178
+ return unless response.html_response?
179
+
180
+ response.parsed_body
181
+ rescue RequestService::UnsupportedResponseContentType
182
+ nil
183
+ end
184
+
185
+ def surface_category(parsed)
186
+ return :unsupported_surface unless parsed
187
+
188
+ AutoSource::Scraper.classify_no_scraper_surface(parsed, body: response.body)
189
+ end
190
+
191
+ def cheap_articles
192
+ return [0, {}] unless response.html_response?
193
+
194
+ source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
195
+ [source.articles.size, source.admission_drops]
196
+ end
197
+
198
+ def sst_payload_and_segments(requested)
199
+ return [nil, nil] unless response.html_response?
200
+
201
+ sst = sst_document
202
+ return [nil, nil] unless sst
203
+
204
+ stats = segment_stats(sst, requested)
205
+ [
206
+ { node_count: sst.node_count, degraded: sst.degraded, segment_stats: stats },
207
+ stats
208
+ ]
209
+ end
210
+
211
+ def sst_document
212
+ Html2rss::SST::Normalizer.call(response.body)
213
+ rescue ArgumentError
214
+ nil
215
+ end
216
+
217
+ def segment_stats(sst, page_url)
218
+ segments = self.class.discover_segments(sst, page_url)
219
+ return { found: 0 } if segments.empty?
220
+
221
+ {
222
+ found: segments.size,
223
+ strategies: segments.map(&:strategy).uniq,
224
+ sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
225
+ }
226
+ end
227
+
228
+ def scheme_downgrade?(requested, final)
229
+ requested.scheme == 'https' && final.scheme == 'http'
230
+ end
231
+
232
+ def alternate_feeds_from(parsed)
233
+ return [] unless parsed.is_a?(Nokogiri::HTML::Document)
234
+
235
+ Html::FeedLink.from_document(parsed).map { |link| { href: link.href, mime_type: link.mime_type } }
236
+ end
237
+
238
+ def blocked_surface_key
239
+ blocked = RequestService::BlockedSurface.interstitial_signature_for(response.body)
240
+ blocked[:key].to_s if blocked
241
+ end
242
+ end
243
+ end
@@ -7,7 +7,7 @@ module Html2rss
7
7
  ##
8
8
  # Maps html2rss request/response handling to botasaurus-scrape-api OpenAPI 2.0
9
9
  # +ScrapeRequest+ / +ScrapeSuccess+ / +ScrapeError+ (sibling +openapi.yaml+).
10
- class BotasaurusContract # rubocop:disable Metrics/ClassLength -- Success/Error envelopes stay with the owner
10
+ class BotasaurusContract
11
11
  # Closed set from OpenAPI ExecutionMode.
12
12
  EXECUTION_MODES = %w[auto request browser].freeze
13
13
  # Closed set from OpenAPI NavigationMode.
@@ -25,27 +25,28 @@ module Html2rss
25
25
  # OpenAPI WindowSize required keys (positive integers).
26
26
  WINDOW_SIZE_PROPERTIES = %i[width height].freeze
27
27
 
28
- # Published wait clamp floor (`[1, 20]` in OpenAPI description).
28
+ # Faraday POST /scrape cap mirroring botasaurus-scrape-api total scrape wall (`SCRAPE_TIMEOUT_SECONDS`).
29
+ SCRAPE_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_TIMEOUT_SECONDS', 45))
30
+ # Post-boot navigate/wait budget mirrored from scrape-api (`SCRAPE_WORK_TIMEOUT_SECONDS`).
31
+ SCRAPE_WORK_TIMEOUT_SECONDS = Integer(ENV.fetch('BOTASAURUS_SCRAPE_WORK_TIMEOUT_SECONDS', 30))
32
+ # Seconds added to the scrape total for client transport overhead.
33
+ TRANSPORT_BUFFER_SECONDS = 2
34
+
35
+ # Published wait clamp floor (`[1, SCRAPE_WORK_TIMEOUT_SECONDS]` in OpenAPI description).
29
36
  MIN_WAIT_TIMEOUT_SECONDS = 1
30
37
  # Published wait clamp ceiling; YAML values above this are rejected.
31
- MAX_WAIT_TIMEOUT_SECONDS = 20
38
+ MAX_WAIT_TIMEOUT_SECONDS = SCRAPE_WORK_TIMEOUT_SECONDS
32
39
  # OpenAPI wait_timeout_seconds default (omitted from the client payload).
33
- DEFAULT_WAIT_TIMEOUT_SECONDS = 15
40
+ DEFAULT_WAIT_TIMEOUT_SECONDS = [15, SCRAPE_WORK_TIMEOUT_SECONDS].min
34
41
 
35
42
  # OpenAPI max_retries maximum (default 2 is applied upstream when omitted).
36
43
  MAX_RETRIES = 3
37
44
 
38
45
  # Allowlisted ScrapeDiagnostics keys nested under diagnostics.
39
- DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge].freeze
46
+ DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge timeout_phase].freeze
40
47
  # Allowlisted ChallengeSignal keys nested under diagnostics.challenge.
41
48
  CHALLENGE_KEYS = %w[blocked detected marker].freeze
42
49
 
43
- # Remaining seconds at or below which Botasaurus retries are disabled.
44
- TIGHT_BUDGET_SECONDS = 12
45
-
46
- # Seconds reserved from remaining budget before setting wait_timeout_seconds.
47
- BUDGET_WAIT_RESERVE_SECONDS = 2
48
-
49
50
  ##
50
51
  # Parsed OpenAPI ScrapeSuccess envelope (HTTP 200).
51
52
  class Success
@@ -184,10 +185,17 @@ module Html2rss
184
185
  'Botasaurus challenge block detected.'
185
186
  end
186
187
 
188
+ # @return [String, nil] scrape-api timeout stage when present on diagnostics
189
+ def timeout_phase
190
+ value = diagnostics['timeout_phase']
191
+ value.is_a?(String) && !value.empty? ? value : nil
192
+ end
193
+
187
194
  # @return [String] actionable upstream failure summary
188
195
  def failure_message
189
196
  details = ["status=#{transport_status}", "error_category=#{error_category}", "error=#{error}"]
190
197
  details << "request_id=#{request_id}" if request_id
198
+ details << "timeout_phase=#{timeout_phase}" if timeout_phase
191
199
  "Botasaurus scrape failed (#{details.join(', ')})."
192
200
  end
193
201
 
@@ -246,7 +254,6 @@ module Html2rss
246
254
  # @param url [Html2rss::Url] canonical URL to scrape
247
255
  # @param headers [Hash] request headers from context
248
256
  # @param options [Hash] validated request.botasaurus options
249
- # @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
250
257
  # @option options [String] :execution_mode
251
258
  # @option options [String] :navigation_mode
252
259
  # @option options [Integer] :max_retries
@@ -264,19 +271,19 @@ module Html2rss
264
271
  # @option options [String] :lang
265
272
  # @option options [Hash] :cookies
266
273
  # @option options [Hash] :headers
267
- def initialize(url:, headers: {}, options: {}, remaining_timeout_seconds: nil)
274
+ def initialize(url:, headers: {}, options: {})
268
275
  @url = url
269
276
  @headers = headers
270
277
  @options = options
271
- @remaining_timeout_seconds = remaining_timeout_seconds
272
278
  end
273
279
 
274
- # @return [Hash] payload for POST /scrape (explicit options plus budget clamp-downs)
280
+ # @return [Hash] payload for POST /scrape (explicit options plus wait cap)
275
281
  def request_payload
276
282
  payload = { url: url.to_s }.merge(filtered_options)
277
283
  forwarded_headers = merged_headers
278
284
  payload[:headers] = forwarded_headers if forwarded_headers&.any?
279
- clamp_for_budget(payload)
285
+ cap_wait_timeout!(payload)
286
+ payload
280
287
  end
281
288
 
282
289
  # @param transport_response [Faraday::Response] upstream HTTP response
@@ -291,7 +298,7 @@ module Html2rss
291
298
 
292
299
  private
293
300
 
294
- attr_reader :url, :headers, :options, :remaining_timeout_seconds
301
+ attr_reader :url, :headers, :options
295
302
 
296
303
  def json_object(body)
297
304
  payload = JSON.parse(body)
@@ -315,36 +322,6 @@ module Html2rss
315
322
  end
316
323
  end
317
324
 
318
- def clamp_for_budget(payload)
319
- remaining = remaining_timeout_seconds
320
- clamped = payload.dup
321
- unless remaining.nil?
322
- apply_tight_retries!(clamped, remaining)
323
- apply_budget_wait!(clamped, remaining)
324
- end
325
- cap_wait_timeout!(clamped)
326
- clamped
327
- end
328
-
329
- def apply_tight_retries!(payload, remaining)
330
- payload[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
331
- end
332
-
333
- def apply_budget_wait!(payload, remaining)
334
- budget_wait = budget_wait_seconds(remaining)
335
- configured = payload[:wait_timeout_seconds]
336
- if configured
337
- payload[:wait_timeout_seconds] = [configured, budget_wait].min
338
- elsif budget_wait < DEFAULT_WAIT_TIMEOUT_SECONDS
339
- payload[:wait_timeout_seconds] = budget_wait
340
- end
341
- end
342
-
343
- def budget_wait_seconds(remaining)
344
- reserved = [MIN_WAIT_TIMEOUT_SECONDS, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
345
- [reserved, MAX_WAIT_TIMEOUT_SECONDS].min
346
- end
347
-
348
325
  def cap_wait_timeout!(payload)
349
326
  wait = payload[:wait_timeout_seconds]
350
327
  return unless wait
@@ -2,12 +2,20 @@
2
2
 
3
3
  require 'faraday'
4
4
  require 'json'
5
+ require 'securerandom'
5
6
 
6
7
  module Html2rss
7
8
  class RequestService
8
9
  ##
9
10
  # Strategy to delegate fetching to a Botasaurus scrape API.
10
11
  class BotasaurusStrategy < Strategy
12
+ # Content-Type negotiation for the scrape API transport hop.
13
+ TRANSPORT_ACCEPT = 'application/json'
14
+ # Disable compressed bodies so Faraday returns raw JSON without implicit decoding surprises.
15
+ TRANSPORT_ENCODING = 'identity'
16
+ # Correlates each POST /scrape with botasaurus-scrape-api request logs.
17
+ REQUEST_ID_HEADER = 'X-Request-Id'
18
+
11
19
  private
12
20
 
13
21
  def fetch
@@ -18,7 +26,9 @@ module Html2rss
18
26
  end
19
27
 
20
28
  def post_scrape_request
21
- transport_response = client.post('/scrape', JSON.generate(contract.request_payload), content_type_header)
29
+ request_id = SecureRandom.uuid
30
+ Log.debug("#{self.class}: POST /scrape #{REQUEST_ID_HEADER}=#{request_id}")
31
+ transport_response = client.post('/scrape', JSON.generate(contract.request_payload), post_headers(request_id))
22
32
  contract.parse_response(transport_response)
23
33
  end
24
34
 
@@ -49,7 +59,7 @@ module Html2rss
49
59
  return unless error.timeout?
50
60
 
51
61
  log_timeout!(reason: 'botasaurus_upstream')
52
- raise RequestTimedOut, error.failure_message
62
+ raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
53
63
  end
54
64
 
55
65
  def response_url(final_url)
@@ -64,13 +74,28 @@ module Html2rss
64
74
  @contract ||= BotasaurusContract.new(
65
75
  url: ctx.url,
66
76
  headers: ctx.headers,
67
- options: ctx.request.fetch(:botasaurus, {}),
68
- remaining_timeout_seconds: attempt_timeout_seconds
77
+ options: ctx.request.fetch(:botasaurus, {})
69
78
  )
70
79
  end
71
80
 
72
81
  def client
73
- @client ||= Faraday.new(url: scraper_base_url.to_s, request: request_options)
82
+ # No :gzip middleware on this client — compression is for remote target fetches only.
83
+ @client ||= Faraday.new(url: scraper_base_url.to_s, headers: client_headers, request: request_options)
84
+ end
85
+
86
+ def client_headers
87
+ {
88
+ 'User-Agent' => Config::RequestHeaders::DEFAULT_USER_AGENT,
89
+ 'Accept' => TRANSPORT_ACCEPT,
90
+ 'Accept-Encoding' => TRANSPORT_ENCODING
91
+ }
92
+ end
93
+
94
+ def post_headers(request_id)
95
+ {
96
+ 'Content-Type' => 'application/json',
97
+ REQUEST_ID_HEADER => request_id
98
+ }
74
99
  end
75
100
 
76
101
  def request_options
@@ -78,13 +103,10 @@ module Html2rss
78
103
  end
79
104
 
80
105
  def attempt_timeout_seconds
81
- @attempt_timeout_seconds ||= ctx.budget.effective_timeout_seconds(
82
- fallback: ctx.policy.total_timeout_seconds
83
- )
84
- end
85
-
86
- def content_type_header
87
- { 'Content-Type' => 'application/json' }
106
+ @attempt_timeout_seconds ||= begin
107
+ budget = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
108
+ [budget, BotasaurusContract::SCRAPE_TIMEOUT_SECONDS + BotasaurusContract::TRANSPORT_BUFFER_SECONDS].min
109
+ end
88
110
  end
89
111
 
90
112
  def scraper_base_url
@@ -14,11 +14,15 @@ module Html2rss
14
14
 
15
15
  # Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
16
16
  HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
17
+ # Content-Type markers for RSS/Atom syndication responses.
18
+ FEED_CT_MARKERS = %w[rss+xml atom+xml rss atom].freeze
19
+ # Body sniff for unlabeled syndication (first 800 bytes, downcased).
20
+ FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed '].freeze
17
21
  # Charset from Content-Type or a leading <meta charset>.
18
22
  CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
19
23
  # Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
20
24
  META_CHARSET_BYTES = 2048
21
- private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES
25
+ private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES, :FEED_CT_MARKERS, :FEED_BODY_MARKERS
22
26
 
23
27
  ##
24
28
  # @param body [String] the body of the response
@@ -74,7 +78,14 @@ module Html2rss
74
78
 
75
79
  # @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
76
80
  def html_response?
77
- content_type.include?('text/html') || (!json_response? && html_looking_body?)
81
+ content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
82
+ end
83
+
84
+ # @return [Boolean] whether response content is RSS/Atom (header or sniffed body)
85
+ def feed_response?
86
+ return @feed_response if defined?(@feed_response)
87
+
88
+ @feed_response = feed_content_type? || (!json_response? && !html_looking_body? && feed_looking_body?)
78
89
  end
79
90
 
80
91
  ##
@@ -143,6 +154,19 @@ module Html2rss
143
154
  def html_looking_body?
144
155
  body.to_s.b.match?(HTML_BODY_SNIFF)
145
156
  end
157
+
158
+ def feed_content_type?
159
+ ct = content_type.downcase
160
+ return false if ct.empty? || ct.include?('html') || ct.include?('json')
161
+
162
+ # Substring match against Content-Type (not Array#intersect? on a String).
163
+ FEED_CT_MARKERS.any? { |marker| ct.include?(marker) } # rubocop:disable Style/ArrayIntersect
164
+ end
165
+
166
+ def feed_looking_body?
167
+ snippet = body.to_s[0, 800].downcase
168
+ FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) } # rubocop:disable Style/ArrayIntersect
169
+ end
146
170
  end
147
171
  end
148
172
  end
@@ -31,8 +31,20 @@ module Html2rss
31
31
  class ResponseTooLarge < Html2rss::Error; end
32
32
  # Raised when blocked content surfaces are detected.
33
33
  class BlockedSurfaceDetected < Html2rss::Error; end
34
+
34
35
  # Raised when a request times out.
35
- class RequestTimedOut < Html2rss::Error; end
36
+ class RequestTimedOut < Html2rss::Error
37
+ ##
38
+ # @param message [String, nil] timeout failure summary
39
+ # @param timeout_phase [String, nil] scrape-api stage when known (+queue+/+boot+/+work+)
40
+ def initialize(message = nil, timeout_phase: nil)
41
+ @timeout_phase = timeout_phase
42
+ super(message)
43
+ end
44
+
45
+ # @return [String, nil] scrape-api timeout stage, or nil for transport/budget timeouts
46
+ attr_reader :timeout_phase
47
+ end
36
48
 
37
49
  # Raised when Botasaurus configuration is missing or invalid.
38
50
  class BotasaurusConfigurationError < Html2rss::Error
@@ -15,14 +15,17 @@ module Html2rss
15
15
  # @param strategy [Symbol] request strategy for the session
16
16
  # @param budget [RequestService::Budget] shared request budget
17
17
  # @param policy [RequestService::Policy] request policy (from FeedPipeline::RuntimePolicy.resources_for)
18
+ # @param scrape_url [String, nil] effective fetch URL when it differs from config channel URL
18
19
  # @param logger [Logger] logger used for operational warnings
19
20
  # @return [RequestSession] configured request session
20
- def build(config:, strategy:, budget:, policy:, logger: Html2rss::Log)
21
+ # rubocop:disable Metrics/ParameterLists -- scrape_url override stays beside config
22
+ def build(config:, strategy:, budget:, policy:, scrape_url: nil, logger: Html2rss::Log)
21
23
  context = RequestService::Context.new(
22
- url: config.url, headers: config.headers, request: config.request, policy:, budget:
24
+ url: scrape_url || config.url, headers: config.headers, request: config.request, policy:, budget:
23
25
  )
24
26
  new(context:, strategy:, logger:)
25
27
  end
28
+ # rubocop:enable Metrics/ParameterLists
26
29
  end
27
30
 
28
31
  ##
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Immutable entry vs effective scrape URLs for one pipeline run.
6
+ #
7
+ # Replaces mutating {Config#scrape_url=} after {FeedResolution} rewrites the fetch URL.
8
+ ScrapeTarget = Data.define(:entry_url, :effective_url) do
9
+ ##
10
+ # @param config [Html2rss::Config]
11
+ # @return [ScrapeTarget]
12
+ def self.from_config(config)
13
+ entry = config.url
14
+ new(entry_url: entry, effective_url: entry)
15
+ end
16
+
17
+ ##
18
+ # @param url [String, Html2rss::Url]
19
+ # @return [ScrapeTarget]
20
+ def with_effective(url)
21
+ self.class.new(entry_url:, effective_url: Url.from_absolute(url).to_s)
22
+ end
23
+ end
24
+ end