html2rss 0.25.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Analyzes a URL and produces a durable feed config: items selector + +enhance: true+.
6
+ #
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
12
+ LEADING_TRIM_TAGS = %w[html body].freeze
13
+ private_constant :LEADING_TRIM_TAGS
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
+
21
+ ##
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
+ )
27
+
28
+ class << self
29
+ ##
30
+ # Analyzes a URL and builds a reusable feed config.
31
+ #
32
+ # @param url [String] source page URL
33
+ # @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
34
+ # @option options [String, nil] :items_selector optional selector hint
35
+ # @option options [Integer, nil] :max_redirects optional redirect limit override
36
+ # @option options [Integer, nil] :max_requests optional request budget override
37
+ # @option options [Integer, nil] :limit max articles to keep
38
+ # @option options [String, nil] :local_file_path optional local HTML file path
39
+ # @return [CaptureResult]
40
+ def build(url, strategy: :auto, **)
41
+ new(url, strategy:, **).build
42
+ end
43
+ end
44
+
45
+ ##
46
+ # @param url [String] source page URL
47
+ # @param strategy [Symbol] request strategy
48
+ # @param options [Hash] additional options
49
+ # @option options [String, nil] :items_selector optional selector hint
50
+ # @option options [Integer, nil] :max_redirects optional redirect limit override
51
+ # @option options [Integer, nil] :max_requests optional request budget override
52
+ # @option options [Integer, nil] :limit max articles to keep
53
+ # @option options [String, nil] :local_file_path optional local HTML file path
54
+ def initialize(url, strategy: :auto, **options)
55
+ @url = url
56
+ @strategy = strategy
57
+ @items_selector_hint = options.delete(:items_selector)
58
+ @max_redirects = options.delete(:max_redirects)
59
+ @max_requests = options.delete(:max_requests)
60
+ @limit = options.delete(:limit)
61
+ @local_file_path = options.delete(:local_file_path)
62
+ end
63
+
64
+ ##
65
+ # Runs the capture pipeline.
66
+ #
67
+ # @return [CaptureResult]
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)
71
+
72
+ config = {
73
+ channel: build_channel(outcome.response),
74
+ selectors: selectors.empty? ? nil : selectors,
75
+ **strategy_stamp(outcome),
76
+ **local_file_request_overlay
77
+ }.compact
78
+
79
+ CaptureResult.new(
80
+ config:,
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
87
+ )
88
+ end
89
+
90
+ private
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
+
104
+ def raw_config
105
+ @raw_config ||= build_raw_config
106
+ end
107
+
108
+ def build_raw_config
109
+ Config.auto_source_config(
110
+ url: @url,
111
+ items_selector: @items_selector_hint,
112
+ request_controls: Config::RequestControls.from_shortcut(
113
+ strategy: @strategy,
114
+ max_redirects: @max_redirects,
115
+ max_requests: @max_requests
116
+ ),
117
+ limit: @limit
118
+ ).tap { |config| apply_local_file_path!(config) }
119
+ end
120
+
121
+ def apply_local_file_path!(config)
122
+ return unless @local_file_path
123
+
124
+ config[:strategy] = :local_file
125
+ config[:request] ||= {}
126
+ config[:request][:local_file_path] = @local_file_path
127
+ end
128
+
129
+ def local_file_request_overlay
130
+ return {} unless @local_file_path
131
+
132
+ { strategy: :local_file, request: { local_file_path: @local_file_path } }
133
+ end
134
+
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?
139
+
140
+ sst = SST::Normalizer.call(response.body)
141
+ return [{}, nil] unless sst
142
+
143
+ select_enhance_selectors(sst, articles)
144
+ rescue ArgumentError => error
145
+ Log.warn("Capture selector derivation failed: #{error.message}")
146
+ [{}, nil]
147
+ end
148
+
149
+ def hint_selectors
150
+ [{ items: { selector: @items_selector_hint, enhance: true } }, :hint]
151
+ end
152
+
153
+ def select_enhance_selectors(sst, articles) # rubocop:disable Metrics/MethodLength -- strategy loop + gate
154
+ link_resolver = Scoring::LinkResolver.new(@url)
155
+
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]
168
+ end
169
+
170
+ def match_segments_to_articles(segments, articles)
171
+ articles_by_url = articles.each_with_object({}) do |article, hash|
172
+ url_str = article.url.to_s
173
+ hash[url_str] = article unless url_str.empty?
174
+ end
175
+
176
+ segments.filter_map do |segment|
177
+ next unless segment.primary_link
178
+
179
+ article = find_matching_article(segment, articles_by_url)
180
+ { segment:, article: } if article
181
+ end
182
+ end
183
+
184
+ def find_matching_article(segment, articles_by_url) # rubocop:disable Metrics/AbcSize
185
+ href = segment.primary_link.attrs.href.to_s
186
+ return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
187
+
188
+ resolved = Url.from_relative(href, @url)
189
+ articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
190
+ end
191
+
192
+ def fuzzy_url_match?(article_url, resolved)
193
+ article_url.to_s.end_with?(resolved.path.to_s)
194
+ rescue StandardError
195
+ false
196
+ end
197
+
198
+ def title_from_segment(segment)
199
+ segment.root_node.visible_text.to_s.strip
200
+ end
201
+
202
+ def items_selector(matched)
203
+ return nil if matched.empty?
204
+
205
+ roots = lift_heading_link_roots(matched.map { |m| m[:segment].root_node })
206
+ shared = shared_class_items_selector(roots)
207
+ return shared if shared
208
+
209
+ paths = roots.map(&:tag_path)
210
+ common = common_path_prefix(paths)
211
+ tag_path = common.empty? ? paths.first.to_s : common
212
+ return nil if tag_path.empty?
213
+
214
+ css_from_trimmed_tag_path(tag_path)
215
+ end
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
+
269
+ def shared_class_items_selector(roots)
270
+ shared = roots.map { |root| root.attrs.class_names }.reduce { |left, right| left & right }
271
+ return nil if shared.nil? || shared.empty?
272
+
273
+ "#{roots.first.name}.#{shared.min}"
274
+ end
275
+
276
+ def css_from_trimmed_tag_path(tag_path)
277
+ segments = tag_path.split('/').reject(&:empty?)
278
+ trimmed = trim_leading_items_segments(segments)
279
+ trimmed.join(' > ')
280
+ end
281
+
282
+ def trim_leading_items_segments(segments)
283
+ return segments if segments.empty?
284
+
285
+ trimmed = segments.dup
286
+ trimmed.shift while trimmed.any? && LEADING_TRIM_TAGS.include?(trimmed.first)
287
+ trimmed.shift while trimmed.length > 1 && trimmed.first == 'div'
288
+ trimmed.empty? ? [segments.last] : trimmed
289
+ end
290
+
291
+ def common_path_prefix(paths) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
292
+ return '' if paths.empty?
293
+
294
+ segments = paths.map { |p| p.split('/').reject(&:empty?) }
295
+ prefix = segments.first
296
+ segments.each do |seg|
297
+ i = 0
298
+ i += 1 while i < prefix.length && i < seg.length && prefix[i] == seg[i]
299
+ prefix = prefix[0...i]
300
+ end
301
+ "/#{prefix.join('/')}"
302
+ end
303
+
304
+ def build_channel(response)
305
+ {
306
+ url: @url,
307
+ title: channel_title_from(response),
308
+ time_zone: 'UTC'
309
+ }.compact
310
+ end
311
+
312
+ def channel_title_from(response)
313
+ Channel.from_response(response).title
314
+ rescue StandardError
315
+ nil
316
+ end
317
+ end
318
+ end
data/lib/html2rss/cli.rb CHANGED
@@ -9,10 +9,6 @@ module Html2rss
9
9
  # The Html2rss command line interface.
