html2rss 0.26.0 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -18
  3. data/html2rss.gemspec +1 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +121 -56
  6. data/lib/html2rss/auto_source/scraper.rb +13 -0
  7. data/lib/html2rss/auto_source.rb +34 -8
  8. data/lib/html2rss/capture/README.md +61 -0
  9. data/lib/html2rss/capture.rb +120 -117
  10. data/lib/html2rss/cli.rb +35 -17
  11. data/lib/html2rss/config/schema.rb +12 -0
  12. data/lib/html2rss/config/validator.rb +31 -8
  13. data/lib/html2rss/config.rb +28 -0
  14. data/lib/html2rss/error.rb +24 -6
  15. data/lib/html2rss/feed_pipeline/README.md +42 -0
  16. data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
  17. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  18. data/lib/html2rss/feed_pipeline.rb +30 -12
  19. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  20. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  21. data/lib/html2rss/html/article_extractor.rb +95 -17
  22. data/lib/html2rss/html/article_rules/category.rb +28 -11
  23. data/lib/html2rss/html/article_rules/date.rb +60 -6
  24. data/lib/html2rss/html/article_rules/description.rb +122 -0
  25. data/lib/html2rss/html/card_walk.rb +42 -0
  26. data/lib/html2rss/html/feed_link.rb +34 -0
  27. data/lib/html2rss/html/navigator.rb +18 -0
  28. data/lib/html2rss/html/sst_article_extractor.rb +119 -32
  29. data/lib/html2rss/link_destination/path_classifier.rb +49 -35
  30. data/lib/html2rss/mcp/config_argument.rb +42 -0
  31. data/lib/html2rss/mcp/contract.rb +173 -0
  32. data/lib/html2rss/mcp/inspect.rb +241 -0
  33. data/lib/html2rss/mcp/outcome.rb +188 -0
  34. data/lib/html2rss/mcp/server.rb +253 -409
  35. data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
  36. data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
  37. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  38. data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
  39. data/lib/html2rss/request_service/policy.rb +1 -1
  40. data/lib/html2rss/request_service/response.rb +57 -6
  41. data/lib/html2rss/request_service.rb +6 -1
  42. data/lib/html2rss/selectors.rb +2 -1
  43. data/lib/html2rss/status.rb +27 -11
  44. data/lib/html2rss/url.rb +20 -0
  45. data/lib/html2rss/version.rb +1 -1
  46. data/lib/html2rss.rb +30 -6
  47. data/schema/html2rss-config.schema.json +26 -15
  48. metadata +15 -4
@@ -2,20 +2,28 @@
2
2
 
3
3
  module Html2rss
4
4
  ##
5
- # Analyzes a URL and produces a reusable feed config hash with derived CSS selectors.
5
+ # Analyzes a URL and produces a durable feed config: items selector + +enhance: true+.
6
6
  #
7
- # Uses the auto-source pipeline to extract articles, then traces back through
8
- # SST segments to build items + attribute selectors suitable for a static feed config.
9
- class Capture # rubocop:disable Metrics/ClassLength
7
+ # Fetches via {FeedPipeline} (including AutoFallback for +:auto+), extracts articles,
8
+ # then derives a reusable items CSS selector from SST segments (list cluster → semantic).
9
+ #
10
+ # {include:file:lib/html2rss/capture/README.md}
11
+ class Capture # rubocop:disable Metrics/ClassLength -- segment strategies + CSS trim stay co-located
10
12
  LEADING_TRIM_TAGS = %w[html body].freeze
11
13
  private_constant :LEADING_TRIM_TAGS
12
14
 
15
+ # Ordered Segmenter strategies tried until the items selector quality gate passes.
16
+ SEGMENT_STRATEGIES = %i[list cluster semantic].freeze
17
+
18
+ # Minimum matched segment/article pairs required to emit an items selector.
19
+ MIN_SELECTOR_MATCHES = 2
20
+
13
21
  ##
