html2rss 0.29.1 → 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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/html2rss.gemspec +1 -3
  4. data/lib/html2rss/article/deduplicator.rb +2 -4
  5. data/lib/html2rss/article.rb +16 -39
  6. data/lib/html2rss/auto_source/README.md +1 -1
  7. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  8. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  10. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  11. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  13. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  14. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  15. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  16. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  17. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  19. data/lib/html2rss/auto_source/scraper.rb +4 -14
  20. data/lib/html2rss/auto_source/segment.rb +7 -4
  21. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  22. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  23. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  24. data/lib/html2rss/auto_source.rb +2 -2
  25. data/lib/html2rss/batch.rb +6 -4
  26. data/lib/html2rss/capture/README.md +7 -5
  27. data/lib/html2rss/capture.rb +68 -36
  28. data/lib/html2rss/channel.rb +3 -3
  29. data/lib/html2rss/cli/probe_view.rb +5 -3
  30. data/lib/html2rss/cli/render.rb +6 -5
  31. data/lib/html2rss/cli/validate.rb +28 -27
  32. data/lib/html2rss/cli.rb +1 -1
  33. data/lib/html2rss/config/issue_mapper.rb +108 -0
  34. data/lib/html2rss/config/request_headers.rb +6 -4
  35. data/lib/html2rss/config/schema.rb +16 -1
  36. data/lib/html2rss/config/selectors_validator.rb +92 -70
  37. data/lib/html2rss/config/validation_issue.rb +60 -0
  38. data/lib/html2rss/config/validation_report.rb +55 -0
  39. data/lib/html2rss/config/validator.rb +10 -6
  40. data/lib/html2rss/config.rb +35 -101
  41. data/lib/html2rss/defaults.rb +42 -30
  42. data/lib/html2rss/doctor/botasaurus.rb +19 -9
  43. data/lib/html2rss/doctor.rb +8 -0
  44. data/lib/html2rss/error.rb +1 -1
  45. data/lib/html2rss/feed_builder/rss.rb +9 -14
  46. data/lib/html2rss/feed_pipeline/README.md +8 -8
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +1 -1
  48. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  49. data/lib/html2rss/feed_pipeline/strategy_plan.rb +7 -3
  50. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  51. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  52. data/lib/html2rss/feed_resolution/options.rb +3 -1
  53. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  54. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  55. data/lib/html2rss/html/article_extractor.rb +2 -4
  56. data/lib/html2rss/html/feed_link.rb +3 -1
  57. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  58. data/lib/html2rss/html/navigator.rb +10 -10
  59. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  60. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  61. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  62. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  63. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  64. data/lib/html2rss/mcp/README.md +5 -3
  65. data/lib/html2rss/mcp/config_argument.rb +3 -1
  66. data/lib/html2rss/mcp/contract.rb +15 -25
  67. data/lib/html2rss/mcp/outcome/playbook.rb +7 -7
  68. data/lib/html2rss/mcp/outcome.rb +36 -43
  69. data/lib/html2rss/mcp/server/tools.rb +112 -132
  70. data/lib/html2rss/mcp/server.rb +27 -19
  71. data/lib/html2rss/page_recon/README.md +5 -5
  72. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  73. data/lib/html2rss/page_recon.rb +9 -5
  74. data/lib/html2rss/recon.rb +20 -31
  75. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  76. data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
  77. data/lib/html2rss/request_service/compressed_body.rb +17 -9
  78. data/lib/html2rss/request_service/httpx_strategy.rb +239 -0
  79. data/lib/html2rss/request_service/network_guard.rb +7 -5
  80. data/lib/html2rss/request_service/response.rb +1 -2
  81. data/lib/html2rss/request_service/response_guard.rb +0 -16
  82. data/lib/html2rss/request_service/strategy.rb +22 -11
  83. data/lib/html2rss/request_service.rb +46 -85
  84. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  85. data/lib/html2rss/scoring/engine.rb +8 -8
  86. data/lib/html2rss/scoring/observation.rb +5 -3
  87. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  88. data/lib/html2rss/scrape_target.rb +3 -1
  89. data/lib/html2rss/selector_candidates.rb +234 -0
  90. data/lib/html2rss/selectors/README.md +64 -0
  91. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  92. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  93. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  94. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  95. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  96. data/lib/html2rss/selectors/extractors.rb +33 -20
  97. data/lib/html2rss/selectors/item_env.rb +31 -0
  98. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  99. data/lib/html2rss/selectors/option_spec.rb +140 -0
  100. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  101. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  102. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  104. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  105. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  106. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  107. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  108. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  109. data/lib/html2rss/selectors/post_processors.rb +6 -5
  110. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  111. data/lib/html2rss/selectors/step_env.rb +24 -0
  112. data/lib/html2rss/selectors.rb +231 -142
  113. data/lib/html2rss/sst/attrs.rb +5 -3
  114. data/lib/html2rss/sst/document.rb +3 -1
  115. data/lib/html2rss/sst/index.rb +3 -1
  116. data/lib/html2rss/sst/node.rb +29 -10
  117. data/lib/html2rss/sst/normalizer.rb +12 -16
  118. data/lib/html2rss/sst/tags.rb +7 -7
  119. data/lib/html2rss/status.rb +13 -8
  120. data/lib/html2rss/surface_category.rb +14 -32
  121. data/lib/html2rss/syndication/parser.rb +2 -2
  122. data/lib/html2rss/test/enhance_audit.rb +1 -3
  123. data/lib/html2rss/test/policy.rb +106 -0
  124. data/lib/html2rss/test.rb +126 -172
  125. data/lib/html2rss/url.rb +25 -4
  126. data/lib/html2rss/version.rb +1 -1
  127. data/lib/html2rss.rb +27 -19
  128. data/lib/tasks/config_schema.rake +2 -2
  129. data/schema/html2rss-config.schema.json +11 -8
  130. metadata +19 -44
  131. data/lib/html2rss/request_service/faraday_strategy.rb +0 -233
  132. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Config