10
10
  class CLI < Thor # rubocop:disable Metrics/ClassLength
11
11
  check_unknown_options!
12
- # Ordered fallback chain attempted by the feed-level :auto plan.
13
- #
14
- # @return [Array<Symbol>]
15
- AUTO_FALLBACK_CHAIN = Html2rss::FeedPipeline::AutoFallback::CHAIN.freeze
16
12
  # Supported CLI strategy plan option values (:auto plus concrete strategies).
17
13
  #
18
14
  # @return [Array<String>]
@@ -23,7 +19,7 @@ module Html2rss
23
19
  # @return [String]
24
20
  STRATEGY_OPTION_DESC = [
25
21
  'Optional request strategy (defaults to auto; auto tries',
26
- "#{AUTO_FALLBACK_CHAIN.join(' -> ')})"
22
+ "#{Html2rss::FeedPipeline::AutoFallback::CHAIN.join(' -> ')})"
27
23
  ].join(' ').freeze
28
24
 
29
25
  # @return [Boolean] whether Thor should terminate process on command failures
@@ -85,17 +81,61 @@ module Html2rss
85
81
  method_option :input,
86
82
  type: :string,
87
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
88
88
  # @param url [String, nil] source page URL for auto discovery
89
89
  # @return [void]
90
90
  def auto(url = nil)
