html2rss 0.30.0 → 0.31.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +1 -1
  6. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  10. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  11. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  13. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  14. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  16. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  17. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper.rb +4 -14
  19. data/lib/html2rss/auto_source/segment.rb +7 -4
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  21. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  22. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  23. data/lib/html2rss/auto_source.rb +2 -2
  24. data/lib/html2rss/batch.rb +3 -1
  25. data/lib/html2rss/capture/README.md +6 -4
  26. data/lib/html2rss/capture.rb +67 -35
  27. data/lib/html2rss/channel.rb +3 -3
  28. data/lib/html2rss/cli/probe_view.rb +5 -3
  29. data/lib/html2rss/cli/render.rb +6 -5
  30. data/lib/html2rss/cli/validate.rb +28 -27
  31. data/lib/html2rss/cli.rb +1 -1
  32. data/lib/html2rss/config/issue_mapper.rb +108 -0
  33. data/lib/html2rss/config/request_headers.rb +2 -3
  34. data/lib/html2rss/config/schema.rb +16 -1
  35. data/lib/html2rss/config/selectors_validator.rb +92 -70
  36. data/lib/html2rss/config/validation_issue.rb +60 -0
  37. data/lib/html2rss/config/validation_report.rb +55 -0
  38. data/lib/html2rss/config/validator.rb +10 -6
  39. data/lib/html2rss/config.rb +35 -113
  40. data/lib/html2rss/defaults.rb +42 -30
  41. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  42. data/lib/html2rss/doctor.rb +8 -0
  43. data/lib/html2rss/error.rb +1 -1
  44. data/lib/html2rss/feed_builder/rss.rb +9 -14
  45. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  46. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  48. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  49. data/lib/html2rss/feed_resolution/options.rb +3 -1
  50. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  51. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  52. data/lib/html2rss/html/article_extractor.rb +2 -4
  53. data/lib/html2rss/html/feed_link.rb +3 -1
  54. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  55. data/lib/html2rss/html/navigator.rb +10 -10
  56. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  57. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  58. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  59. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  60. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  61. data/lib/html2rss/mcp/README.md +2 -0
  62. data/lib/html2rss/mcp/config_argument.rb +3 -1
  63. data/lib/html2rss/mcp/contract.rb +6 -20
  64. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  65. data/lib/html2rss/mcp/outcome.rb +36 -43
  66. data/lib/html2rss/mcp/server/tools.rb +111 -131
  67. data/lib/html2rss/mcp/server.rb +26 -18
  68. data/lib/html2rss/page_recon/README.md +5 -5
  69. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  70. data/lib/html2rss/page_recon.rb +9 -5
  71. data/lib/html2rss/recon.rb +19 -30
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  74. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  75. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  76. data/lib/html2rss/request_service/network_guard.rb +7 -5
  77. data/lib/html2rss/request_service/response.rb +1 -2
  78. data/lib/html2rss/request_service/strategy.rb +3 -8
  79. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  80. data/lib/html2rss/scoring/engine.rb +8 -8
  81. data/lib/html2rss/scoring/observation.rb +5 -3
  82. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  83. data/lib/html2rss/scrape_target.rb +3 -1
  84. data/lib/html2rss/selector_candidates.rb +234 -0
  85. data/lib/html2rss/selectors/README.md +64 -0
  86. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  87. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  88. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  89. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  90. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  91. data/lib/html2rss/selectors/extractors.rb +33 -20
  92. data/lib/html2rss/selectors/item_env.rb +31 -0
  93. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  94. data/lib/html2rss/selectors/option_spec.rb +140 -0
  95. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  96. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  97. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  98. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  99. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  100. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  101. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  102. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  103. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  104. data/lib/html2rss/selectors/post_processors.rb +6 -5
  105. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  106. data/lib/html2rss/selectors/step_env.rb +24 -0
  107. data/lib/html2rss/selectors.rb +231 -142
  108. data/lib/html2rss/sst/attrs.rb +5 -3
  109. data/lib/html2rss/sst/document.rb +3 -1
  110. data/lib/html2rss/sst/index.rb +3 -1
  111. data/lib/html2rss/sst/node.rb +29 -10
  112. data/lib/html2rss/sst/normalizer.rb +12 -16
  113. data/lib/html2rss/sst/tags.rb +7 -7
  114. data/lib/html2rss/status.rb +13 -8
  115. data/lib/html2rss/surface_category.rb +14 -32
  116. data/lib/html2rss/syndication/parser.rb +2 -2
  117. data/lib/html2rss/test/enhance_audit.rb +1 -3
  118. data/lib/html2rss/test/policy.rb +106 -0
  119. data/lib/html2rss/test.rb +126 -173
  120. data/lib/html2rss/url.rb +25 -4
  121. data/lib/html2rss/version.rb +1 -1
  122. data/lib/html2rss.rb +21 -13
  123. data/lib/tasks/config_schema.rake +2 -2
  124. data/schema/html2rss-config.schema.json +11 -8
  125. metadata +15 -6
  126. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -33,18 +33,17 @@ module Html2rss
