html2rss 0.25.0 → 0.26.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  6. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  7. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  8. data/lib/html2rss/auto_source/scraper.rb +34 -15
  9. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  10. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  11. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  12. data/lib/html2rss/auto_source.rb +7 -2
  13. data/lib/html2rss/capture.rb +315 -0
  14. data/lib/html2rss/cli.rb +64 -5
  15. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  16. data/lib/html2rss/config/schema.rb +67 -26
  17. data/lib/html2rss/config/selectors_validator.rb +60 -21
  18. data/lib/html2rss/config/validator.rb +18 -27
  19. data/lib/html2rss/error.rb +15 -3
  20. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  21. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  22. data/lib/html2rss/feed_pipeline.rb +10 -3
  23. data/lib/html2rss/hash_util.rb +17 -0
  24. data/lib/html2rss/html/sst_article_extractor.rb +36 -7
  25. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  26. data/lib/html2rss/link_destination/path_classifier.rb +3 -0
  27. data/lib/html2rss/mcp/server.rb +577 -0
  28. data/lib/html2rss/mcp.rb +21 -0
  29. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  30. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  31. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  32. data/lib/html2rss/request_service/budget.rb +7 -35
  33. data/lib/html2rss/request_service/context.rb +0 -6
  34. data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
  35. data/lib/html2rss/request_service/policy.rb +1 -1
  36. data/lib/html2rss/request_service/response.rb +15 -1
  37. data/lib/html2rss/request_service/strategy.rb +1 -2
  38. data/lib/html2rss/request_service.rb +4 -9
  39. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  40. data/lib/html2rss/scoring/engine.rb +49 -5
  41. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  42. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  43. data/lib/html2rss/scoring/score.rb +1 -20
  44. data/lib/html2rss/scoring.rb +0 -25
  45. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  46. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  47. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  48. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  49. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  50. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  51. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  52. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  53. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  54. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  55. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  56. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  57. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  58. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  59. data/lib/html2rss/url.rb +11 -7
  60. data/lib/html2rss/version.rb +1 -1
  61. data/lib/html2rss.rb +32 -1
  62. data/schema/html2rss-config.schema.json +408 -85
  63. metadata +54 -12
  64. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  65. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  66. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  67. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  68. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -44,7 +44,7 @@ module Html2rss
44
44
  @minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
45
45
  @use_top_selectors = opts.fetch(:use_top_selectors, 5)
46
46
  @link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
47
- @noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver, index: document.index)
47
+ @noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver)
48
48
  end
49
49
 
50
50
  # @return [Array<Segment>]
@@ -61,6 +61,35 @@ module Html2rss
61
61
 
62
62
  # @return [SST::Index]
63
63
  def index = document.index
64
+
65
+ ##
66
+ # Whether +anchor+ has a utility-landmark ancestor outside +container+.
67
+ #
68
+ # @param anchor [SST::Node]
69
+ # @param container [SST::Node]
70
+ # @return [Boolean]
71
+ def landmark_ancestor?(anchor, container)
72
+ cache = (@landmark_cache ||= {})
73
+ key = [anchor.object_id, container.object_id]
74
+ return cache[key] if cache.key?(key)
75
+
76
+ cache[key] = compute_landmark_ancestor?(anchor:, container:)
77
+ end
78
+
79
+ private
80
+
81
+ def compute_landmark_ancestor?(anchor:, container:) # rubocop:disable Metrics/CyclomaticComplexity
82
+ return false unless anchor && container
83
+
84
+ curr = index.parent_of(anchor)
85
+ while curr && curr.name != :html
86
+ return true if curr != container && curr.utility_landmark?
87
+ return false if curr.equal?(container)
88
+
89
+ curr = index.parent_of(curr)
90
+ end
91
+ false
92
+ end
64
93
  end
65
94
  end
66
95
  end
@@ -44,6 +44,9 @@ module Html2rss
44
44
  json_state: {
45
45
  enabled: true
46
46
  },
47
+ xhr_articles: {
48
+ enabled: true
49
+ },
47
50
  meta_oembed: {
48
51
  enabled: true
49
52
  },
@@ -93,6 +96,7 @@ module Html2rss
93
96
  @parsed_body = response.parsed_body
94
97
  @body = response.body
95
98
  @url = response.url
99
+ @captured_responses = response.captured_responses
96
100
  @opts = opts