91
91
  format = options.fetch(:format, 'rss')
92
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) }
93
94
 
94
- result = execute_feed do
95
- source_call(url, strategy, local_file_path, format == 'jsonfeed')
96
- end
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)
97
+ end
97
98
 
98
- puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
99
+ desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
100
+ method_option :strategy,
101
+ type: :string,
102
+ desc: STRATEGY_OPTION_DESC,
103
+ enum: STRATEGY_OPTION_ENUM
104
+ method_option :items_selector, type: :string, desc: 'CSS selector hint for items (optional)'
105
+ method_option :max_redirects,
106
+ type: :numeric,
107
+ desc: 'Maximum redirects to follow per request'
108
+ method_option :max_requests,
109
+ type: :numeric,
110
+ desc: 'Maximum requests to allow for this feed build'
111
+ method_option :limit,
112
+ type: :numeric,
113
+ desc: 'Maximum number of articles to keep (default: 25)'
114
+ method_option :input,
115
+ type: :string,
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
121
+ ##
122
+ # Captures a URL and prints a reusable YAML config.
123
+ #
124
+ # @param url [String, nil] source page URL for capture
125
+ # @return [void]
126
+ def capture(url = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- CLI option wiring
127
+ strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
128
+ result = Html2rss::Capture.build(
129
+ url,
130
+ strategy:,
131
+ items_selector: options[:items_selector],
132
+ limit: options[:limit]&.to_i,
133
+ max_redirects: options[:max_redirects],
134
+ max_requests: options[:max_requests],
135
+ local_file_path:
136
+ )
137
+ explain_capture!(result) if options[:explain]
138
+ puts Config.to_yaml(result.config)
99
139
  end
100
140
 
101
141
  desc 'schema', 'Print the exported config JSON Schema'
@@ -123,6 +163,27 @@ module Html2rss
123
163
  puts schema_json
124
164
  end
125
165
 
166
+ desc 'mcp', 'Start the MCP server for AI client consumption'
167
+ method_option :transport,
168
+ type: :string,
169
+ desc: 'MCP transport protocol',
170
+ enum: %w[stdio http],
171
+ default: 'stdio'
172
+ method_option :port,
173
+ type: :numeric,
174
+ desc: 'Port for HTTP transport (binds 127.0.0.1)',
175
+ default: 8080
176
+ ##
177
+ # Starts the MCP server for AI client consumption.
178
+ #
179
+ # @return [void]
180
+ def mcp
181
+ Html2rss::MCP.start(
182
+ transport: options[:transport].to_sym,
183
+ port: options[:port]
184
+ )
185
+ end
186
+
126
187
  desc 'validate YAML_FILE [feed_name]', 'Validate a YAML config with the runtime validator'
127
188
  method_option :params,
128
189
  type: :hash,
@@ -183,7 +244,7 @@ module Html2rss
183
244
 
184
245
  file_path = check_file_exists!(input_option)
185
246
  detected_url = url || detect_base_url!(
186
- file_path, 'Please specify a URL: html2rss auto [URL] --input <file>'
247
+ file_path, 'Please specify a URL: html2rss <command> [URL] --input <file>'
187
248
  )
188
249
 
189
250
  [:local_file, file_path, detected_url]
@@ -221,23 +282,22 @@ module Html2rss
221
282
  yield
222
283
  rescue Faraday::FollowRedirects::RedirectLimitReached => error
223
284
  raise Thor::Error,
224
- "#{error.message}. retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
285
+ "#{error.message}. already retried the last redirect hop once; " \
286
+ "retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
225
287
  rescue Html2rss::RequestService::RequestBudgetExceeded => error
226
288
  raise Thor::Error,
227
289
  "#{error.message}. retry with --max-requests #{suggested_max_requests} " \
228
290
  'or increase request.max_requests in the config.'
229
- rescue Html2rss::RequestService::BrowserlessConfigurationError,
230
- Html2rss::RequestService::BrowserlessConnectionFailed,
231
- Html2rss::RequestService::BotasaurusConfigurationError,
291
+ rescue Html2rss::RequestService::BotasaurusConfigurationError,
232
292
  Html2rss::RequestService::BotasaurusConnectionFailed,
293
+ Html2rss::RequestService::BotasaurusServiceError,
233
294
  Html2rss::RequestService::BlockedSurfaceDetected,
234
295
  Html2rss::NoFeedItemsExtracted => error
235
296
  raise Thor::Error, error.message
236
297
  end
237
298
 
238
- def source_call(url, strategy, local_file_path, is_json)
239
- method = is_json ? Html2rss.method(:auto_json_feed) : Html2rss.method(:auto_source)
240
- method.call(
299
+ def auto_feed_result_for(url, strategy, local_file_path)
300
+ Html2rss.auto_feed_result(
241
301
  url,
242
302
  strategy:,
243
303
  items_selector: options[:items_selector],
@@ -248,6 +308,23 @@ module Html2rss
248
308
  )
249
309
  end
250
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
+
251
328
  def check_file_exists!(path)
252
329
  File.expand_path(path).tap do |file_path|
253
330
  raise Thor::Error, "Input file does not exist: #{path}" unless File.exist?(file_path)
@@ -29,6 +29,9 @@ module Html2rss
29
29
  optional(:json_state).hash do
30
30
  optional(:enabled).filled(:bool)
31
31
  end
32
+ optional(:xhr_articles).hash do
33
+ optional(:enabled).filled(:bool)
34
+ end
32
35
  optional(:meta_oembed).hash do
33
36
  optional(:enabled).filled(:bool)
34
37
  end
@@ -46,7 +49,6 @@ module Html2rss
46
49
 
47
50
  optional(:cleanup).hash do
48
51
  optional(:keep_different_domain).filled(:bool)
49
- optional(:min_words_title).filled(:integer, gt?: 0)
50
52
  end
51
53
  end
52
54
  end
@@ -48,6 +48,7 @@ module Html2rss
48
48
  ##
49
49
  # Orchestrates schema assembly from runtime validator contracts plus
50
50
  # client-facing overlays.
51
+ # rubocop:disable Metrics/ClassLength -- overlay assembly stays in one builder
51
52
  class Builder
52
53
  class << self
53
54
  # @return [Hash{String => Object}] fully assembled JSON schema hash
@@ -60,9 +61,10 @@ module Html2rss
60
61
  def call
61
62
  schema = validator_schema
62
63
  apply_top_level(schema)
64
+ schema[:$defs] = registry_catalog_defs
63
65
  schema.fetch(:properties).merge!(overlay)
64
66
  schema.fetch(:properties).delete(:dynamic_params_error)
65
- DeepStringifier.call(schema)
67
+ HashUtil.deep_stringify_keys(schema)
66
68
  end
67
69
 
68
70
  private
@@ -77,6 +79,35 @@ module Html2rss
77
79
  { 'required' => ['selectors'] },
78
80
  { 'required' => ['auto_source'] }
79
81
  ]
82
+
83
+ topics = schema.dig(:properties, :directory, :properties, :topics)
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
97
+ end
98
+
99
+ # @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
100
+ def registry_catalog_defs
101
+ {
102
+ post_processors: catalog_from_registry(Selectors::PostProcessors::NAME_TO_CLASS),
103
+ extractors: catalog_from_registry(Selectors::Extractors::NAME_TO_CLASS)
104
+ }
105
+ end
106
+
107
+ # @param registry [Hash{Symbol => Class}]
108
+ # @return [Hash{String => Hash}]
109
+ def catalog_from_registry(registry)
110
+ registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
80
111
  end
81
112
 
82
113
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
@@ -105,6 +136,13 @@ module Html2rss
105
136
  ]
