html2rss 0.30.0 → 0.32.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 +6 -3
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +34 -7
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_document.rb +135 -0
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +74 -11
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +4 -8
- 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 +12 -17
- 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 +58 -3
- 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 +6 -2
- data/lib/html2rss/config/auto_source_contract.rb +3 -0
- 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 +44 -114
- 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/auto_fallback.rb +11 -2
- 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/feed_resolution.rb +30 -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 +7 -21
- 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 +6 -4
- data/lib/html2rss/request_service/strategy.rb +3 -8
- data/lib/html2rss/request_service.rb +11 -2
- 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 +32 -19
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +26 -8
- metadata +16 -6
- data/lib/html2rss/selectors/item_scope.rb +0 -30
|
@@ -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
|
|
|
@@ -190,10 +125,18 @@ module Html2rss
|
|
|
190
125
|
# @param items_selector [String, nil] optional selector hint for item extraction
|
|
191
126
|
# @param request_controls [Html2rss::Config::RequestControls, nil] explicit request controls to write
|
|
192
127
|
# @param limit [Integer, nil] max articles to keep in the auto-sourced feed
|
|
128
|
+
# @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
|
|
193
129
|
# @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
|
|
194
|
-
|
|
130
|
+
# rubocop:disable-next Metrics/MethodLength -- keep_different_domain override stays co-located
|
|
131
|
+
def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil,
|
|
132
|
+
keep_different_domain: nil)
|
|
195
133
|
auto_source = AutoSource::DEFAULT_CONFIG
|
|
196
134
|
auto_source = auto_source.merge(limit:) unless limit.nil?
|
|
135
|
+
unless keep_different_domain.nil?
|
|
136
|
+
auto_source = auto_source.merge(
|
|
137
|
+
cleanup: auto_source[:cleanup].merge(keep_different_domain:)
|
|
138
|
+
)
|
|
139
|
+
end
|
|
197
140
|
|
|
198
141
|
config = {
|
|
199
142
|
channel: default_config[:channel].merge(url:),
|
|
@@ -226,6 +169,20 @@ module Html2rss
|
|
|
226
169
|
Html2rss.defaults.default_strategy || :auto
|
|
227
170
|
end
|
|
228
171
|
|
|
172
|
+
##
|
|
173
|
+
# Normalizes raw config input with defaults.
|
|
174
|
+
#
|
|
175
|
+
# @param config [Hash{Symbol => Object}] raw config input
|
|
176
|
+
# @return [Hash{Symbol => Object}] config with defaults applied
|
|
177
|
+
def prepare_defaults(config)
|
|
178
|
+
config = HashUtil.deep_merge(default_config, config)
|
|
179
|
+
config = HashUtil.deep_merge({ selectors: Selectors::DEFAULT_CONFIG }, config) if config[:selectors]
|
|
180
|
+
if config[:auto_source]
|
|
181
|
+
config = HashUtil.deep_merge({ auto_source: Html2rss::AutoSource::DEFAULT_CONFIG }, config)
|
|
182
|
+
end
|
|
183
|
+
config
|
|
184
|
+
end
|
|
185
|
+
|
|
229
186
|
private
|
|
230
187
|
|
|
231
188
|
def default_request_config
|
|
@@ -239,7 +196,7 @@ module Html2rss
|
|
|
239
196
|
def resolve_effective_config(config, params:)
|
|
240
197
|
cfg = HashUtil.deep_symbolize_keys(config, context: 'config')
|
|
241
198
|
p = parameter_defaults(cfg)
|
|
242
|
-
p.merge!(HashUtil.deep_symbolize_keys(params, context: 'params')) unless params.
|
|
199
|
+
p.merge!(HashUtil.deep_symbolize_keys(params, context: 'params')) unless params.nil?
|
|
243
200
|
|
|
244
201
|
cfg[:headers] = DynamicParams.call(cfg[:headers], p) if cfg[:headers]
|
|
245
202
|
cfg[:channel] = DynamicParams.call(cfg[:channel], p) if cfg[:channel]
|
|
@@ -255,7 +212,7 @@ module Html2rss
|
|
|
255
212
|
end
|
|
256
213
|
|
|
257
214
|
def prepare_for_validation(config)
|
|
258
|
-
|
|
215
|
+
prepare_defaults(HashUtil.deep_dup(config))
|
|
259
216
|
end
|
|
260
217
|
|
|
261
218
|
# @param config_input [Hash, String]
|
|
@@ -282,7 +239,7 @@ module Html2rss
|
|
|
282
239
|
# @raise [InvalidConfig] if the configuration fails validation.
|
|
283
240
|
def initialize(config)
|
|
284
241
|
@request_controls = RequestControls.from_config(config)
|
|
285
|
-
prepared_config =
|
|
242
|
+
prepared_config = self.class.prepare_defaults(config.dup)
|
|
286
243
|
validated_config = validated_config_for(prepared_config)
|
|
287
244
|
|
|
288
245
|
@config = validated_config.freeze
|
|
@@ -340,42 +297,15 @@ module Html2rss
|
|
|
340
297
|
|
|
341
298
|
attr_reader :config
|
|
342
299
|
|
|
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
300
|
def validated_config_for(config)
|
|
374
|
-
|
|
301
|
+
dry = Validator.new.call(config)
|
|
375
302
|
|
|
376
|
-
|
|
303
|
+
unless dry.success?
|
|
304
|
+
report = IssueMapper.from(dry, values: config)
|
|
305
|
+
raise InvalidConfig, "Invalid configuration: #{report}"
|
|
306
|
+
end
|
|
377
307
|
|
|
378
|
-
normalized_headers(
|
|
308
|
+
normalized_headers(dry.to_h)
|
|
379
309
|
end
|
|
380
310
|
|
|
381
311
|
def normalized_headers(validated_config)
|