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.
- checksums.yaml +4 -4
- data/README.md +8 -8
- data/html2rss.gemspec +1 -3
- 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 +6 -4
- data/lib/html2rss/capture/README.md +7 -5
- data/lib/html2rss/capture.rb +68 -36
- 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 +6 -4
- 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 -101
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +19 -9
- 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/README.md +8 -8
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +1 -1
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +7 -3
- 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 +5 -3
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +15 -25
- data/lib/html2rss/mcp/outcome/playbook.rb +7 -7
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +112 -132
- data/lib/html2rss/mcp/server.rb +27 -19
- 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 +20 -31
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
- data/lib/html2rss/request_service/compressed_body.rb +17 -9
- data/lib/html2rss/request_service/httpx_strategy.rb +239 -0
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/response_guard.rb +0 -16
- data/lib/html2rss/request_service/strategy.rb +22 -11
- data/lib/html2rss/request_service.rb +46 -85
- 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 -172
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +27 -19
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +19 -44
- data/lib/html2rss/request_service/faraday_strategy.rb +0 -233
- 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.
|
|
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 =
|
|
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).
|
|
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
|