14
- # Result of a capture operation.
15
- # @!attribute config [Hash] feed config hash with +:channel+ and +:selectors+
16
- # @!attribute articles_count [Integer] number of articles extracted
17
- # @!attribute channel_title [String] derived channel title
18
- CaptureResult = Data.define(:config, :articles_count, :channel_title)
22
+ # Result of a capture operation (config plus quality meta).
23
+ CaptureResult = Data.define(
24
+ :config, :articles_count, :channel_title, :has_selectors, :segment_strategy,
25
+ :admission_drops, :selected_strategy
26
+ )
19
27
 
20
28
  class << self
21
29
  ##
@@ -57,31 +65,47 @@ module Html2rss
57
65
  # Runs the capture pipeline.
58
66
  #
59
67
  # @return [CaptureResult]
60
- def build # rubocop:disable Metrics/MethodLength
61
- response = fetch_response
62
- articles = extract_articles(response)
63
- selectors = derive_selectors(response, articles)
68
+ def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- outcome + selector + result assembly
69
+ outcome = FeedPipeline.new(raw_config).to_outcome
70
+ selectors, segment_strategy = derive_selectors(outcome.response, outcome.articles)
64
71
 
65
72
  config = {
66
- channel: build_channel(response),
73
+ channel: build_channel(outcome.response),
67
74
  selectors: selectors.empty? ? nil : selectors,
75
+ **strategy_stamp(outcome),
68
76
  **local_file_request_overlay
69
77
  }.compact
70
78
 
71
79
  CaptureResult.new(
72
80
  config:,
73
- articles_count: articles.size,
74
- channel_title: channel_title_from(response)
81
+ articles_count: outcome.articles.size,
82
+ channel_title: channel_title_from(outcome.response),
83
+ has_selectors: !selectors.empty?,
84
+ segment_strategy:,
85
+ admission_drops: outcome.admission_drops,
86
+ selected_strategy: outcome.selected_strategy
75
87
  )
76
88
  end
77
89
 
78
90
  private
79
91
 
92
+ def strategy_stamp(outcome)
93
+ concrete = outcome.selected_strategy || concrete_request_strategy
94
+ return {} if concrete.nil? || concrete == :auto
95
+
96
+ { strategy: concrete }
97
+ end
98
+
99
+ def concrete_request_strategy
100
+ plan = FeedPipeline::StrategyPlan.resolve(@strategy)
101
+ plan.is_a?(FeedPipeline::StrategyPlan::Concrete) ? plan.strategy : nil
102
+ end
103
+
80
104
  def raw_config
81
105
  @raw_config ||= build_raw_config
82
106
  end
83
107
 
84
- def build_raw_config # rubocop:disable Metrics/MethodLength
108
+ def build_raw_config
85
109
  Config.auto_source_config(
86
110
  url: @url,
87
111
  items_selector: @items_selector_hint,
@@ -91,10 +115,7 @@ module Html2rss
91
115
  max_requests: @max_requests
92
116
  ),
93
117
  limit: @limit
94
- ).tap do |config|
95
- config[:strategy] = resolve_strategy(config[:strategy] || @strategy)
96
- apply_local_file_path!(config)
97
- end
118
+ ).tap { |config| apply_local_file_path!(config) }
98
119
  end
99
120
 
100
121
  def apply_local_file_path!(config)
@@ -111,62 +132,39 @@ module Html2rss
111
132
  { strategy: :local_file, request: { local_file_path: @local_file_path } }
112
133
  end
113
134
 