33
33
  }.freeze
34
34
 
35
35
  # Hop-by-hop headers forbidden in HTTP/2 requests (RFC 7540 §8.1.2.2 / RFC 9113 §8.2.1).
36
- FORBIDDEN_H2_HEADERS = %w[connection keep-alive proxy-connection transfer-encoding upgrade].to_set.freeze
36
+ FORBIDDEN_H2_HEADERS = Set['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade'].freeze
37
37
 
38
38
  class << self
39
39
  ##
40
- # :reek:ManualDispatch
41
40
  # :reek:TooManyStatements
42
41
  #
43
42
  # @return [Hash{String => String}] the default header set merged with global defaults
44
43
  def browser_defaults
45
44
  defaults = DEFAULT_HEADERS.dup
46
45
  global_headers = Html2rss.defaults.headers
47
- global_headers = global_headers.call if global_headers.respond_to?(:call)
46
+ global_headers = global_headers.call if global_headers.is_a?(Proc)
48
47
 
49
48
  if global_headers.is_a?(Hash)
50
49
  global_headers.each do |key, value|
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Html2rss
4
6
  class Config
5
7
  ##
@@ -19,6 +21,15 @@ module Html2rss
19
21
  Builder.call
20
22
  end
21
23
 
24
+ ##
25
+ # Returns the exported JSON Schema as JSON.
26
+ #
27
+ # @param pretty [Boolean] whether to pretty-print the JSON output
28
+ # @return [String] serialized JSON Schema
29
+ def json_schema_json(pretty: true)
30
+ pretty ? JSON.pretty_generate(json_schema) : JSON.generate(json_schema)
31
+ end
32
+
22
33
  ##
23
34
  # Resolves the packaged schema path used by downstream tools.
24
35
  #
@@ -107,12 +118,16 @@ module Html2rss
107
118
  # @param registry [Hash{Symbol => Class}]
108
119
  # @return [Hash{String => Hash}]
109
120
  def catalog_from_registry(registry)
110
- registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
121
+ registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_export] }
111
122
  end
112
123
 
113
124
  # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
114
125
  def overlay
115
126
  items_schema = Html2rss::Config::SelectorsValidator::Items.new.schema.json_schema(loose: true)
