html2rss 0.24.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
data/lib/html2rss/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'yaml'
|
|
5
6
|
require 'thor'
|
|
6
7
|
|
|
7
8
|
module Html2rss
|
|
@@ -79,6 +80,9 @@ module Html2rss
|
|
|
79
80
|
method_option :max_requests,
|
|
80
81
|
type: :numeric,
|
|
81
82
|
desc: 'Maximum requests to allow for this feed build'
|
|
83
|
+
method_option :limit,
|
|
84
|
+
type: :numeric,
|
|
85
|
+
desc: 'Maximum number of articles to keep (default: 25)'
|
|
82
86
|
method_option :input,
|
|
83
87
|
type: :string,
|
|
84
88
|
desc: 'Local HTML file path to read input from'
|
|
@@ -95,6 +99,43 @@ module Html2rss
|
|
|
95
99
|
puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
|
|
96
100
|
end
|
|
97
101
|
|
|
102
|
+
desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
|
|
103
|
+
method_option :strategy,
|
|
104
|
+
type: :string,
|
|
105
|
+
desc: STRATEGY_OPTION_DESC,
|
|
106
|
+
enum: STRATEGY_OPTION_ENUM
|
|
107
|
+
method_option :items_selector, type: :string, desc: 'CSS selector hint for items (optional)'
|
|
108
|
+
method_option :max_redirects,
|
|
109
|
+
type: :numeric,
|
|
110
|
+
desc: 'Maximum redirects to follow per request'
|
|
111
|
+
method_option :max_requests,
|
|
112
|
+
type: :numeric,
|
|
113
|
+
desc: 'Maximum requests to allow for this feed build'
|
|
114
|
+
method_option :limit,
|
|
115
|
+
type: :numeric,
|
|
116
|
+
desc: 'Maximum number of articles to keep (default: 25)'
|
|
117
|
+
method_option :input,
|
|
118
|
+
type: :string,
|
|
119
|
+
desc: 'Local HTML file path to read input from'
|
|
120
|
+
##
|
|
121
|
+
# Captures a URL and prints a reusable YAML config.
|
|
122
|
+
#
|
|
123
|
+
# @param url [String, nil] source page URL for capture
|
|
124
|
+
# @return [void]
|
|
125
|
+
def capture(url = nil) # rubocop:disable Metrics/MethodLength
|
|
126
|
+
strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
|
|
127
|
+
config = Html2rss.capture(
|
|
128
|
+
url,
|
|
129
|
+
strategy:,
|
|
130
|
+
items_selector: options[:items_selector],
|
|
131
|
+
limit: options[:limit]&.to_i,
|
|
132
|
+
max_redirects: options[:max_redirects],
|
|
133
|
+
max_requests: options[:max_requests],
|
|
134
|
+
local_file_path:
|
|
135
|
+
)
|
|
136
|
+
puts YAML.dump(HashUtil.deep_stringify_keys(config))
|
|
137
|
+
end
|
|
138
|
+
|
|
98
139
|
desc 'schema', 'Print the exported config JSON Schema'
|
|
99
140
|
method_option :pretty,
|
|
100
141
|
type: :boolean,
|
|
@@ -120,6 +161,27 @@ module Html2rss
|
|
|
120
161
|
puts schema_json
|
|
121
162
|
end
|
|
122
163
|
|
|
164
|
+
desc 'mcp', 'Start the MCP server for AI client consumption'
|
|
165
|
+
method_option :transport,
|
|
166
|
+
type: :string,
|
|
167
|
+
desc: 'MCP transport protocol',
|
|
168
|
+
enum: %w[stdio http],
|
|
169
|
+
default: 'stdio'
|
|
170
|
+
method_option :port,
|
|
171
|
+
type: :numeric,
|
|
172
|
+
desc: 'Port for HTTP transport (binds 127.0.0.1)',
|
|
173
|
+
default: 8080
|
|
174
|
+
##
|
|
175
|
+
# Starts the MCP server for AI client consumption.
|
|
176
|
+
#
|
|
177
|
+
# @return [void]
|
|
178
|
+
def mcp
|
|
179
|
+
Html2rss::MCP.start(
|
|
180
|
+
transport: options[:transport].to_sym,
|
|
181
|
+
port: options[:port]
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
123
185
|
desc 'validate YAML_FILE [feed_name]', 'Validate a YAML config with the runtime validator'
|
|
124
186
|
method_option :params,
|
|
125
187
|
type: :hash,
|
|
@@ -180,29 +242,20 @@ module Html2rss
|
|
|
180
242
|
|
|
181
243
|
file_path = check_file_exists!(input_option)
|
|
182
244
|
detected_url = url || detect_base_url!(
|
|
183
|
-
file_path, 'Please specify a URL: html2rss
|
|
245
|
+
file_path, 'Please specify a URL: html2rss <command> [URL] --input <file>'
|
|
184
246
|
)
|
|
185
247
|
|
|
186
248
|
[:local_file, file_path, detected_url]
|
|
187
249
|
end
|
|
188
250
|
|
|
189
251
|
def request_controls
|
|
190
|
-
Html2rss::Config::RequestControls.
|
|
191
|
-
strategy: options[:strategy]
|
|
252
|
+
Html2rss::Config::RequestControls.from_cli_options(
|
|
253
|
+
strategy: options[:strategy],
|
|
192
254
|
max_redirects: options[:max_redirects],
|
|
193
|
-
max_requests: options[:max_requests]
|
|
194
|
-
explicit_keys: explicit_request_control_keys
|
|
255
|
+
max_requests: options[:max_requests]
|
|
195
256
|
)
|
|
196
257
|
end
|
|
197
258
|
|
|
198
|
-
def explicit_request_control_keys
|
|
199
|
-
keys = []
|
|
200
|
-
keys << :strategy if options[:strategy]
|
|
201
|
-
keys << :max_redirects unless options[:max_redirects].nil?
|
|
202
|
-
keys << :max_requests unless options[:max_requests].nil?
|
|
203
|
-
keys
|
|
204
|
-
end
|
|
205
|
-
|
|
206
259
|
def current_strategy
|
|
207
260
|
options[:strategy]&.to_sym || :auto
|
|
208
261
|
end
|
|
@@ -227,15 +280,15 @@ module Html2rss
|
|
|
227
280
|
yield
|
|
228
281
|
rescue Faraday::FollowRedirects::RedirectLimitReached => error
|
|
229
282
|
raise Thor::Error,
|
|
230
|
-
"#{error.message}.
|
|
283
|
+
"#{error.message}. already retried the last redirect hop once; " \
|
|
284
|
+
"retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
|
|
231
285
|
rescue Html2rss::RequestService::RequestBudgetExceeded => error
|
|
232
286
|
raise Thor::Error,
|
|
233
287
|
"#{error.message}. retry with --max-requests #{suggested_max_requests} " \
|
|
234
288
|
'or increase request.max_requests in the config.'
|
|
235
|
-
rescue Html2rss::RequestService::
|
|
236
|
-
Html2rss::RequestService::BrowserlessConnectionFailed,
|
|
237
|
-
Html2rss::RequestService::BotasaurusConfigurationError,
|
|
289
|
+
rescue Html2rss::RequestService::BotasaurusConfigurationError,
|
|
238
290
|
Html2rss::RequestService::BotasaurusConnectionFailed,
|
|
291
|
+
Html2rss::RequestService::BotasaurusServiceError,
|
|
239
292
|
Html2rss::RequestService::BlockedSurfaceDetected,
|
|
240
293
|
Html2rss::NoFeedItemsExtracted => error
|
|
241
294
|
raise Thor::Error, error.message
|
|
@@ -249,7 +302,8 @@ module Html2rss
|
|
|
249
302
|
items_selector: options[:items_selector],
|
|
250
303
|
max_redirects: options[:max_redirects],
|
|
251
304
|
max_requests: options[:max_requests],
|
|
252
|
-
local_file_path
|
|
305
|
+
local_file_path:,
|
|
306
|
+
limit: options[:limit]&.to_i
|
|
253
307
|
)
|
|
254
308
|
end
|
|
255
309
|
|
|
@@ -6,6 +6,8 @@ module Html2rss
|
|
|
6
6
|
class Config
|
|
7
7
|
# Runtime source of truth for validating auto-source config values.
|
|
8
8
|
AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
|
|
9
|
+
optional(:limit).filled(:integer, gt?: 0)
|
|
10
|
+
|
|
9
11
|
optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
|
|
10
12
|
optional(:wordpress_api).hash do
|
|
11
13
|
optional(:enabled).filled(:bool)
|
|
@@ -27,6 +29,9 @@ module Html2rss
|
|
|
27
29
|
optional(:json_state).hash do
|
|
28
30
|
optional(:enabled).filled(:bool)
|
|
29
31
|
end
|
|
32
|
+
optional(:xhr_articles).hash do
|
|
33
|
+
optional(:enabled).filled(:bool)
|
|
34
|
+
end
|
|
30
35
|
optional(:meta_oembed).hash do
|
|
31
36
|
optional(:enabled).filled(:bool)
|
|
32
37
|
end
|
|
@@ -44,7 +49,6 @@ module Html2rss
|
|
|
44
49
|
|
|
45
50
|
optional(:cleanup).hash do
|
|
46
51
|
optional(:keep_different_domain).filled(:bool)
|
|
47
|
-
optional(:min_words_title).filled(:integer, gt?: 0)
|
|
48
52
|
end
|
|
49
53
|
end
|
|
50
54
|
end
|
|
@@ -43,6 +43,39 @@ module Html2rss
|
|
|
43
43
|
request_config.is_a?(Hash) && request_config.key?(key)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
##
|
|
47
|
+
# Builds controls for public API shortcuts ({Html2rss.auto_source}, etc.).
|
|
48
|
+
# Strategy is explicit only when non-nil and not the configured default.
|
|
49
|
+
#
|
|
50
|
+
# @param strategy [Symbol, nil]
|
|
51
|
+
# @param max_redirects [Integer, nil]
|
|
52
|
+
# @param max_requests [Integer, nil]
|
|
53
|
+
# @return [RequestControls]
|
|
54
|
+
def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil)
|
|
55
|
+
keys = []
|
|
56
|
+
keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
|
|
57
|
+
keys << :max_redirects unless max_redirects.nil?
|
|
58
|
+
keys << :max_requests unless max_requests.nil?
|
|
59
|
+
new(strategy:, max_redirects:, max_requests:, explicit_keys: keys)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# Builds controls from CLI option values. Strategy is explicit when the
|
|
64
|
+
# option was provided (truthy string/symbol), matching Thor option presence.
|
|
65
|
+
#
|
|
66
|
+
# @param strategy [Symbol, String, nil]
|
|
67
|
+
# @param max_redirects [Integer, nil]
|
|
68
|
+
# @param max_requests [Integer, nil]
|
|
69
|
+
# @return [RequestControls]
|
|
70
|
+
def self.from_cli_options(strategy:, max_redirects: nil, max_requests: nil)
|
|
71
|
+
strategy_sym = strategy&.to_sym
|
|
72
|
+
keys = []
|
|
73
|
+
keys << :strategy if strategy
|
|
74
|
+
keys << :max_redirects unless max_redirects.nil?
|
|
75
|
+
keys << :max_requests unless max_requests.nil?
|
|
76
|
+
new(strategy: strategy_sym, max_redirects:, max_requests:, explicit_keys: keys)
|
|
77
|
+
end
|
|
78
|
+
|
|
46
79
|
private_class_method :explicit_keys_for, :request_value_for, :request_key?
|
|
47
80
|
|
|
48
81
|
##
|
|
@@ -48,6 +48,7 @@ module Html2rss
|
|
|
48
48
|
##
|
|
49
49
|
# Orchestrates schema assembly from runtime validator contracts plus
|
|
50
50
|
# client-facing overlays.
|
|
51
|
+
# rubocop:disable Metrics/ClassLength -- overlay assembly stays in one builder
|
|
51
52
|
class Builder
|
|
52
53
|
class << self
|
|
53
54
|
# @return [Hash{String => Object}] fully assembled JSON schema hash
|
|
@@ -60,9 +61,10 @@ module Html2rss
|
|
|
60
61
|
def call
|
|
61
62
|
schema = validator_schema
|
|
62
63
|
apply_top_level(schema)
|
|
64
|
+
schema[:$defs] = registry_catalog_defs
|
|
63
65
|
schema.fetch(:properties).merge!(overlay)
|
|
64
66
|
schema.fetch(:properties).delete(:dynamic_params_error)
|
|
65
|
-
|
|
67
|
+
HashUtil.deep_stringify_keys(schema)
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
private
|
|
@@ -77,6 +79,23 @@ module Html2rss
|
|
|
77
79
|
{ 'required' => ['selectors'] },
|
|
78
80
|
{ 'required' => ['auto_source'] }
|
|
79
81
|
]
|
|
82
|
+
|
|
83
|
+
topics = schema.dig(:properties, :directory, :properties, :topics)
|
|
84
|
+
topics[:minItems] = 1 if topics.is_a?(Hash)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
|
|
88
|
+
def registry_catalog_defs
|
|
89
|
+
{
|
|
90
|
+
post_processors: catalog_from_registry(Selectors::PostProcessors::NAME_TO_CLASS),
|
|
91
|
+
extractors: catalog_from_registry(Selectors::Extractors::NAME_TO_CLASS)
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param registry [Hash{Symbol => Class}]
|
|
96
|
+
# @return [Hash{String => Hash}]
|
|
97
|
+
def catalog_from_registry(registry)
|
|
98
|
+
registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
|
|
80
99
|
end
|
|
81
100
|
|
|
82
101
|
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
|
|
@@ -105,6 +124,13 @@ module Html2rss
|
|
|
105
124
|
]
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
selector_schema = Schema.apply_selector_registry_refs!(
|
|
128
|
+
Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true)
|
|
129
|
+
)
|
|
130
|
+
enclosure_schema = Schema.apply_selector_registry_refs!(
|
|
131
|
+
Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true)
|
|
132
|
+
)
|
|
133
|
+
|
|
108
134
|
{
|
|
109
135
|
strategy: {
|
|
110
136
|
type: 'string',
|
|
@@ -121,7 +147,7 @@ module Html2rss
|
|
|
121
147
|
items: Html2rss::Config::Validator::StylesheetConfig.json_schema(loose: true)
|
|
122
148
|
},
|
|
123
149
|
auto_source: Html2rss::Config::AutoSourceContract.json_schema(loose: true).merge(
|
|
124
|
-
default:
|
|
150
|
+
default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG)
|
|
125
151
|
),
|
|
126
152
|
selectors: {
|
|
127
153
|
type: 'object',
|
|
@@ -130,14 +156,14 @@ module Html2rss
|
|
|
130
156
|
items: items_schema.merge(
|
|
131
157
|
description: 'Defines the items selector and optional enhancement settings.'
|
|
132
158
|
),
|
|
133
|
-
enclosure:
|
|
159
|
+
enclosure: enclosure_schema.merge(
|
|
134
160
|
description: 'Describes enclosure extraction settings.'
|
|
135
161
|
),
|
|
136
162
|
guid: reference_array('List of selector keys used to build the GUID. Each entry must reference a sibling selector key; runtime validation enforces those references.'),
|
|
137
163
|
categories: reference_array('List of selector keys whose values will be used as categories. Each entry must reference a sibling selector key; runtime validation enforces those references.')
|
|
138
164
|
},
|
|
139
165
|
patternProperties: {
|
|
140
|
-
'^(?!items$|enclosure$|guid$|categories$).+$' =>
|
|
166
|
+
'^(?!items$|enclosure$|guid$|categories$).+$' => selector_schema.merge(
|
|
141
167
|
description: 'Dynamic selector definition keyed by attribute name.'
|
|
142
168
|
)
|
|
143
169
|
},
|
|
@@ -159,31 +185,46 @@ module Html2rss
|
|
|
159
185
|
}
|
|
160
186
|
end
|
|
161
187
|
end
|
|
188
|
+
# rubocop:enable Metrics/ClassLength
|
|
162
189
|
|
|
163
190
|
##
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
191
|
+
# Wires extractor / post_process to thin oneOf $refs into the registry catalog.
|
|
192
|
+
#
|
|
193
|
+
# @param schema [Hash] dry-schema JSON schema fragment for a selector
|
|
194
|
+
# @return [Hash] same schema with registry $ref wiring applied
|
|
195
|
+
def apply_selector_registry_refs!(schema)
|
|
196
|
+
properties = schema.fetch(:properties)
|
|
197
|
+
properties[:extractor] = extractor_wire_schema
|
|
198
|
+
properties[:post_process] = post_process_wire_schema
|
|
199
|
+
schema
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @return [Hash{Symbol => Object}]
|
|
203
|
+
def extractor_wire_schema
|
|
204
|
+
{
|
|
205
|
+
description: 'Extractor used to pull a value from the selected element.',
|
|
206
|
+
oneOf: registry_ref_list('extractors', Selectors::Extractors::NAME_TO_CLASS)
|
|
207
|
+
}
|
|
208
|
+
end
|
|
182
209
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
210
|
+
# @return [Hash{Symbol => Object}]
|
|
211
|
+
def post_process_wire_schema
|
|
212
|
+
{
|
|
213
|
+
type: 'array',
|
|
214
|
+
description: 'Ordered transforms applied to the extracted value.',
|
|
215
|
+
items: {
|
|
216
|
+
oneOf: registry_ref_list('post_processors', Selectors::PostProcessors::NAME_TO_CLASS)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
##
|
|
222
|
+
# @param catalog [String] $defs catalog name (`extractors` or `post_processors`)
|
|
223
|
+
# @param registry [Hash{Symbol => Class}]
|
|
224
|
+
# @return [Array<Hash>] oneOf entries of `{ '$ref' => ... }`
|
|
225
|
+
def registry_ref_list(catalog, registry)
|
|
226
|
+
registry.keys.sort.map do |name|
|
|
227
|
+
{ '$ref' => "#/$defs/#{catalog}/#{name}" }
|
|
187
228
|
end
|
|
188
229
|
end
|
|
189
230
|
end
|
|
@@ -75,6 +75,9 @@ module Html2rss
|
|
|
75
75
|
##
|
|
76
76
|
# Validates the configuration of a single selector.
|
|
77
77
|
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
|
+
|
|
78
81
|
params do
|
|
79
82
|
optional(:selector)
|
|
80
83
|
optional(:extractor).filled(:string)
|
|
@@ -88,33 +91,69 @@ module Html2rss
|
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
rule(:extractor) do
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
next unless value
|
|
95
|
+
|
|
96
|
+
klass = Selectors::Extractors::NAME_TO_CLASS[value.to_sym]
|
|
97
|
+
next key(:extractor).failure("unknown extractor: #{value}") unless klass
|
|
98
|
+
|
|
99
|
+
klass::Options.members.each do |field|
|
|
100
|
+
next if RUNTIME_EXTRACTOR_FIELDS.include?(field)
|
|
101
|
+
next if values[field]
|
|
102
|
+
|
|
103
|
+
key(field).failure("`#{field}` is required for extractor `#{value}`")
|
|
97
104
|
end
|
|
98
105
|
end
|
|
99
106
|
|
|
100
107
|
rule(:post_process).each do
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
key(
|
|
110
|
-
when 'html_to_markdown', 'markdown_to_html', 'parse_time', 'parse_uri', 'sanitize_html'
|
|
111
|
-
# nothing to validate
|
|
112
|
-
when nil
|
|
113
|
-
key(:post_process).failure('Missing post_processor `name`')
|
|
114
|
-
else
|
|
115
|
-
key(:post_process).failure("Unknown post_processor `name`: #{name}")
|
|
108
|
+
name = value[:name]
|
|
109
|
+
next key(:post_process).failure('Missing post_processor `name`') if name.nil?
|
|
110
|
+
|
|
111
|
+
klass = Selectors::PostProcessors::NAME_TO_CLASS[name.to_sym]
|
|
112
|
+
next key(:post_process).failure("Unknown post_processor `name`: #{name}") unless klass
|
|
113
|
+
next unless klass.const_defined?(:Options)
|
|
114
|
+
|
|
115
|
+
post_process_option_type_errors(klass, value).each do |field, message|
|
|
116
|
+
key(field).failure(message)
|
|
116
117
|
end
|
|
117
118
|
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
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)]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def optional_option_type_errors(klass, value)
|
|
138
|
+
return [] unless klass.const_defined?(:OPTIONAL_OPTION_TYPES)
|
|
139
|
+
|
|
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
|
|
147
|
+
|
|
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}"
|
|
156
|
+
end
|
|
118
157
|
end
|
|
119
158
|
|
|
120
159
|
##
|
|
@@ -15,6 +15,11 @@ module Html2rss
|
|
|
15
15
|
LANGUAGE_FORMAT_REGEX = /\A[a-z]{2}(-[A-Z]{2})?\z/
|
|
16
16
|
# Baseline strategy-plan enum (:auto plus concrete RequestService strategies).
|
|
17
17
|
BASE_STRATEGY_OPTIONS = Html2rss::FeedPipeline::StrategyPlan.accepted_names.freeze
|
|
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
|
+
].freeze
|
|
18
23
|
|
|
19
24
|
# Contract for the top-level `channel` section.
|
|
20
25
|
ChannelConfig = Dry::Schema.Params do
|
|
@@ -24,6 +29,13 @@ module Html2rss
|
|
|
24
29
|
optional(:language).maybe(:string, format?: LANGUAGE_FORMAT_REGEX)
|
|
25
30
|
optional(:ttl).maybe(:integer, gt?: 0)
|
|
26
31
|
optional(:time_zone).maybe(:string)
|
|
32
|
+
optional(:author).maybe(:string)
|
|
33
|
+
optional(:image).maybe(:string, format?: URI_REGEXP)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Contract for catalog-only `directory` metadata (topics for feed directories).
|
|
37
|
+
DirectoryConfig = Dry::Schema.Params do
|
|
38
|
+
optional(:topics).value(:array, min_size?: 1).each(:string, included_in?: DIRECTORY_TOPICS)
|
|
27
39
|
end
|
|
28
40
|
|
|
29
41
|
# Contract for a stylesheet entry in `stylesheets`.
|
|
@@ -33,47 +45,28 @@ module Html2rss
|
|
|
33
45
|
optional(:media).maybe(:string)
|
|
34
46
|
end
|
|
35
47
|
|
|
36
|
-
# Contract for Browserless click-preload options.
|
|
37
|
-
BrowserlessPreloadClickSelectorConfig = Dry::Schema.Params do
|
|
38
|
-
required(:selector).filled(:string)
|
|
39
|
-
optional(:max_clicks).filled(:integer, gt?: 0)
|
|
40
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Contract for Browserless scroll-preload options.
|
|
44
|
-
BrowserlessPreloadScrollConfig = Dry::Schema.Params do
|
|
45
|
-
optional(:iterations).filled(:integer, gt?: 0)
|
|
46
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Contract for Browserless preload orchestration options.
|
|
50
|
-
BrowserlessPreloadConfig = Dry::Schema.Params do
|
|
51
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
52
|
-
optional(:click_selectors).array(BrowserlessPreloadClickSelectorConfig)
|
|
53
|
-
optional(:scroll_down).hash(BrowserlessPreloadScrollConfig)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Contract for Browserless-specific request options.
|
|
57
|
-
BrowserlessRequestConfig = Dry::Schema.Params do
|
|
58
|
-
optional(:preload).hash(BrowserlessPreloadConfig)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
48
|
# Contract for Botasaurus-specific request options.
|
|
62
49
|
BotasaurusRequestConfig = Dry::Schema.Params do
|
|
63
50
|
config.validate_keys = true
|
|
64
51
|
|
|
65
|
-
optional(:
|
|
52
|
+
optional(:execution_mode).filled(:string, included_in?: %w[auto request browser])
|
|
53
|
+
optional(:navigation_mode).filled(:string, included_in?: %w[auto get google_get google_get_bypass organic_get])
|
|
66
54
|
optional(:max_retries).filled(:integer, gteq?: 0, lteq?: 3)
|
|
67
55
|
optional(:wait_for_selector).maybe(:string)
|
|
68
56
|
optional(:wait_timeout_seconds).filled(:integer, gt?: 0)
|
|
57
|
+
optional(:scroll).filled(:bool)
|
|
58
|
+
optional(:scroll_to_bottom).filled(:bool)
|
|
69
59
|
optional(:block_images).filled(:bool)
|
|
70
60
|
optional(:block_images_and_css).filled(:bool)
|
|
61
|
+
optional(:block_trackers).filled(:bool)
|
|
71
62
|
optional(:wait_for_complete_page_load).filled(:bool)
|
|
72
63
|
optional(:headless).filled(:bool)
|
|
73
64
|
optional(:proxy).filled(:string)
|
|
74
65
|
optional(:user_agent).filled(:string)
|
|
75
66
|
optional(:window_size).value(:array, min_size?: 2, max_size?: 2).each(:integer, gt?: 0)
|
|
76
67
|
optional(:lang).filled(:string)
|
|
68
|
+
optional(:cookies).hash
|
|
69
|
+
optional(:headers).hash
|
|
77
70
|
end
|
|
78
71
|
|
|
79
72
|
# Contract for the top-level `request` section.
|
|
@@ -81,7 +74,6 @@ module Html2rss
|
|
|
81
74
|
optional(:max_redirects).filled(:integer, gteq?: 0)
|
|
82
75
|
optional(:max_requests).filled(:integer, gt?: 0)
|
|
83
76
|
optional(:total_timeout_seconds).filled(:integer, gt?: 0)
|
|
84
|
-
optional(:browserless).hash(BrowserlessRequestConfig)
|
|
85
77
|
optional(:botasaurus).hash(BotasaurusRequestConfig)
|
|
86
78
|
optional(:local_file_path).filled(:string)
|
|
87
79
|
end
|
|
@@ -89,6 +81,7 @@ module Html2rss
|
|
|
89
81
|
params do
|
|
90
82
|
optional(:strategy).filled(:symbol)
|
|
91
83
|
required(:channel).hash(ChannelConfig)
|
|
84
|
+
optional(:directory).hash(DirectoryConfig)
|
|
92
85
|
optional(:headers).hash
|
|
93
86
|
optional(:stylesheets).array(StylesheetConfig)
|
|
94
87
|
optional(:auto_source).hash(Config::AutoSourceContract)
|
|
@@ -132,15 +125,20 @@ module Html2rss
|
|
|
132
125
|
|
|
133
126
|
# URL validation delegated to Url class
|
|
134
127
|
rule(:channel) do
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
128
|
+
if (url_string = values.dig(:channel, :url)) && !url_string.empty?
|
|
129
|
+
begin
|
|
130
|
+
Html2rss::Url.for_channel(url_string)
|
|
131
|
+
rescue ArgumentError => error
|
|
132
|
+
key(%i[channel url]).failure(error.message)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
139
135
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
if (image_string = values.dig(:channel, :image)) && !image_string.empty?
|
|
137
|
+
begin
|
|
138
|
+
Html2rss::Url.from_absolute(image_string)
|
|
139
|
+
rescue ArgumentError => error
|
|
140
|
+
key(%i[channel image]).failure(error.message)
|
|
141
|
+
end
|
|
144
142
|
end
|
|
145
143
|
end
|
|
146
144
|
end
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -120,11 +120,15 @@ module Html2rss
|
|
|
120
120
|
# @param url [String] source page URL
|
|
121
121
|
# @param items_selector [String, nil] optional selector hint for item extraction
|
|
122
122
|
# @param request_controls [Html2rss::Config::RequestControls, nil] explicit request controls to write
|
|
123
|
+
# @param limit [Integer, nil] max articles to keep in the auto-sourced feed
|
|
123
124
|
# @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
|
|
124
|
-
def auto_source_config(url:, items_selector: nil, request_controls: nil)
|
|
125
|
+
def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil)
|
|
126
|
+
auto_source = AutoSource::DEFAULT_CONFIG
|
|
127
|
+
auto_source = auto_source.merge(limit:) unless limit.nil?
|
|
128
|
+
|
|
125
129
|
config = {
|
|
126
130
|
channel: default_config[:channel].merge(url:),
|
|
127
|
-
auto_source:
|
|
131
|
+
auto_source:
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
request_controls ||= RequestControls.new
|
data/lib/html2rss/error.rb
CHANGED
|
@@ -6,16 +6,24 @@ module Html2rss
|
|
|
6
6
|
|
|
7
7
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
8
8
|
class NoFeedItemsExtracted < Error
|
|
9
|
+
# Categories that append shared surface guidance to the empty-feed message.
|
|
10
|
+
SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface].freeze
|
|
11
|
+
|
|
9
12
|
##
|
|
10
13
|
# @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
|
|
11
|
-
|
|
14
|
+
# @param surface_category [Symbol, nil] optional Scraper surface classification
|
|
15
|
+
def initialize(attempts:, surface_category: nil)
|
|
12
16
|
@attempts = attempts
|
|
17
|
+
@surface_category = surface_category
|
|
13
18
|
super(build_message)
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
# @return [Array<Hash{Symbol => Object}>] tier attempt diagnostics
|
|
17
22
|
attr_reader :attempts
|
|
18
23
|
|
|
24
|
+
# @return [Symbol, nil] surface classification when a response body was available
|
|
25
|
+
attr_reader :surface_category
|
|
26
|
+
|
|
19
27
|
private
|
|
20
28
|
|
|
21
29
|
def build_message
|
|
@@ -24,8 +32,12 @@ module Html2rss
|
|
|
24
32
|
"#{attempt[:strategy]} (#{details})"
|
|
25
33
|
end.join(', ')
|
|
26
34
|
|
|
27
|
-
"No feed items extracted after auto fallback across strategies: #{summaries}. " \
|
|
28
|
-
|
|
35
|
+
message = "No feed items extracted after auto fallback across strategies: #{summaries}. " \
|
|
36
|
+
'Try a more specific listing URL or provide explicit selectors.'
|
|
37
|
+
return message unless SURFACE_HINT_CATEGORIES.include?(surface_category)
|
|
38
|
+
|
|
39
|
+
guidance = AutoSource::Scraper::NoScraperFound::CATEGORY_MESSAGES.fetch(surface_category)
|
|
40
|
+
"#{message} #{guidance}"
|
|
29
41
|
end
|
|
30
42
|
end
|
|
31
43
|
end
|