97
101
  @request_session = request_session
98
102
  end
@@ -114,7 +118,7 @@ module Html2rss
114
118
 
115
119
  private
116
120
 
117
- attr_reader :url, :parsed_body, :body, :request_session
121
+ attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
118
122
 
119
123
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
120
124
  def extract_articles
@@ -143,7 +147,8 @@ module Html2rss
143
147
  request_session:,
144
148
  body:,
145
149
  document:,
146
- link_resolver:
150
+ link_resolver:,
151
+ captured_responses:
147
152
  )
148
153
  next unless instance
149
154
  next unless Scraper.extractable_instance?(instance, parsed_body)
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Analyzes a URL and produces a reusable feed config hash with derived CSS selectors.
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
10
+ LEADING_TRIM_TAGS = %w[html body].freeze
11
+ private_constant :LEADING_TRIM_TAGS
12
+
13
+ ##
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)
19
+
20
+ class << self
21
+ ##
22
+ # Analyzes a URL and builds a reusable feed config.
23
+ #
24
+ # @param url [String] source page URL
25
+ # @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
26
+ # @option options [String, nil] :items_selector optional selector hint
27
+ # @option options [Integer, nil] :max_redirects optional redirect limit override
28
+ # @option options [Integer, nil] :max_requests optional request budget override
29
+ # @option options [Integer, nil] :limit max articles to keep
30
+ # @option options [String, nil] :local_file_path optional local HTML file path
31
+ # @return [CaptureResult]
32
+ def build(url, strategy: :auto, **)
33
+ new(url, strategy:, **).build
34
+ end
35
+ end
36
+
37
+ ##
38
+ # @param url [String] source page URL
39
+ # @param strategy [Symbol] request strategy
40
+ # @param options [Hash] additional options
41
+ # @option options [String, nil] :items_selector optional selector hint
42
+ # @option options [Integer, nil] :max_redirects optional redirect limit override
43
+ # @option options [Integer, nil] :max_requests optional request budget override
44
+ # @option options [Integer, nil] :limit max articles to keep
45
+ # @option options [String, nil] :local_file_path optional local HTML file path
46
+ def initialize(url, strategy: :auto, **options)
47
+ @url = url
48
+ @strategy = strategy
49
+ @items_selector_hint = options.delete(:items_selector)
50
+ @max_redirects = options.delete(:max_redirects)
51
+ @max_requests = options.delete(:max_requests)
52
+ @limit = options.delete(:limit)
53
+ @local_file_path = options.delete(:local_file_path)
54
+ end
55
+
56
+ ##
57
+ # Runs the capture pipeline.
58
+ #
59
+ # @return [CaptureResult]
60
+ def build # rubocop:disable Metrics/MethodLength
61
+ response = fetch_response
62
+ articles = extract_articles(response)
63
+ selectors = derive_selectors(response, articles)
64
+
65
+ config = {
66
+ channel: build_channel(response),
67
+ selectors: selectors.empty? ? nil : selectors,
68
+ **local_file_request_overlay
69
+ }.compact
70
+
71
+ CaptureResult.new(
72
+ config:,
73
+ articles_count: articles.size,
74
+ channel_title: channel_title_from(response)
75
+ )
76
+ end
77
+
78
+ private
79
+
80
+ def raw_config
81
+ @raw_config ||= build_raw_config
82
+ end
83
+
84
+ def build_raw_config # rubocop:disable Metrics/MethodLength
85
+ Config.auto_source_config(
86
+ url: @url,
87
+ items_selector: @items_selector_hint,
88
+ request_controls: Config::RequestControls.from_shortcut(
89
+ strategy: @strategy,
90
+ max_redirects: @max_redirects,
91
+ max_requests: @max_requests
92
+ ),
93
+ limit: @limit
94
+ ).tap do |config|
95
+ config[:strategy] = resolve_strategy(config[:strategy] || @strategy)
96
+ apply_local_file_path!(config)
97
+ end
98
+ end
99
+
100
+ def apply_local_file_path!(config)
101
+ return unless @local_file_path
102
+
103
+ config[:strategy] = :local_file
104
+ config[:request] ||= {}
105
+ config[:request][:local_file_path] = @local_file_path
106
+ end
107
+
108
+ def local_file_request_overlay
109
+ return {} unless @local_file_path
110
+
111
+ { strategy: :local_file, request: { local_file_path: @local_file_path } }
112
+ end
113
+
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
143
+
144
+ segments = discover_segments(sst)
145
+ return {} if segments.empty?
146
+
147
+ matched = match_segments_to_articles(segments, articles)
148
+ return {} if matched.empty?
149
+
150
+ build_selector_hash(matched, sst)
151
+ rescue ArgumentError => error
152
+ Log.warn("Capture selector derivation failed: #{error.message}")
153
+ {}
154
+ end
155
+
156
+ def normalize_sst(response)
157
+ SST::Normalizer.call(response.body)
158
+ end
159
+
160
+ def discover_segments(sst)
161
+ link_resolver = Scoring::LinkResolver.new(@url)
162
+
163
+ AutoSource::Segmenter.call(
164
+ sst,
165
+ base_url: @url,
166
+ strategy: :list,
167
+ permit_unanchored: false,
168
+ link_resolver:
169
+ )
170
+ end
171
+
172
+ def match_segments_to_articles(segments, articles)
173
+ articles_by_url = articles.each_with_object({}) do |article, hash|
174
+ url_str = article.url.to_s
175
+ hash[url_str] = article unless url_str.empty?
176
+ end
177
+
178
+ segments.filter_map do |segment|
179
+ next unless segment.primary_link
180
+
181
+ article = find_matching_article(segment, articles_by_url)
182
+ { segment:, article: } if article
183
+ end
184
+ end
185
+
186
+ def find_matching_article(segment, articles_by_url) # rubocop:disable Metrics/AbcSize
187
+ href = segment.primary_link.attrs.href.to_s
188
+ return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
189
+
190
+ resolved = Url.from_relative(href, @url)
191
+ articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
192
+ end
193
+
194
+ def fuzzy_url_match?(article_url, resolved)
195
+ article_url.to_s.end_with?(resolved.path.to_s)
196
+ rescue StandardError
197
+ false
198
+ end
199
+
200
+ def title_from_segment(segment)
201
+ segment.root_node.visible_text.to_s.strip
202
+ end
203
+
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
+ def items_selector(matched)
221
+ roots = matched.map { |m| m[:segment].root_node }
222
+ shared = shared_class_items_selector(roots)
223
+ return shared if shared
224
+
225
+ paths = roots.map(&:tag_path)
226
+ common = common_path_prefix(paths)
227
+ tag_path = common.empty? ? paths.first.to_s : common
228
+ return nil if tag_path.empty?
229
+
230
+ css_from_trimmed_tag_path(tag_path)
231
+ end
232
+
233
+ def shared_class_items_selector(roots)
234
+ shared = roots.map { |root| root.attrs.class_names }.reduce { |left, right| left & right }
235
+ return nil if shared.nil? || shared.empty?
236
+
237
+ "#{roots.first.name}.#{shared.min}"
238
+ end
239
+
240
+ def css_from_trimmed_tag_path(tag_path)
241
+ segments = tag_path.split('/').reject(&:empty?)
242
+ trimmed = trim_leading_items_segments(segments)
243
+ trimmed.join(' > ')
244
+ end
245
+
246
+ def trim_leading_items_segments(segments)
247
+ return segments if segments.empty?
248
+
249
+ trimmed = segments.dup
250
+ trimmed.shift while trimmed.any? && LEADING_TRIM_TAGS.include?(trimmed.first)
251
+ trimmed.shift while trimmed.length > 1 && trimmed.first == 'div'
252
+ trimmed.empty? ? [segments.last] : trimmed
253
+ end
254
+
255
+ def common_path_prefix(paths) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
256
+ return '' if paths.empty?
257
+
258
+ segments = paths.map { |p| p.split('/').reject(&:empty?) }
259
+ prefix = segments.first
260
+ segments.each do |seg|
261
+ i = 0
262
+ i += 1 while i < prefix.length && i < seg.length && prefix[i] == seg[i]
263
+ prefix = prefix[0...i]
264
+ end
265
+ "/#{prefix.join('/')}"
266
+ end
267
+
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
+ def build_channel(response)
302
+ {
303
+ url: @url,
304
+ title: channel_title_from(response),
305
+ time_zone: 'UTC'
306
+ }.compact
307
+ end
308
+
309
+ def channel_title_from(response)
310
+ Channel.from_response(response).title
311
+ rescue StandardError
312
+ nil
313
+ end
314
+ end
315
+ end
data/lib/html2rss/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'json'
5
+ require 'yaml'
5
6
  require 'thor'