114
- def resolve_strategy(strategy)
115
- plan = FeedPipeline::StrategyPlan.resolve(strategy)
116
- plan.is_a?(FeedPipeline::StrategyPlan::Auto) ? :faraday : plan.strategy
117
- end
118
-
119
- def fetch_response
120
- config = Config.from_hash(raw_config)
121
- resources = FeedPipeline::RuntimePolicy.resources_for(config)
122
- session = RequestSession.build(
123
- config:,
124
- strategy: config.strategy,
125
- budget: resources.budget,
126
- policy: resources.policy
127
- )
128
- session.fetch_initial_response
129
- end
130
-
131
- def extract_articles(response)
132
- auto_source_opts = raw_config[:auto_source] || AutoSource::DEFAULT_CONFIG
133
- AutoSource.new(response, auto_source_opts).articles
134
- rescue AutoSource::Scraper::NoScraperFound
135
- []
136
- end
137
-
138
- def derive_selectors(response, articles) # rubocop:disable Metrics/MethodLength
139
- return {} if articles.empty? || !response.html_response?
140
-
141
- sst = normalize_sst(response)
142
- return {} unless sst
135
+ # @return [Array(Hash, Symbol, nil)] selectors hash and winning segment strategy
136
+ def derive_selectors(response, articles)
137
+ return hint_selectors if @items_selector_hint
138
+ return [{}, nil] if articles.empty? || !response.html_response?
143
139
 
144
- segments = discover_segments(sst)
145
- return {} if segments.empty?
140
+ sst = SST::Normalizer.call(response.body)
141
+ return [{}, nil] unless sst
146
142
 
147
- matched = match_segments_to_articles(segments, articles)
148
- return {} if matched.empty?
149
-
150
- build_selector_hash(matched, sst)
143
+ select_enhance_selectors(sst, articles)
151
144
  rescue ArgumentError => error
152
145
  Log.warn("Capture selector derivation failed: #{error.message}")
153
- {}
146
+ [{}, nil]
154
147
  end
155
148
 
156
- def normalize_sst(response)
157
- SST::Normalizer.call(response.body)
149
+ def hint_selectors
150
+ [{ items: { selector: @items_selector_hint, enhance: true } }, :hint]
158
151
  end
159
152
 
160
- def discover_segments(sst)
153
+ def select_enhance_selectors(sst, articles) # rubocop:disable Metrics/MethodLength -- strategy loop + gate
161
154
  link_resolver = Scoring::LinkResolver.new(@url)
162
155
 
163
- AutoSource::Segmenter.call(
164
- sst,
165
- base_url: @url,
166
- strategy: :list,
167
- permit_unanchored: false,
168
- link_resolver:
169
- )
156
+ SEGMENT_STRATEGIES.each do |strategy|
157
+ segments = AutoSource::Segmenter.call(
158
+ sst, base_url: @url, strategy:, permit_unanchored: false, link_resolver:
159
+ )
160
+ matched = match_segments_to_articles(segments, articles)
161
+ items_sel = items_selector(matched)
162
+ next unless items_sel && matched.size >= MIN_SELECTOR_MATCHES
163
+
164
+ return [{ items: { selector: items_sel, enhance: true } }, strategy]
165
+ end
166
+
167
+ [{}, nil]
170
168
  end
171
169
 
172
170
  def match_segments_to_articles(segments, articles)
@@ -201,24 +199,10 @@ module Html2rss
201
199
  segment.root_node.visible_text.to_s.strip
202
200
  end
203
201
 
204
- def build_selector_hash(matched, _sst)
205
- items_sel = items_selector(matched)
206
- return {} unless items_sel
207
-
208
- first = matched.first
209
- root = first[:segment].root_node
210
-
211
- attrs = {}.tap do |a|
212
- a[:title] = title_selector(root)
213
- a[:url] = url_selector(first[:segment])
214
- a[:description] = description_selector(root, first[:segment])
215
- end.compact
216
-
217
- { items: { selector: items_sel }, **attrs }
218
- end
219
-
220
202
  def items_selector(matched)
221
- roots = matched.map { |m| m[:segment].root_node }
203
+ return nil if matched.empty?
204
+
205
+ roots = lift_heading_link_roots(matched.map { |m| m[:segment].root_node })
222
206
  shared = shared_class_items_selector(roots)
223
207
  return shared if shared
224
208
 
@@ -230,6 +214,58 @@ module Html2rss
230
214
  css_from_trimmed_tag_path(tag_path)
231
215
  end
232
216
 