127
+ items_schema[:required] = Array(items_schema[:required]).reject { |key| key.to_sym == :selector }
128
+ items_schema[:properties][:selector] = items_schema.fetch(:properties).fetch(:selector).merge(
129
+ default: Html2rss::Selectors::DEFAULT_ITEMS_SELECTOR
130
+ )
116
131
  items_schema[:properties][:enhance] = items_schema.fetch(:properties).fetch(:enhance).merge(
117
132
  description: 'List-card enrichment: run Html::ArticleExtractor on each matched item node ' \
118
133
  'to fill missing fields from the card HTML.'
@@ -6,9 +6,22 @@ module Html2rss
6
6
  class Config
7
7
  ##
8
8
  # Validates the configuration hash for :selectors.
9
- class SelectorsValidator < Dry::Validation::Contract
10
- # Required wrapper key used to validate dynamic selector names.
11
- NESTING_KEY = :dynamic_keys_workaround
9
+ module SelectorsValidator
10
+ ##
11
+ # One selector validation error.
12
+ Error = Data.define(:path, :text)
13
+
14
+ Result = Data.define(:errors)
15
+
16
+ ##
17
+ # Validation result containing collected selector errors.
18
+ class Result
19
+ # @return [Boolean]
20
+ def success? = errors.empty?
21
+
22
+ # @return [Boolean]
23
+ def failure? = !success?
24
+ end
12
25
 
13
26
  ##
14
27
  # Validates the configuration of the :items selector
@@ -75,9 +88,6 @@ module Html2rss
75
88
  ##
76
89
  # Validates the configuration of a single selector.
77
90
  class Selector < Dry::Validation::Contract
78
- # Extractor Options members supplied at runtime, not from feed YAML.
79
- RUNTIME_EXTRACTOR_FIELDS = %i[selector channel].to_set.freeze
80
-
81
91
  params do
82
92
  optional(:selector)
83
93
  optional(:extractor).filled(:string)
@@ -96,11 +106,11 @@ module Html2rss
96
106
  klass = Selectors::Extractors::NAME_TO_CLASS[value.to_sym]
97
107
  next key(:extractor).failure("unknown extractor: #{value}") unless klass
98
108
 
99
- klass::Options.members.each do |field|
100
- next if RUNTIME_EXTRACTOR_FIELDS.include?(field)
101
- next if values[field]
109
+ Selectors::OptionSpec.for(klass).each do |spec|
110
+ next unless spec.required
111
+ next if values[spec.name]
102
112
 
103
- key(field).failure("`#{field}` is required for extractor `#{value}`")
113
+ key(spec.name).failure("`#{spec.name}` is required for extractor `#{value}`")
104
114
  end
105
115
  end
106
116
 
@@ -110,49 +120,44 @@ module Html2rss
110
120
 
111
121
  klass = Selectors::PostProcessors::NAME_TO_CLASS[name.to_sym]
112
122
  next key(:post_process).failure("Unknown post_processor `name`: #{name}") unless klass
113
- next unless klass.const_defined?(:Options)
114
123
 
115
124
  post_process_option_type_errors(klass, value).each do |field, message|
116
125
  key(field).failure(message)
117
126
  end
127
+
128
+ pattern_error = gsub_pattern_error(klass, value)
129
+ key(:pattern).failure(pattern_error) if pattern_error
118
130
  end
119
131
 
120
132
  private
121
133
 
122
134
  def post_process_option_type_errors(klass, value)
123
- required_option_type_errors(klass, value) + optional_option_type_errors(klass, value)
124
- end
125
-
126
- def required_option_type_errors(klass, value)
127
- option_types = klass.const_defined?(:OPTION_TYPES) ? klass::OPTION_TYPES : {}
128
-
129
- klass::Options.members.filter_map do |field|
130
- expected = option_types.fetch(field, String)
131
- next if value[field].is_a?(expected)
132
-
133
- [field, option_type_failure(field, expected)]
135
+ Selectors::OptionSpec.for(klass).filter_map do |spec|
136
+ actual = value[spec.name]
137
+ if actual.nil?
138
+ [spec.name, spec.error_message(optional: false)] if spec.required
139
+ elsif !spec.valid_type?(actual)
140
+ [spec.name, spec.error_message(optional: !spec.required)]
141
+ end
134
142
  end
135
143
  end
136
144
 
137
- def optional_option_type_errors(klass, value)
138
- return [] unless klass.const_defined?(:OPTIONAL_OPTION_TYPES)
145
+ # Same compile as Gsub#call. ArgumentError is the bound; Gsub maps
146
+ # parser errors onto that path so admit and execute still share it.
147
+ #
148
+ # @param klass [Class]
149
+ # @param value [Hash]
150
+ # @return [String, nil]
151
+ def gsub_pattern_error(klass, value)
152
+ return unless klass == Selectors::PostProcessors::Gsub
139
153
 
140
- klass::OPTIONAL_OPTION_TYPES.filter_map do |field, expected|
141
- actual = value[field]
142
- next if actual.nil? || actual.is_a?(expected)
143
-
144
- [field, option_type_failure(field, expected, optional: true)]
145
- end
146
- end
154
+ pattern = value[:pattern]
155
+ return unless pattern.is_a?(String)
147
156
 
148
- def option_type_failure(field, expected, optional: false)
149
- label = {
150
- Integer => 'an integer',
151
- String => 'a string',
152
- Hash => 'a hash'
153
- }.fetch(expected) { "a #{expected}" }
154
- suffix = optional ? ' or omitted' : ''
155
- "`#{field}` must be #{label}#{suffix}"
157
+ Selectors::PostProcessors::Gsub.compiled_pattern(pattern)
158
+ nil
159
+ rescue ArgumentError => error
160
+ error.message
156
161
  end
157
162
  end
158
163
 
@@ -164,47 +169,64 @@ module Html2rss
164
169
  end
165
170
  end
166
171
 
167
- params do
168
- required(NESTING_KEY).hash
169
- end
172
+ class << self
173
+ ##
174
+ # Shortcut to validate the config.
175
+ # @param config [Hash] the configuration hash to validate
176
+ # @return [Result]
177
+ def call(config)
178
+ errors = []
179
+ return Result.new(errors:) unless config.is_a?(Hash)
180
+
181
+ config.each_pair do |selector_key, selector|
182
+ validate_entry(selector_key, selector, config, errors)
183
+ end
184
+
185
+ Result.new(errors:)
186
+ end
170
187
 
171
- rule(NESTING_KEY) do
172
- value.each_pair do |selector_key, selector|
188
+ private
189
+
190
+ def validate_entry(selector_key, selector, config, errors)
173
191
  case selector_key.to_sym
174
192
  when Selectors::ITEMS_SELECTOR_KEY
175
- Items.new.call(selector).errors.each { |error| key(selector_key).failure(error.text) }
193
+ collect_contract_errors(Items, selector, selector_key, errors)
176
194
  when :enclosure
177
- Enclosure.new.call(selector).errors.each { |error| key(selector_key).failure(error.text) }
195
+ collect_contract_errors(Enclosure, selector, selector_key, errors)
178
196
  when :guid, :categories
179
- unless selector.is_a?(Array)
180
- key(selector_key).failure("`#{selector_key}` must be an array")
181
- next
182
- end
197
+ validate_array_selector(selector_key, selector, config, errors)
198
+ else
199
+ collect_contract_errors(Selector, selector, selector_key, errors)
200
+ end
201
+ end
183
202
 
184
- key(selector_key).failure("`#{selector_key}` must contain at least one element") if selector.empty?
203
+ def collect_contract_errors(contract_class, selector, selector_key, errors)
204
+ contract_class.new.call(selector).errors.each do |error|
205
+ errors << Error.new(path: [selector_key, *error.path], text: error.text)
206
+ end
207
+ end
185
208
 
186
- selector.each do |name|
187
- next if values[NESTING_KEY].key?(name.to_sym)
209
+ def validate_array_selector(selector_key, selector, config, errors)
210
+ msg = array_selector_shape_error(selector_key, selector)
211
+ return errors << Error.new(path: [selector_key], text: msg) if msg
188
212
 
189
- key(selector_key).failure("`#{selector_key}` references unspecified `#{name}`")
190
- end
191
- else
192
- # From here on, the selector is found under its "dynamic" selector_key
193
- Selector.new.call(selector).errors.each { |error| key(selector_key).failure(error.text) }
194
- end
213
+ check_unspecified_references(selector_key, selector, config, errors)
195
214
  end
196
- end
197
215
 
198
- ##
199
- # Shortcut to validate the config.
200
- # @param config [Hash] the configuration hash to validate
201
- # @return [Dry::Validation::Result] the result of the validation
202
- def self.call(config)
203
- # dry-validation/schema does not support "Dynamic Keys" yet: https://github.com/dry-rb/dry-schema/issues/37
204
- # But :selectors contains mostly "dynamic" keys, as the user defines them to extract article attributes.
205
- # --> Validate the dynamic keys manually.
206
- # To be able to specify a `rule`, nest the config under NESTING_KEY and mark that as `required`.
207
- new.call(NESTING_KEY => config)
216
+ def array_selector_shape_error(key, selector)
217
+ return "`#{key}` must be an array" unless selector.is_a?(Array)
218
+ return "`#{key}` must contain at least one element" if selector.empty?
219
+
220
+ nil
221
+ end
222
+
223
+ def check_unspecified_references(selector_key, selector, config, errors)
224
+ selector.each do |name|
225
+ next if config.key?(name.to_sym) || config.key?(name.to_s)
226
+
227
+ errors << Error.new(path: [selector_key], text: "`#{selector_key}` references unspecified `#{name}`")
228
+ end
229
+ end
208
230
  end
209
231
  end
210
232
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Config
5
+ ValidationIssue = Data.define(:path, :code, :message, :expected, :actual)
6
+
7
+ ##
8
+ # One structured validation finding for GUI/MCP/CLI/Test.
9
+ #
10
+ # +path+ is JSON-pointer-friendly segments. +code+ is closed ({CODES}).
11
+ # +expected+ / +actual+ are JSON-ish or +nil+ (never invented).
12
+ class ValidationIssue
13
+ # Closed set of issue codes (mapper owns Dry predicate → code).
14
+ CODES = Set[
15
+ :missing_key,
16
+ :type_mismatch,
17
+ :unknown_value,
18
+ :invalid_value,
19
+ :constraint,
20
+ :parse
21
+ ].freeze
22
+
23
+ ##
24
+ # @param path [Array<Symbol, Integer, String>]
25
+ # @param code [Symbol]
26
+ # @param message [String]
27
+ # @param expected [Hash, String, Numeric, true, false, nil]
28
+ # @param actual [Hash, String, Numeric, true, false, nil]
29
+ def initialize(path:, code:, message:, expected: nil, actual: nil)
30
+ raise ArgumentError, "unknown ValidationIssue code: #{code.inspect}" unless CODES.include?(code)
31
+
32
+ super(
33
+ path: Array(path).freeze,
34
+ code:,
35
+ message: message.to_s,
36
+ expected:,
37
+ actual:
38
+ )
39
+ end
40
+
41
+ ##
42
+ # Wire shape for a single issue.
43
+ #
44
+ # @return [Hash{Symbol => Object}]
45
+ def to_h
46
+ { path:, code:, message:, expected:, actual: }
47
+ end
48
+
49
+ ##
50
+ # Single renderer for CLI / exceptions (path [code] message).
51
+ #
52
+ # @return [String]
53
+ def to_s
54
+ path_s = path.join('.')
55
+ path_s = '(root)' if path_s.empty?
56
+ "#{path_s} [#{code}] #{message}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Config
5
+ ValidationReport = Data.define(:issues)
6
+
7
+ ##
8
+ # Public result of config validation. Dry::Validation::Result never crosses this boundary.
9
+ # +success+ is derived from +issues+ — invalid states like +success: true+ with issues cannot exist.
10
+ class ValidationReport
11
+ ##
12
+ # @return [Html2rss::Config::ValidationReport]
13
+ def self.ok
14
+ new(issues: [].freeze)
15
+ end
16
+
17
+ ##
18
+ # @param issues [Array<Html2rss::Config::ValidationIssue>]
19
+ # @return [Html2rss::Config::ValidationReport]
20
+ def self.failure(issues)
21
+ new(issues: Array(issues).freeze)
22
+ end
23
+
24
+ ##
25
+ # @param issues [Array<Html2rss::Config::ValidationIssue>]
26
+ def initialize(issues:)
27
+ super(issues: Array(issues).freeze)
28
+ end
29
+
30
+ # @return [Boolean]
31
+ def success? = issues.empty?
32
+
33
+ # @return [Boolean]
34
+ def failure? = !success?
35
+
36
+ ##
37
+ # Wire shape: +{ success:, issues: [...] }+ only (MCP contract v3).
38
+ #
39
+ # @return [Hash{Symbol => Object}]
40
+ def to_h
41
+ { success: success?, issues: issues.map(&:to_h) }
42
+ end
43
+
44
+ ##
45
+ # Human-readable summary for CLI / exception messages.
46
+ #
47
+ # @return [String]
48
+ def to_s
49
+ return 'valid' if success?
50
+
51
+ issues.join('; ')
52
+ end
53
+ end
54
+ end
55
+ end
@@ -16,10 +16,10 @@ module Html2rss
16
16
  # Baseline strategy-plan enum (:auto plus concrete RequestService strategies).
17
17
  BASE_STRATEGY_OPTIONS = Html2rss::FeedPipeline::StrategyPlan.accepted_names.freeze
18
18
  # Controlled vocabulary for catalog-only `directory.topics` (not RSS channel fields).
19
- DIRECTORY_TOPICS = %w[
20
- sports energy tech science news entertainment jobs finance
21
- security travel environment consumer civic product research
22
- health culture education rights transport
19
+ DIRECTORY_TOPICS = Set[
20
+ 'sports', 'energy', 'tech', 'science', 'news', 'entertainment', 'jobs', 'finance',
21
+ 'security', 'travel', 'environment', 'consumer', 'civic', 'product', 'research',
22
+ 'health', 'culture', 'education', 'rights', 'transport'
23
23
  ].freeze
24
24
 
25
25
  # Contract for the top-level `channel` section.
@@ -134,8 +134,12 @@ module Html2rss
134
134
  rule(:selectors) do
135
135
  next unless value
136
136
 
137
- errors = Config::SelectorsValidator.call(value).errors
138
- errors.each { |error| key(:selectors).failure(error.text) } unless errors.empty?
137
+ result = Config::SelectorsValidator.call(value)
138
+ next if result.success?
139
+
140
+ result.errors.each do |error|
141
+ key([:selectors, *error.path]).failure(error.text)
142
+ end
139
143
  end
140
144
 
141
145
  rule(request: :botasaurus) do
@@ -13,53 +13,8 @@ module Html2rss
13
13
  class Config # rubocop:disable Metrics/ClassLength
14
14
  # Raised when a configuration hash fails runtime validation.
15
15
  class InvalidConfig < Html2rss::Error; end
16
- # Sentinel to differentiate omitted params from explicit `nil`.
17
- UNSET = Object.new.freeze
18
-
19
- ValidationResult = Data.define(:success, :errors_hash)
20
-
21
- ##
22
- # Duck-compatible with +Dry::Validation::Result+ for resolve-time outcomes
23
- # (+#success?+, +#errors#to_h+). Used for parse failures so callers never see a Struct snowflake.
24
- class ValidationResult
25
- ##
26
- # @param message [String]
27
- # @return [Html2rss::Config::ValidationResult]
28
- def self.parse_failure(message)
29
- new(success: false, errors_hash: { parse: [message] })
30
- end
31
-
32
- ##
33
- # @return [Boolean]
34
- def success? = success
35
-
36
- ##
37
- # @return [self]
38
- def errors = self
39
-
40
- ##
41
- # @return [Hash]
42
- def to_h = errors_hash
43
- end
44
16
 
45
17
  class << self
46
- ##
47
- # Returns the exported JSON Schema for html2rss configuration.
48
- #
49
- # @return [Hash{String => Object}] JSON Schema represented as a Ruby hash
50
- def json_schema
51
- Schema.json_schema
52
- end
53
-
54
- ##
55
- # Returns the exported JSON Schema as JSON.
56
- #
57
- # @param pretty [Boolean] whether to pretty-print the JSON output
58
- # @return [String] serialized JSON Schema
59
- def json_schema_json(pretty: true)
60
- pretty ? JSON.pretty_generate(json_schema) : JSON.generate(json_schema)
61
- end
62
-
63
18
  ##
64
19
  # Resolves a Hash, YAML file path, or YAML string to a working config Hash and validates it.
65
20
  # The returned Hash is a deep copy — callers may stamp strategy/params without mutating input.
@@ -67,13 +22,14 @@ module Html2rss
67
22
  # @param config_input [Hash, String] config hash, YAML string, or file path
68
23
  # @param feed_name [String, nil] optional feed name for multi-feed files
69
24
  # @param params [Hash] dynamic feed params
70
- # @return [Array(Hash, Dry::Validation::Result, Html2rss::Config::ValidationResult)]
25
+ # @return [Array(Hash, Html2rss::Config::ValidationReport)]
71
26
  def resolve_and_validate(config_input, feed_name: nil, params: {})
72
- param_arg = params.empty? ? UNSET : params
73
- working = HashUtil.deep_dup(resolve_raw_hash(config_input, feed_name))
74
- [working, validate(working, params: param_arg)]
75
- rescue StandardError => error
76
- [{}, ValidationResult.parse_failure(error.message)]
27
+ raw = resolve_raw_hash(config_input, feed_name)
28
+ rescue Psych::Exception, ArgumentError => error
29
+ [{}, IssueMapper.parse_failure(error.message)]
30
+ else
31
+ working = HashUtil.deep_dup(raw)
32
+ [working, validate(working, params:)]
77
33
  end
78
34
 
79
35
  ##
@@ -81,36 +37,15 @@ module Html2rss
81
37
  #
82
38
  # @param config [Hash{Symbol => Object}] the configuration hash
83
39
  # @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
84
- # @return [Dry::Validation::Result] validation result after defaults are applied
85
- def validate(config, params: UNSET)
40
+ # @return [Html2rss::Config::ValidationReport]
41
+ def validate(config, params: {})
86
42
  prepared_config = prepare_for_validation(resolve_effective_config(config, params:))
87
-
88
- Validator.new.call(prepared_config)
43
+ IssueMapper.from(Validator.new.call(prepared_config), values: prepared_config)
89
44
  rescue DynamicParams::ParamsMissing => error
90
45
  prepared_config = prepare_for_validation(HashUtil.deep_symbolize_keys(config, context: 'config'))
91
46
  prepared_config[:dynamic_params_error] = error.message
92
47
 
93
- Validator.new.call(prepared_config)
94
- end
95
-
96
- ##
97
- # Loads and validates a YAML configuration file.
98
- #
99
- # @param file [String] the YAML file to load
100
- # @param feed_name [String, nil] optional feed name for multi-feed files
101
- # @param multiple_feeds_key [Symbol] key under which multiple feeds are defined
102
- # @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
103
- # @return [Dry::Validation::Result, Html2rss::Config::ValidationResult]
104
- def validate_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS, params: UNSET)
105
- validate(load_yaml(file, feed_name, multiple_feeds_key:), params:)
106
- end
107
-
108
- ##
109
- # Returns the packaged JSON Schema file path.
110
- #
111
- # @return [String] absolute path to the packaged JSON Schema file
112
- def schema_path
113
- Schema.path
48
+ IssueMapper.from(Validator.new.call(prepared_config), values: prepared_config)
114
49
  end
115
50
 
116
51
  ##
@@ -179,7 +114,7 @@ module Html2rss
179
114
  # @param config [Hash{Symbol => Object}] the configuration hash.
180
115
  # @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting.
181
116
  # @return [Html2rss::Config] the configuration object.
182
- def from_hash(config, params: UNSET)
117
+ def from_hash(config, params: {})
183
118
  new(resolve_effective_config(config, params:))
184
119
  end
185
120
 
@@ -226,6 +161,20 @@ module Html2rss
226
161
  Html2rss.defaults.default_strategy || :auto
227
162
  end
228
163
 
164
+ ##
165
+ # Normalizes raw config input with defaults.
166
+ #
167
+ # @param config [Hash{Symbol => Object}] raw config input
168
+ # @return [Hash{Symbol => Object}] config with defaults applied
169
+ def prepare_defaults(config)
170
+ config = HashUtil.deep_merge(default_config, config)
171
+ config = HashUtil.deep_merge({ selectors: Selectors::DEFAULT_CONFIG }, config) if config[:selectors]
172
+ if config[:auto_source]
173
+ config = HashUtil.deep_merge({ auto_source: Html2rss::AutoSource::DEFAULT_CONFIG }, config)
174
+ end
175
+ config
176
+ end
177
+
229
178
  private
230
179
 
231
180
  def default_request_config
@@ -239,7 +188,7 @@ module Html2rss
239
188
  def resolve_effective_config(config, params:)
240
189
  cfg = HashUtil.deep_symbolize_keys(config, context: 'config')
241
190
  p = parameter_defaults(cfg)
242
- p.merge!(HashUtil.deep_symbolize_keys(params, context: 'params')) unless params.equal?(UNSET) || params.nil?
191
+ p.merge!(HashUtil.deep_symbolize_keys(params, context: 'params')) unless params.nil?
243
192
 
244
193
  cfg[:headers] = DynamicParams.call(cfg[:headers], p) if cfg[:headers]
245
194
  cfg[:channel] = DynamicParams.call(cfg[:channel], p) if cfg[:channel]
@@ -255,7 +204,7 @@ module Html2rss
255
204
  end
256
205
 
257
206
  def prepare_for_validation(config)
258
- Config::Preparer.new.call(HashUtil.deep_dup(config))
207
+ prepare_defaults(HashUtil.deep_dup(config))
259
208
  end
260
209
 
261
210
  # @param config_input [Hash, String]
@@ -282,7 +231,7 @@ module Html2rss
282
231
  # @raise [InvalidConfig] if the configuration fails validation.
283
232
  def initialize(config)
284
233
  @request_controls = RequestControls.from_config(config)
285
- prepared_config = Preparer.new.call(config)
234
+ prepared_config = self.class.prepare_defaults(config.dup)
286
235
  validated_config = validated_config_for(prepared_config)
287
236
 
288
237
  @config = validated_config.freeze
@@ -340,42 +289,15 @@ module Html2rss
340
289
 
341
290
  attr_reader :config
342
291
 
343
- # Normalizes raw config input before validation.
344
- class Preparer
345
- ##
346
- # @param config [Hash{Symbol => Object}] raw config input
347
- # @return [Hash{Symbol => Object}] config with defaults applied
348
- def call(config)
349
- config = config.dup if config.frozen?
350
-
351
- config = apply_default_config(config)
352
- config = apply_default_selectors_config(config) if config[:selectors]
353
- config = apply_default_auto_source_config(config) if config[:auto_source]
354
-
355
- config
356
- end
357
-
358
- private
359
-
360
- def apply_default_config(config)
361
- HashUtil.deep_merge(Config.default_config, config)
362
- end
363
-
364
- def apply_default_selectors_config(config)
365
- HashUtil.deep_merge({ selectors: Selectors::DEFAULT_CONFIG }, config)
366
- end
367
-
368
- def apply_default_auto_source_config(config)
369
- HashUtil.deep_merge({ auto_source: Html2rss::AutoSource::DEFAULT_CONFIG }, config)
370
- end
371
- end
372
-
373
292
  def validated_config_for(config)
374
- validator = Validator.new.call(config)
293
+ dry = Validator.new.call(config)
375
294
 
376
- raise InvalidConfig, "Invalid configuration: #{validator.errors.to_h}" unless validator.success?
295
+ unless dry.success?
296
+ report = IssueMapper.from(dry, values: config)
297
+ raise InvalidConfig, "Invalid configuration: #{report}"
298
+ end
377
299
 
378
- normalized_headers(validator.to_h)
300
+ normalized_headers(dry.to_h)
379
301
  end
380
302
 
381
303
  def normalized_headers(validated_config)