6
7
 
7
8
  module Html2rss
@@ -98,6 +99,43 @@ module Html2rss
98
99
  puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
99
100
  end
100
101
 
102
+ desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
103
+ method_option :strategy,
104
+ type: :string,
105
+ desc: STRATEGY_OPTION_DESC,
106
+ enum: STRATEGY_OPTION_ENUM
107
+ method_option :items_selector, type: :string, desc: 'CSS selector hint for items (optional)'
108
+ method_option :max_redirects,
109
+ type: :numeric,
110
+ desc: 'Maximum redirects to follow per request'
111
+ method_option :max_requests,
112
+ type: :numeric,
113
+ desc: 'Maximum requests to allow for this feed build'
114
+ method_option :limit,
115
+ type: :numeric,
116
+ desc: 'Maximum number of articles to keep (default: 25)'
117
+ method_option :input,
118
+ type: :string,
119
+ desc: 'Local HTML file path to read input from'
120
+ ##
121
+ # Captures a URL and prints a reusable YAML config.
122
+ #
123
+ # @param url [String, nil] source page URL for capture
124
+ # @return [void]
125
+ def capture(url = nil) # rubocop:disable Metrics/MethodLength
126
+ strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
127
+ config = Html2rss.capture(
128
+ url,
129
+ strategy:,
130
+ items_selector: options[:items_selector],
131
+ limit: options[:limit]&.to_i,
132
+ max_redirects: options[:max_redirects],
133
+ max_requests: options[:max_requests],
134
+ local_file_path:
135
+ )
136
+ puts YAML.dump(HashUtil.deep_stringify_keys(config))
137
+ end
138
+
101
139
  desc 'schema', 'Print the exported config JSON Schema'