106
137
  }
107
138
 
139
+ selector_schema = Schema.apply_selector_registry_refs!(
140
+ Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true)
141
+ )
142
+ enclosure_schema = Schema.apply_selector_registry_refs!(
143
+ Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true)
144
+ )
145
+
108
146
  {
109
147
  strategy: {
110
148
  type: 'string',
@@ -121,7 +159,7 @@ module Html2rss
121
159
  items: Html2rss::Config::Validator::StylesheetConfig.json_schema(loose: true)
122
160
  },
123
161
  auto_source: Html2rss::Config::AutoSourceContract.json_schema(loose: true).merge(
124
- default: DeepStringifier.call(Html2rss::AutoSource::DEFAULT_CONFIG)
162
+ default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG)
125
163
  ),
126
164
  selectors: {
127
165
  type: 'object',
@@ -130,14 +168,14 @@ module Html2rss
130
168
  items: items_schema.merge(
131
169
  description: 'Defines the items selector and optional enhancement settings.'
132
170
  ),
133
- enclosure: Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true).merge(
171
+ enclosure: enclosure_schema.merge(
134
172
  description: 'Describes enclosure extraction settings.'
135
173
  ),
136
174
  guid: reference_array('List of selector keys used to build the GUID. Each entry must reference a sibling selector key; runtime validation enforces those references.'),
137
175
  categories: reference_array('List of selector keys whose values will be used as categories. Each entry must reference a sibling selector key; runtime validation enforces those references.')
138
176
  },