217
+ def lift_heading_link_roots(roots)
218
+ roots.map { |root| lift_heading_link_root(root, roots) }
219
+ end
220
+
221
+ def lift_heading_link_root(root, all_roots)
222
+ return root unless heading_or_inner_title_link?(root)
223
+
224
+ index = SST::Index.for_node(root)
225
+ return root unless index
226
+
227
+ walk_usable_card(root, index, all_roots)
228
+ end
229
+
230
+ def walk_usable_card(root, index, all_roots)
231
+ candidate = root
232
+ parent = index.parent_of(root)
233
+ while parent && Html::Navigator.usable_card_parent?(parent)
234
+ break if contains_other_root?(parent, root, all_roots)
235
+
236
+ candidate = parent
237
+ parent = index.parent_of(parent)
238
+ end
239
+ candidate
240
+ end
241
+
242
+ def heading_or_inner_title_link?(node)
243
+ name = node.name.to_s
244
+ return false if wrapping_anchor_root?(node)
245
+
246
+ Html::Navigator::HEADING_TAGS.include?(name) || name == 'a'
247
+ end
248
+
249
+ def wrapping_anchor_root?(node)
250
+ return false unless node.name.to_s == 'a'
251
+ return false unless node.respond_to?(:find)
252
+
253
+ tags = Html::Navigator::WRAPPING_ANCHOR_CHILD_TAGS
254
+ node.find { |child| !child.equal?(node) && tags.include?(child.name.to_s) }
255
+ end
256
+
257
+ def contains_other_root?(parent, root, all_roots)
258
+ all_roots.any? do |other|
259
+ next if other.equal?(root)
260
+
261
+ other.equal?(parent) || sst_descendant?(other, parent)
262
+ end
263
+ end
264
+
265
+ def sst_descendant?(child, ancestor)
266
+ SST::Index.for_node(child)&.descendant_of?(child, ancestor)
267
+ end
268
+
233
269
  def shared_class_items_selector(roots)
234
270
  shared = roots.map { |root| root.attrs.class_names }.reduce { |left, right| left & right }
235
271
  return nil if shared.nil? || shared.empty?
@@ -265,39 +301,6 @@ module Html2rss
265
301
  "/#{prefix.join('/')}"
266
302
  end
267
303
 
268
- def title_selector(root) # rubocop:disable Metrics/CyclomaticComplexity
269
- heading = root.find(&:heading?)
270
- relative = css_for_path(heading.tag_path, root.tag_path) if heading
271
- relative ||= begin
272
- link = root.find { |n| n.link? && n.visible_text.to_s.strip.length > 3 }
273
- css_for_path(link.tag_path, root.tag_path) if link
274
- end
275
- return nil unless relative
276
-
277
- { selector: relative }
278
- end
279
-
280
- def url_selector(segment)
281
- return nil unless segment.primary_link
282
-
283
- relative = css_for_path(segment.primary_link.tag_path, segment.root_node.tag_path)
284
- { selector: relative, extractor: 'href' }
285
- end
286
-
287
- def description_selector(root, segment)
288
- relative = css_for_path(root.tag_path, segment.root_node.tag_path)
289
- return nil if relative.nil? || relative.empty? || relative == '.'
290
-
291
- { selector: relative }
292
- end
293
-
294
- def css_for_path(full_path, root_path)
295
- relative = full_path.delete_prefix(root_path)
296
- return '.' if relative.empty?
297
-
298
- relative.split('/').reject(&:empty?).join(' > ')
299
- end
300
-
301
304
  def build_channel(response)