102
140
  method_option :pretty,
103
141
  type: :boolean,
@@ -123,6 +161,27 @@ module Html2rss
123
161
  puts schema_json
124
162
  end
125
163
 
164
+ desc 'mcp', 'Start the MCP server for AI client consumption'
165
+ method_option :transport,
166
+ type: :string,
167
+ desc: 'MCP transport protocol',
168
+ enum: %w[stdio http],
169
+ default: 'stdio'
170
+ method_option :port,
171
+ type: :numeric,
172
+ desc: 'Port for HTTP transport (binds 127.0.0.1)',
173
+ default: 8080
174
+ ##
175
+ # Starts the MCP server for AI client consumption.
176
+ #
177
+ # @return [void]
178
+ def mcp
179
+ Html2rss::MCP.start(
180
+ transport: options[:transport].to_sym,
181
+ port: options[:port]
182
+ )
183
+ end
184
+
126
185
  desc 'validate YAML_FILE [feed_name]', 'Validate a YAML config with the runtime validator'
127
186
  method_option :params,
128
187
  type: :hash,
@@ -183,7 +242,7 @@ module Html2rss
183
242
 
184
243
  file_path = check_file_exists!(input_option)
185
244
  detected_url = url || detect_base_url!(
186
- file_path, 'Please specify a URL: html2rss auto [URL] --input <file>'
245
+ file_path, 'Please specify a URL: html2rss <command> [URL] --input <file>'
187
246
  )
188
247
 
189
248
  [:local_file, file_path, detected_url]
@@ -221,15 +280,15 @@ module Html2rss
221
280
  yield
222
281
  rescue Faraday::FollowRedirects::RedirectLimitReached => error
223
282
  raise Thor::Error,
224
- "#{error.message}. retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
283
+ "#{error.message}. already retried the last redirect hop once; " \
284
+ "retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
225
285
  rescue Html2rss::RequestService::RequestBudgetExceeded => error
226
286
  raise Thor::Error,
227
287
  "#{error.message}. retry with --max-requests #{suggested_max_requests} " \
228
288
  'or increase request.max_requests in the config.'
229
- rescue Html2rss::RequestService::BrowserlessConfigurationError,
230
- Html2rss::RequestService::BrowserlessConnectionFailed,
231
- Html2rss::RequestService::BotasaurusConfigurationError,
289
+ rescue Html2rss::RequestService::BotasaurusConfigurationError,
232
290
  Html2rss::RequestService::BotasaurusConnectionFailed,