5
+ ##
6
+ # Maps internal Dry::Validation messages to {ValidationIssue} (predicate → code).
7
+ # Not a public API — Dry stays inside Validator / this mapper.
8
+ module IssueMapper
9
+ # Dry schema predicates → closed {ValidationIssue::CODES}.
10
+ PREDICATE_CODES = {
11
+ key?: :missing_key,
12
+ filled?: :missing_key,
13
+ str?: :type_mismatch,
14
+ int?: :type_mismatch,
15
+ bool?: :type_mismatch,
16
+ hash?: :type_mismatch,
17
+ array?: :type_mismatch,
18
+ type?: :type_mismatch,
19
+ format?: :invalid_value,
20
+ included_in?: :unknown_value,
21
+ gt?: :constraint,
22
+ gteq?: :constraint,
23
+ lt?: :constraint,
24
+ lteq?: :constraint,
25
+ max_size?: :constraint,
26
+ min_size?: :constraint
27
+ }.freeze
28
+
29
+ class << self
30
+ ##
31
+ # @param dry_result [Dry::Validation::Result]
32
+ # @param values [Hash, nil] config under validation (for actual/expected dig)
33
+ # @return [Html2rss::Config::ValidationReport]
34
+ def from(dry_result, values: nil)
35
+ return ValidationReport.ok if dry_result.success?
36
+
37
+ ValidationReport.failure(dry_result.errors.map { |message| issue_for(message, values:) })
38
+ end
39
+
40
+ ##
41
+ # @param message [String]
42
+ # @return [Html2rss::Config::ValidationReport]
43
+ def parse_failure(message)
44
+ ValidationReport.failure(
45
+ [
46
+ ValidationIssue.new(path: %i[parse], code: :parse, message:)
47
+ ]
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ def issue_for(message, values:)
54
+ path = Array(message.path).compact
55
+ ValidationIssue.new(
56
+ path:,
57
+ code: code_for(message.predicate),
58
+ message: message.text,
59
+ expected: expected_for(path, values),
60
+ actual: actual_for(path, values, message)
61
+ )
62
+ end
63
+
64
+ def code_for(predicate)
65
+ return :invalid_value if predicate.nil?
66
+
67
+ PREDICATE_CODES.fetch(predicate) do
68
+ raise ArgumentError, "unmapped Dry validation predicate: #{predicate.inspect}"
69
+ end
70
+ end
71
+
72
+ def actual_for(path, values, message)
73
+ dug = dig_path(values, path)
74
+ return dug unless dug.nil?
75
+ return message.input if message.is_a?(Dry::Schema::Message) && !message.input.nil?
76
+
77
+ nil
78
+ end
79
+
80
+ def expected_for(path, values)
81
+ return if values.nil? || path.empty?
82
+ return unless path.last.is_a?(Symbol)
83
+
84
+ parent = dig_path(values, path[0...-1])
85
+ return unless parent.is_a?(Hash)
86
+
87
+ option = Selectors::OptionSpec.option_for(parent, path.last)
88
+ return unless option
89
+
90
+ {
91
+ type: option.json_type,
92
+ required: option.required
93
+ }
94
+ end
95
+
96
+ def dig_path(values, path)
97
+ return if values.nil? || path.empty?
98
+
99
+ path.reduce(values) do |cursor, segment|
100
+ break unless cursor.is_a?(Hash) || (cursor.is_a?(Array) && segment.is_a?(Integer))
101
+
102
+ cursor[segment]
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -24,7 +24,6 @@ module Html2rss
24
24
  DEFAULT_HEADERS = {
25
25
  'Accept' => DEFAULT_ACCEPT,
26
26
  'Cache-Control' => 'max-age=0',
27
- 'Connection' => 'keep-alive',
28
27
  'Sec-Fetch-Dest' => 'document',
29
28
  'Sec-Fetch-Mode' => 'navigate',
30
29
  'Sec-Fetch-Site' => 'none',
@@ -33,16 +32,18 @@ module Html2rss
33
32
  'User-Agent' => DEFAULT_USER_AGENT
34
33
  }.freeze
35
34
 
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 = Set['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade'].freeze
37
+
36
38
  class << self
37
39
  ##
38
- # :reek:ManualDispatch
39
40
  # :reek:TooManyStatements
40
41
  #
41
42
  # @return [Hash{String => String}] the default header set merged with global defaults
42
43
  def browser_defaults
43
44
  defaults = DEFAULT_HEADERS.dup
44
45
  global_headers = Html2rss.defaults.headers
45
- global_headers = global_headers.call if global_headers.respond_to?(:call)
46
+ global_headers = global_headers.call if global_headers.is_a?(Proc)
46
47
 
47
48
  if global_headers.is_a?(Hash)
48
49
  global_headers.each do |key, value|
@@ -75,7 +76,7 @@ module Html2rss
75
76
  ##
76
77
  # @return [Hash{String => String}] normalized HTTP headers
77
78
  def to_h
78
- defaults = DEFAULT_HEADERS.dup
79
+ defaults = self.class.browser_defaults
79
80
  normalized = normalize_custom_headers(headers)
80
81
 
81
82
  accept_override = normalized.delete('Accept')
@@ -84,6 +85,7 @@ module Html2rss
84
85
  defaults['Accept'] = normalize_accept(accept_override)
85
86
  defaults['Accept-Language'] = build_accept_language
86
87
 
88
+ defaults.reject! { |key, _| FORBIDDEN_H2_HEADERS.include?(key.downcase) }
87
89
  defaults.compact
88
90
  end
89
91
 
@@ -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