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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +1 -1
- data/lib/html2rss/auto_source/cleanup.rb +15 -6
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -14
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +2 -2
- data/lib/html2rss/batch.rb +3 -1
- data/lib/html2rss/capture/README.md +6 -4
- data/lib/html2rss/capture.rb +67 -35
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +1 -1
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +2 -3
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +35 -113
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +6 -4
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +2 -0
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +6 -20
- data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +111 -131
- data/lib/html2rss/mcp/server.rb +26 -18
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +19 -30
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/compressed_body.rb +4 -1
- data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/strategy.rb +3 -8
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -173
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +21 -13
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +15 -6
- 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 =
|
|
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.
|
|
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).
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
100
|
-
next
|
|
101
|
-
next if values[
|
|
109
|
+
Selectors::OptionSpec.for(klass).each do |spec|
|
|
110
|
+
next unless spec.required
|
|
111
|
+
next if values[spec.name]
|
|
102
112
|
|
|
103
|
-
key(
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
193
|
+
collect_contract_errors(Items, selector, selector_key, errors)
|
|
176
194
|
when :enclosure
|
|
177
|
-
Enclosure
|
|
195
|
+
collect_contract_errors(Enclosure, selector, selector_key, errors)
|
|
178
196
|
when :guid, :categories
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 =
|
|
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
|
-
|
|
138
|
-
|
|
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
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -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,
|
|
25
|
+
# @return [Array(Hash, Html2rss::Config::ValidationReport)]
|
|
71
26
|
def resolve_and_validate(config_input, feed_name: nil, params: {})
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
[
|
|
75
|
-
|
|
76
|
-
|
|
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 [
|
|
85
|
-
def validate(config, params:
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
293
|
+
dry = Validator.new.call(config)
|
|
375
294
|
|
|
376
|
-
|
|
295
|
+
unless dry.success?
|
|
296
|
+
report = IssueMapper.from(dry, values: config)
|
|
297
|
+
raise InvalidConfig, "Invalid configuration: #{report}"
|
|
298
|
+
end
|
|
377
299
|
|
|
378
|
-
normalized_headers(
|
|
300
|
+
normalized_headers(dry.to_h)
|
|
379
301
|
end
|
|
380
302
|
|
|
381
303
|
def normalized_headers(validated_config)
|