139
177
  patternProperties: {
140
- '^(?!items$|enclosure$|guid$|categories$).+$' => Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true).merge(
178
+ '^(?!items$|enclosure$|guid$|categories$).+$' => selector_schema.merge(
141
179
  description: 'Dynamic selector definition keyed by attribute name.'
142
180
  )
143
181
  },
@@ -159,31 +197,46 @@ module Html2rss
159
197
  }
160
198
  end
161
199
  end
200
+ # rubocop:enable Metrics/ClassLength
162
201
 
163
202
  ##
164
- # Converts nested hash keys to strings so the resulting schema serializes cleanly.
165
- module DeepStringifier
166
- module_function
167
-
168
- # @param object [Hash, Array, Object] nested data to normalize
169
- # @return [Hash, Array, Object] deep copy with stringified hash keys
170
- def call(object)
171
- case object
172
- when Hash
173
- stringify_hash(object)
174
- when Array
175
- object.map { |value| call(value) }
176
- when Symbol
177
- object.to_s
178
- else
179
- object
180
- end
181
- end
203
+ # Wires extractor / post_process to thin oneOf $refs into the registry catalog.
204
+ #
205
+ # @param schema [Hash] dry-schema JSON schema fragment for a selector
206
+ # @return [Hash] same schema with registry $ref wiring applied
207
+ def apply_selector_registry_refs!(schema)
208
+ properties = schema.fetch(:properties)
209
+ properties[:extractor] = extractor_wire_schema
210
+ properties[:post_process] = post_process_wire_schema
211
+ schema
212
+ end
213
+
214
+ # @return [Hash{Symbol => Object}]
215
+ def extractor_wire_schema
216
+ {
217
+ description: 'Extractor used to pull a value from the selected element.',
218
+ oneOf: registry_ref_list('extractors', Selectors::Extractors::NAME_TO_CLASS)
219
+ }
220
+ end
182
221
 
183
- # @param object [Hash{Object => Object}] hash whose keys should become strings
184
- # @return [Hash{String => Object}] hash with recursively normalized values
185
- def stringify_hash(object)
186
- object.to_h { |key, value| [key.to_s, call(value)] }
222
+ # @return [Hash{Symbol => Object}]
223
+ def post_process_wire_schema
224
+ {
225
+ type: 'array',
226
+ description: 'Ordered transforms applied to the extracted value.',
227
+ items: {
228
+ oneOf: registry_ref_list('post_processors', Selectors::PostProcessors::NAME_TO_CLASS)
229
+ }
230
+ }
231
+ end
232
+
233
+ ##
234
+ # @param catalog [String] $defs catalog name (`extractors` or `post_processors`)
235
+ # @param registry [Hash{Symbol => Class}]
236
+ # @return [Array<Hash>] oneOf entries of `{ '$ref' => ... }`
237
+ def registry_ref_list(catalog, registry)
238
+ registry.keys.sort.map do |name|
239
+ { '$ref' => "#/$defs/#{catalog}/#{name}" }
187
240
  end
188
241
  end
189
242
  end