302
305
  {
303
306
  url: @url,
data/lib/html2rss/cli.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'json'
5
- require 'yaml'
6
5
  require 'thor'
7
6
 
8
7
  module Html2rss
@@ -10,10 +9,6 @@ module Html2rss
10
9
  # The Html2rss command line interface.
11
10
  class CLI < Thor # rubocop:disable Metrics/ClassLength
12
11
  check_unknown_options!
13
- # Ordered fallback chain attempted by the feed-level :auto plan.
14
- #
15
- # @return [Array<Symbol>]
16
- AUTO_FALLBACK_CHAIN = Html2rss::FeedPipeline::AutoFallback::CHAIN.freeze
17
12
  # Supported CLI strategy plan option values (:auto plus concrete strategies).
18
13
  #
19
14
  # @return [Array<String>]
@@ -24,7 +19,7 @@ module Html2rss
24
19
  # @return [String]
25
20
  STRATEGY_OPTION_DESC = [
26
21
  'Optional request strategy (defaults to auto; auto tries',
27
- "#{AUTO_FALLBACK_CHAIN.join(' -> ')})"
22
+ "#{Html2rss::FeedPipeline::AutoFallback::CHAIN.join(' -> ')})"
28
23
  ].join(' ').freeze
29
24
 
30
25
  # @return [Boolean] whether Thor should terminate process on command failures
@@ -86,17 +81,19 @@ module Html2rss
86
81
  method_option :input,
87
82
  type: :string,
88
83
  desc: 'Local HTML file path to read input from'
84
+ method_option :explain,
85
+ type: :boolean,
86
+ desc: 'Print Status JSON to stderr (stdout stays the feed)',
87
+ default: false
89
88
  # @param url [String, nil] source page URL for auto discovery
90
89
  # @return [void]
91
90
  def auto(url = nil)
92
91
  format = options.fetch(:format, 'rss')
93
92
  strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
93
+ feed_result = execute_feed { auto_feed_result_for(url, strategy, local_file_path) }
94
94
 
95
- result = execute_feed do
96
- source_call(url, strategy, local_file_path, format == 'jsonfeed')
97
- end
98
-
99
- puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
95
+ explain_status!(feed_result.status) if options[:explain]
96
+ puts(format == 'jsonfeed' ? JSON.pretty_generate(feed_result.to_json_feed) : feed_result.to_rss)
100
97
  end
101
98
 
102
99
  desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
@@ -117,14 +114,18 @@ module Html2rss
117
114
  method_option :input,
118
115
  type: :string,
119
116
  desc: 'Local HTML file path to read input from'
117
+ method_option :explain,
118
+ type: :boolean,
119
+ desc: 'Print capture quality JSON to stderr (stdout stays YAML)',
120
+ default: false
120
121
  ##
121
122
  # Captures a URL and prints a reusable YAML config.
122
123
  #
123
124
  # @param url [String, nil] source page URL for capture
124
125
  # @return [void]
125
- def capture(url = nil) # rubocop:disable Metrics/MethodLength
126
+ def capture(url = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- CLI option wiring
126
127
  strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
127
- config = Html2rss.capture(
128
+ result = Html2rss::Capture.build(
128
129
  url,
129
130
  strategy:,
130
131
  items_selector: options[:items_selector],
@@ -133,7 +134,8 @@ module Html2rss
133
134
  max_requests: options[:max_requests],
134
135
  local_file_path:
135
136
  )
136
- puts YAML.dump(HashUtil.deep_stringify_keys(config))
137
+ explain_capture!(result) if options[:explain]
138
+ puts Config.to_yaml(result.config)
137
139
  end
138
140
 
139
141
  desc 'schema', 'Print the exported config JSON Schema'
@@ -294,9 +296,8 @@ module Html2rss
294
296
  raise Thor::Error, error.message
295
297
  end
296
298
 
297
- def source_call(url, strategy, local_file_path, is_json)
298
- method = is_json ? Html2rss.method(:auto_json_feed) : Html2rss.method(:auto_source)
299
- method.call(
299
+ def auto_feed_result_for(url, strategy, local_file_path)
300
+ Html2rss.auto_feed_result(
300
301
  url,
301
302
  strategy:,
302
303
  items_selector: options[:items_selector],
@@ -307,6 +308,23 @@ module Html2rss
307
308
  )
308
309
  end
309
310
 
311
+ def explain_status!(status)
312
+ $stderr.puts JSON.pretty_generate(status.to_h) # rubocop:disable Style/StderrPuts -- CLI explain contract
313
+ end
314
+
315
+ def explain_capture!(result)
316
+ $stderr.puts JSON.pretty_generate( # rubocop:disable Style/StderrPuts -- CLI explain contract
317
+ {
318
+ articles_count: result.articles_count,
319
+ channel_title: result.channel_title,
320
+ has_selectors: result.has_selectors,
321
+ segment_strategy: result.segment_strategy,
322
+ selected_strategy: result.selected_strategy,
323
+ admission_drops: result.admission_drops
324
+ }.compact
325
+ )
326
+ end
327
+
310
328
  def check_file_exists!(path)
311
329
  File.expand_path(path).tap do |file_path|
312
330
  raise Thor::Error, "Input file does not exist: #{path}" unless File.exist?(file_path)
@@ -82,6 +82,18 @@ module Html2rss
82
82
 
83
83
  topics = schema.dig(:properties, :directory, :properties, :topics)
84
84
  topics[:minItems] = 1 if topics.is_a?(Hash)
85
+ apply_botasaurus_schema!(schema)
86
+ end
87
+
88
+ def apply_botasaurus_schema!(schema)
89
+ request = schema.dig(:properties, :request, :properties)
90
+ return unless request.is_a?(Hash)
91
+
92
+ exported = Html2rss::Config::Validator::BotasaurusRequestExport
93
+ .new.schema.json_schema(loose: true).except(:$schema)
94
+ exported[:additionalProperties] = false
95
+ exported.fetch(:properties).fetch(:window_size)[:additionalProperties] = false
96
+ request[:botasaurus] = exported
85
97
  end
86
98
 
87
99
  # @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
@@ -45,17 +45,20 @@ module Html2rss
45
45
  optional(:media).maybe(:string)
46
46
  end
47
47
 
48
- # Contract for Botasaurus-specific request options.
48
+ # Contract for Botasaurus-specific request options (OpenAPI ScrapeRequest minus url).
49
49
  BotasaurusRequestConfig = Dry::Schema.Params do
50
50
  config.validate_keys = true
51
51
 
52
- optional(:execution_mode).filled(:string, included_in?: %w[auto request browser])
53
- optional(:navigation_mode).filled(:string, included_in?: %w[auto get google_get google_get_bypass organic_get])
54
- optional(:max_retries).filled(:integer, gteq?: 0, lteq?: 3)
52
+ botasaurus = Html2rss::RequestService::BotasaurusContract
53
+
54
+ optional(:execution_mode).filled(:string, included_in?: botasaurus::EXECUTION_MODES)
55
+ optional(:navigation_mode).filled(:string, included_in?: botasaurus::NAVIGATION_MODES)
56
+ optional(:max_retries).filled(:integer, gteq?: 0, lteq?: botasaurus::MAX_RETRIES)
55
57
  optional(:wait_for_selector).maybe(:string)
56
- optional(:wait_timeout_seconds).filled(:integer, gt?: 0)
58
+ optional(:wait_timeout_seconds).filled(
59
+ :integer, gteq?: botasaurus::MIN_WAIT_TIMEOUT_SECONDS, lteq?: botasaurus::MAX_WAIT_TIMEOUT_SECONDS
60
+ )
57
61
  optional(:scroll).filled(:bool)
58
- optional(:scroll_to_bottom).filled(:bool)
59
62
  optional(:block_images).filled(:bool)
60
63
  optional(:block_images_and_css).filled(:bool)
61
64
  optional(:block_trackers).filled(:bool)
@@ -63,18 +66,27 @@ module Html2rss
63
66
  optional(:headless).filled(:bool)
64
67
  optional(:proxy).filled(:string)
65
68
  optional(:user_agent).filled(:string)
66
- optional(:window_size).value(:array, min_size?: 2, max_size?: 2).each(:integer, gt?: 0)
69
+ optional(:window_size).hash do
70
+ botasaurus::WINDOW_SIZE_PROPERTIES.each do |key|
71
+ required(key).filled(:integer, gt?: 0)
72
+ end
73
+ end
67
74
  optional(:lang).filled(:string)
68
75
  optional(:cookies).hash
69
76
  optional(:headers).hash
70
77
  end
71
78
 
79
+ # JSON Schema export adapter for +BotasaurusRequestConfig+ (params schemas have no json_schema).
80
+ class BotasaurusRequestExport < Dry::Validation::Contract
81
+ params(BotasaurusRequestConfig)
82
+ end
83
+
72
84
  # Contract for the top-level `request` section.
73
85
  RequestConfig = Dry::Schema.Params do
74
86
  optional(:max_redirects).filled(:integer, gteq?: 0)
75
87
  optional(:max_requests).filled(:integer, gt?: 0)
76
88
  optional(:total_timeout_seconds).filled(:integer, gt?: 0)
77
- optional(:botasaurus).hash(BotasaurusRequestConfig)
89
+ optional(:botasaurus).hash
78
90
  optional(:local_file_path).filled(:string)
79
91
  end
80
92
 
@@ -123,6 +135,17 @@ module Html2rss
123
135
  errors.each { |error| key(:selectors).failure(error.text) } unless errors.empty?
124
136
  end
125
137
 
138
+ rule(request: :botasaurus) do
139
+ next unless value
140
+
141
+ result = BotasaurusRequestConfig.call(value)
142
+ next if result.success?
143
+
144
+ result.errors.each do |error|
145
+ key([:request, :botasaurus, *error.path]).failure(error.text)
146
+ end
147
+ end
148
+
126
149
  # URL validation delegated to Url class
127
150
  rule(:channel) do
128
151
  if (url_string = values.dig(:channel, :url)) && !url_string.empty?
@@ -71,6 +71,34 @@ module Html2rss
71
71
  validate(load_yaml(file, feed_name, multiple_feeds_key:), params:)
72
72
  end
73
73
 
74
+ ##
75
+ # Serializes a configuration hash to string-key YAML.
76
+ #
77
+ # This is the single serializer for CLI capture and MCP +capture_config+.
78
+ #
79
+ # @param hash [Hash] configuration hash (symbol or string keys)
80
+ # @return [String] YAML document without Ruby symbol-key prefixes
81
+ def to_yaml(hash)
82
+ YAML.dump(HashUtil.deep_stringify_keys(hash))
83
+ end
84
+
85
+ ##
86
+ # Parses a YAML configuration string into a symbol-keyed hash.
87
+ #
88
+ # Does not validate. Call {validate} or {from_hash} after this.
89
+ #
90
+ # @param string [String] YAML document
91
+ # @return [Hash{Symbol => Object}] configuration hash
92
+ # @raise [ArgumentError] if +string+ is not a String or does not deserialize to a Hash
93
+ def from_yaml(string)
94
+ raise ArgumentError, 'YAML must be a String' unless string.is_a?(String)
95
+
96
+ parsed = YAML.safe_load(string)
97
+ raise ArgumentError, 'YAML must deserialize to a Hash' unless parsed.is_a?(Hash)
98
+
99
+ HashUtil.deep_symbolize_keys(parsed, context: 'config')
100
+ end
101
+
74
102
  ##
75
103
  # Loads the feed configuration from a YAML file.
76
104
  #
@@ -7,7 +7,7 @@ module Html2rss
7
7
  # Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
8
8
  class NoFeedItemsExtracted < Error
9
9
  # Categories that append shared surface guidance to the empty-feed message.
10
- SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface].freeze
10
+ SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface high_entropy_surface].freeze
11
11
 
12
12
  ##
13
13
  # @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
@@ -27,17 +27,35 @@ module Html2rss
27
27
  private
28
28
 
29
29
  def build_message
30
+ [base_message, surface_guidance, botasaurus_guidance].compact.join(' ')
31
+ end
32
+
33
+ def base_message
30
34
  summaries = attempts.map do |attempt|
31
35
  details = attempt[:items_count].nil? ? "#{attempt[:error_class]} error" : "#{attempt[:items_count]} items"
32
36
  "#{attempt[:strategy]} (#{details})"
33
37
  end.join(', ')
34
38
 
35
- message = "No feed items extracted after auto fallback across strategies: #{summaries}. " \
36
- 'Try a more specific listing URL or provide explicit selectors.'
37
- return message unless SURFACE_HINT_CATEGORIES.include?(surface_category)
39
+ "No feed items extracted after auto fallback across strategies: #{summaries}. " \
40
+ 'Try a more specific listing URL or provide explicit selectors.'
41
+ end
42
+
43
+ def surface_guidance
44
+ return unless SURFACE_HINT_CATEGORIES.include?(surface_category)
45
+
46
+ AutoSource::Scraper::NoScraperFound::CATEGORY_MESSAGES.fetch(surface_category)
47
+ end
48
+
49
+ def botasaurus_guidance
50
+ return unless botasaurus_configuration_error_attempt?
51
+ return if surface_guidance&.include?('BOTASAURUS_SCRAPER_URL')
52
+
53
+ RequestService::BotasaurusConfigurationError::EMPTY_FEED_HINT
54
+ end
38
55
 
39
- guidance = AutoSource::Scraper::NoScraperFound::CATEGORY_MESSAGES.fetch(surface_category)
40
- "#{message} #{guidance}"
56
+ def botasaurus_configuration_error_attempt?
57
+ error_name = RequestService::BotasaurusConfigurationError.name
58
+ attempts.any? { |attempt| attempt[:error_class] == error_name }
41
59
  end
42
60
  end
43
61
  end
@@ -0,0 +1,42 @@
1
+ # FeedPipeline — `auto` request strategy
2
+
3
+ `:auto` is the default request plan for feed builds (`auto_source`, `auto_json_feed`, Capture, and MCP `scrape_url` / `capture_config`). `FeedPipeline::StrategyPlan` resolves it; `FeedPipeline::AutoFallback` executes `AutoFallback::CHAIN`.
4
+
5
+ Use `:auto` when you want Faraday first and a browser-backed hop only if that fetch fails or yields zero items. Pin a concrete strategy (`faraday`, `botasaurus`, `local_file`) when you need a single transport.
6
+
7
+ ## Chain
8
+
9
+ `AutoFallback::CHAIN` is:
10
+
11
+ 1. **Faraday** — plain HTTP (faster, cheaper).
12
+ 2. **Botasaurus** — attempted when Faraday raises a fallback-eligible error (for example `BlockedSurfaceDetected` or timeout) or extracts zero feed items.
13
+
14
+ There is no Browserless / Puppeteer-in-gem tier. Pin `botasaurus` when you want browser rendering without Faraday first. Botasaurus needs `BOTASAURUS_SCRAPER_URL`.
15
+
16
+ ## Surfaces
17
+
18
+ | Surface | `:auto` behavior |
19
+ |---------|------------------|
20
+ | Gem / CLI feed build, MCP `scrape_url`, Capture | Full AutoFallback chain (`faraday` → `botasaurus`) |
21
+ | MCP `inspect_url` | Cheap diagnostic: `StrategyPlan.concrete_for_diagnostic` maps `auto` to Faraday (pin `botasaurus` when you need browser rendering) |
22
+
23
+ ## Fallback vs abort
24
+
25
+ These typically hop to the next chain member (among other `StandardError`s `AutoFallback` rescues):
26
+
27
+ - `Html2rss::RequestService::BlockedSurfaceDetected`
28
+ - `Html2rss::RequestService::RequestTimedOut`
29
+ - Faraday connection / timeout errors
30
+ - Empty extraction results when a later chain member may succeed
31
+
32
+ These abort immediately (`AutoFallback::NON_FALLBACK_ERRORS`): unknown strategy, invalid URL, unsupported scheme, budget exceeded, private network denied, cross-origin follow-up denied, response too large.
33
+
34
+ Retries share the feed's request/session policy. Pin a concrete strategy when you need a single-hop budget profile.
35
+
36
+ ## Success signals
37
+
38
+ - `Html2rss::RequestService::Response` includes transport metadata for the strategy that produced it.
39
+ - `Html2rss::Status` records selected strategy and attempt tallies for MCP envelope payload / CLI `--explain`.
40
+ - Fallback hops log at info/warn (`AutoFallback`).
41
+
42
+ See also {Html2rss::AutoSource} for article scraping (a different pipeline) and {Html2rss::Capture} for durable configs that stamp the selected strategy.