291
+ Html2rss::RequestService::BotasaurusServiceError,
233
292
  Html2rss::RequestService::BlockedSurfaceDetected,
234
293
  Html2rss::NoFeedItemsExtracted => error
235
294
  raise Thor::Error, error.message
@@ -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,23 @@ 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
+ end
86
+
87
+ # @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
88
+ def registry_catalog_defs
89
+ {
90
+ post_processors: catalog_from_registry(Selectors::PostProcessors::NAME_TO_CLASS),
91
+ extractors: catalog_from_registry(Selectors::Extractors::NAME_TO_CLASS)
92
+ }
93
+ end
94
+
95
+ # @param registry [Hash{Symbol => Class}]
96
+ # @return [Hash{String => Hash}]
97
+ def catalog_from_registry(registry)
98
+ registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
80
99
  end
81
100
 
82
101
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
@@ -105,6 +124,13 @@ module Html2rss
105
124
  ]
106
125
  }
107
126
 
127
+ selector_schema = Schema.apply_selector_registry_refs!(
128
+ Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true)
129
+ )
130
+ enclosure_schema = Schema.apply_selector_registry_refs!(
131
+ Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true)
132
+ )
133
+
108
134
  {
109
135
  strategy: {
110
136
  type: 'string',
@@ -121,7 +147,7 @@ module Html2rss
121
147
  items: Html2rss::Config::Validator::StylesheetConfig.json_schema(loose: true)
122
148
  },
123
149
  auto_source: Html2rss::Config::AutoSourceContract.json_schema(loose: true).merge(
124
- default: DeepStringifier.call(Html2rss::AutoSource::DEFAULT_CONFIG)
150
+ default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG)
125
151
  ),
126
152
  selectors: {
127
153
  type: 'object',
@@ -130,14 +156,14 @@ module Html2rss
130
156
  items: items_schema.merge(
131
157
  description: 'Defines the items selector and optional enhancement settings.'
132
158
  ),
133
- enclosure: Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true).merge(
159
+ enclosure: enclosure_schema.merge(
134
160
  description: 'Describes enclosure extraction settings.'
135
161
  ),
136
162
  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
163
  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
164
  },
139
165
  patternProperties: {
140
- '^(?!items$|enclosure$|guid$|categories$).+$' => Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true).merge(
166
+ '^(?!items$|enclosure$|guid$|categories$).+$' => selector_schema.merge(
141
167
  description: 'Dynamic selector definition keyed by attribute name.'
142
168
  )
143
169
  },
@@ -159,31 +185,46 @@ module Html2rss
159
185
  }
160
186
  end
161
187
  end
188
+ # rubocop:enable Metrics/ClassLength
162
189
 
163
190
  ##
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
191
+ # Wires extractor / post_process to thin oneOf $refs into the registry catalog.
192
+ #
193
+ # @param schema [Hash] dry-schema JSON schema fragment for a selector
194
+ # @return [Hash] same schema with registry $ref wiring applied
195
+ def apply_selector_registry_refs!(schema)
196
+ properties = schema.fetch(:properties)
197
+ properties[:extractor] = extractor_wire_schema
198
+ properties[:post_process] = post_process_wire_schema
199
+ schema
200
+ end
201
+
202
+ # @return [Hash{Symbol => Object}]
203
+ def extractor_wire_schema
204
+ {
205
+ description: 'Extractor used to pull a value from the selected element.',
206
+ oneOf: registry_ref_list('extractors', Selectors::Extractors::NAME_TO_CLASS)
207
+ }
208
+ end
182
209
 
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)] }
210
+ # @return [Hash{Symbol => Object}]
211
+ def post_process_wire_schema
212
+ {
213
+ type: 'array',
214
+ description: 'Ordered transforms applied to the extracted value.',
215
+ items: {
216
+ oneOf: registry_ref_list('post_processors', Selectors::PostProcessors::NAME_TO_CLASS)
217
+ }
218
+ }
219
+ end
220
+
221
+ ##
222
+ # @param catalog [String] $defs catalog name (`extractors` or `post_processors`)
223
+ # @param registry [Hash{Symbol => Class}]
224
+ # @return [Array<Hash>] oneOf entries of `{ '$ref' => ... }`
225
+ def registry_ref_list(catalog, registry)
226
+ registry.keys.sort.map do |name|
227
+ { '$ref' => "#/$defs/#{catalog}/#{name}" }
187
228
  end
188
229
  end
189
230
  end