html2rss 0.32.4 → 0.34.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 +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -25
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +127 -53
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +50 -7
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +55 -51
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +111 -42
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
data/lib/html2rss/cli.rb
CHANGED
|
@@ -114,27 +114,29 @@ module Html2rss
|
|
|
114
114
|
method_option :explain, type: :boolean, desc: 'Print capture diagnostics to stderr', default: false
|
|
115
115
|
# @param target [String, nil] URL or local HTML file
|
|
116
116
|
# @return [void]
|
|
117
|
-
def capture(target = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
117
|
+
def capture(target = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
118
118
|
strategy, local_file_path, url = prepare_auto_inputs(target, options[:input])
|
|
119
119
|
topics = options[:topics]&.split(',')&.map(&:strip)
|
|
120
120
|
|
|
121
|
-
result =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
121
|
+
result = execute_feed do
|
|
122
|
+
Html2rss::Capture.build(
|
|
123
|
+
url,
|
|
124
|
+
strategy:,
|
|
125
|
+
items_selector: options[:items_selector],
|
|
126
|
+
topics:,
|
|
127
|
+
title: options[:title],
|
|
128
|
+
summary: options[:summary],
|
|
129
|
+
force: options[:force],
|
|
130
|
+
enhance: options[:enhance],
|
|
131
|
+
limit: options[:limit],
|
|
132
|
+
max_redirects: options[:max_redirects],
|
|
133
|
+
max_requests: options[:max_requests],
|
|
134
|
+
local_file_path:
|
|
135
|
+
)
|
|
136
|
+
end
|
|
135
137
|
|
|
136
138
|
if result.native_feed? && !options[:force]
|
|
137
|
-
$stderr.puts "First-party RSS/Atom feed detected at #{result.native_feed}. Use --force to capture anyway." # rubocop:disable Style/StderrPuts
|
|
139
|
+
$stderr.puts "First-party RSS/Atom/RDF feed detected at #{result.native_feed}. Use --force to capture anyway." # rubocop:disable Style/StderrPuts
|
|
138
140
|
exit(3)
|
|
139
141
|
end
|
|
140
142
|
|
|
@@ -158,15 +160,17 @@ module Html2rss
|
|
|
158
160
|
# @return [void]
|
|
159
161
|
def test(config_input = nil, feed_name = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
160
162
|
raw_content, input_source = read_config_input(config_input)
|
|
161
|
-
result =
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
163
|
+
result = execute_feed do
|
|
164
|
+
Html2rss.test(
|
|
165
|
+
raw_content,
|
|
166
|
+
feed_name,
|
|
167
|
+
min_items: options.fetch(:min_items, 1),
|
|
168
|
+
params: options[:params] || {},
|
|
169
|
+
strategy: options[:strategy],
|
|
170
|
+
strict_quality: options.fetch(:strict_quality, false),
|
|
171
|
+
compare_enhance: options.fetch(:compare_enhance, false)
|
|
172
|
+
)
|
|
173
|
+
end
|
|
170
174
|
|
|
171
175
|
if options[:json]
|
|
172
176
|
puts JSON.pretty_generate(result.to_h)
|
|
@@ -194,15 +198,10 @@ module Html2rss
|
|
|
194
198
|
# @param source [String, nil] YAML file path or '-'
|
|
195
199
|
# @param feed_name [String, nil] optional named feed
|
|
196
200
|
# @return [void]
|
|
197
|
-
def apply(source = nil, feed_name = nil)
|
|
198
|
-
config =
|
|
199
|
-
Html2rss.config_from_yaml_file(source.to_s, feed_name)
|
|
200
|
-
else
|
|
201
|
-
raw_content, = read_config_input(source)
|
|
202
|
-
Config.from_yaml(raw_content)
|
|
203
|
-
end
|
|
201
|
+
def apply(source = nil, feed_name = nil)
|
|
202
|
+
config = execute_feed { load_apply_config(source, feed_name) }
|
|
204
203
|
config[:params] = options[:params] || {}
|
|
205
|
-
apply_runtime_request_overrides!(config)
|
|
204
|
+
execute_feed { apply_runtime_request_overrides!(config) }
|
|
206
205
|
apply_local_file_input!(config, options[:input]) if options[:input]
|
|
207
206
|
|
|
208
207
|
run_feed_command { Html2rss.apply(config) }
|
|
@@ -216,6 +215,8 @@ module Html2rss
|
|
|
216
215
|
method_option :keep_different_domain, type: :boolean, default: false,
|
|
217
216
|
desc: 'Allow published items to link to any domain ' \
|
|
218
217
|
'supplied by the source (no affiliation/safety check)'
|
|
218
|
+
method_option :keep_sponsored, type: :boolean, default: false,
|
|
219
|
+
desc: 'Keep items the page marks as sponsored (rel="sponsored" or ad labels)'
|
|
219
220
|
# @param url [String, nil]
|
|
220
221
|
# @return [void]
|
|
221
222
|
def scrape(url = nil)
|
|
@@ -253,6 +254,8 @@ module Html2rss
|
|
|
253
254
|
# @return [void]
|
|
254
255
|
def mcp
|
|
255
256
|
Html2rss::MCP.start(transport: options[:transport].to_sym, port: options[:port])
|
|
257
|
+
rescue Html2rss::MCP::Server::HttpTransportUnavailable => error
|
|
258
|
+
raise Thor::Error, error.message
|
|
256
259
|
end
|
|
257
260
|
|
|
258
261
|
desc 'doctor [TYPE]', 'Runtime preflight checks (botasaurus)'
|
|
@@ -305,7 +308,7 @@ module Html2rss
|
|
|
305
308
|
|
|
306
309
|
expected = Recon::Verdict[verdict_filter.downcase]
|
|
307
310
|
results.select { |r| r.verdict == expected }
|
|
308
|
-
rescue
|
|
311
|
+
rescue InvalidOption => error
|
|
309
312
|
raise Thor::Error, error.message
|
|
310
313
|
end
|
|
311
314
|
|
|
@@ -328,7 +331,7 @@ module Html2rss
|
|
|
328
331
|
|
|
329
332
|
def handle_capture_output(result, url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
330
333
|
if options[:output_dir]
|
|
331
|
-
dir = File.join(options[:output_dir],
|
|
334
|
+
dir = File.join(options[:output_dir], Html2rss::Url.from_absolute(url).host.to_s.delete_prefix('www.'))
|
|
332
335
|
FileUtils.mkdir_p(dir)
|
|
333
336
|
file_path = File.join(dir, 'index.yml')
|
|
334
337
|
File.write(file_path, result.yaml)
|
|
@@ -408,6 +411,13 @@ module Html2rss
|
|
|
408
411
|
options[:strategy]&.to_sym || :auto
|
|
409
412
|
end
|
|
410
413
|
|
|
414
|
+
def load_apply_config(source, feed_name)
|
|
415
|
+
return Html2rss.config_from_yaml_file(source.to_s, feed_name) if File.file?(source.to_s)
|
|
416
|
+
|
|
417
|
+
raw_content, = read_config_input(source)
|
|
418
|
+
Config.from_yaml(raw_content)
|
|
419
|
+
end
|
|
420
|
+
|
|
411
421
|
def execute_feed # rubocop:disable Metrics/MethodLength
|
|
412
422
|
yield
|
|
413
423
|
rescue Html2rss::RequestService::RedirectLimitReached => error
|
|
@@ -421,7 +431,10 @@ module Html2rss
|
|
|
421
431
|
Html2rss::RequestService::BotasaurusConnectionFailed,
|
|
422
432
|
Html2rss::RequestService::BotasaurusServiceError,
|
|
423
433
|
Html2rss::RequestService::BlockedSurfaceDetected,
|
|
424
|
-
Html2rss::NoFeedItemsExtracted
|
|
434
|
+
Html2rss::NoFeedItemsExtracted,
|
|
435
|
+
Html2rss::InvalidOption,
|
|
436
|
+
Html2rss::Config::InvalidConfig,
|
|
437
|
+
Html2rss::Url::InvalidUrlError => error
|
|
425
438
|
raise Thor::Error, error.message
|
|
426
439
|
end
|
|
427
440
|
|
|
@@ -433,11 +446,15 @@ module Html2rss
|
|
|
433
446
|
max_redirects: options[:max_redirects],
|
|
434
447
|
max_requests: options[:max_requests],
|
|
435
448
|
local_file_path:,
|
|
436
|
-
limit: options[:limit]
|
|
437
|
-
|
|
449
|
+
limit: options[:limit],
|
|
450
|
+
**cleanup_overrides
|
|
438
451
|
)
|
|
439
452
|
end
|
|
440
453
|
|
|
454
|
+
def cleanup_overrides
|
|
455
|
+
{ keep_different_domain: options[:keep_different_domain], keep_sponsored: options[:keep_sponsored] }
|
|
456
|
+
end
|
|
457
|
+
|
|
441
458
|
def explain_json!(payload)
|
|
442
459
|
$stderr.puts JSON.pretty_generate(payload) # rubocop:disable Style/StderrPuts
|
|
443
460
|
end
|
|
@@ -4,61 +4,68 @@ require 'dry-validation'
|
|
|
4
4
|
|
|
5
5
|
module Html2rss
|
|
6
6
|
class Config
|
|
7
|
+
# Scraper options are splatted into scraper constructors, so a key no scraper accepts is a
|
|
8
|
+
# validation error. Key validation applies only when a schema is called directly, so the
|
|
9
|
+
# auto-source contract keeps +scraper+ an open hash and {Validator} applies this one to it.
|
|
10
|
+
# The raw values reach the scrapers, so this schema checks types without coercing them.
|
|
11
|
+
AutoSourceScraperContract = Dry::Schema.JSON do # rubocop:disable Metrics/BlockLength
|
|
12
|
+
config.validate_keys = true
|
|
13
|
+
|
|
14
|
+
optional(:native_feed).hash do
|
|
15
|
+
optional(:enabled).filled(:bool)
|
|
16
|
+
end
|
|
17
|
+
optional(:wordpress_api).hash do
|
|
18
|
+
optional(:enabled).filled(:bool)
|
|
19
|
+
end
|
|
20
|
+
optional(:sitemap).hash do
|
|
21
|
+
optional(:enabled).filled(:bool)
|
|
22
|
+
optional(:min_priority).filled { int? | float? }
|
|
23
|
+
optional(:max_age_days).filled(:integer, gt?: 0)
|
|
24
|
+
end
|
|
25
|
+
optional(:schema).hash do
|
|
26
|
+
optional(:enabled).filled(:bool)
|
|
27
|
+
end
|
|
28
|
+
optional(:microdata).hash do
|
|
29
|
+
optional(:enabled).filled(:bool)
|
|
30
|
+
end
|
|
31
|
+
optional(:microformats2).hash do
|
|
32
|
+
optional(:enabled).filled(:bool)
|
|
33
|
+
end
|
|
34
|
+
optional(:json_state).hash do
|
|
35
|
+
optional(:enabled).filled(:bool)
|
|
36
|
+
end
|
|
37
|
+
optional(:json_document).hash do
|
|
38
|
+
optional(:enabled).filled(:bool)
|
|
39
|
+
end
|
|
40
|
+
optional(:xhr_articles).hash do
|
|
41
|
+
optional(:enabled).filled(:bool)
|
|
42
|
+
end
|
|
43
|
+
optional(:meta_oembed).hash do
|
|
44
|
+
optional(:enabled).filled(:bool)
|
|
45
|
+
end
|
|
46
|
+
optional(:semantic_html).hash do
|
|
47
|
+
optional(:enabled).filled(:bool)
|
|
48
|
+
end
|
|
49
|
+
optional(:html).hash do
|
|
50
|
+
optional(:enabled).filled(:bool)
|
|
51
|
+
optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
|
|
52
|
+
optional(:use_top_selectors).filled(:integer, gt?: 0)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
7
56
|
# Runtime source of truth for validating auto-source config values.
|
|
8
|
-
AutoSourceContract = Dry::Schema.Params do
|
|
57
|
+
AutoSourceContract = Dry::Schema.Params do
|
|
9
58
|
optional(:limit).filled(:integer, gt?: 0)
|
|
10
59
|
optional(:entry_resolution).hash do
|
|
11
60
|
optional(:enabled).filled(:bool)
|
|
12
61
|
optional(:max_probes).filled(:integer, gt?: 0)
|
|
13
62
|
end
|
|
14
63
|
|
|
15
|
-
optional(:scraper).hash
|
|
16
|
-
optional(:native_feed).hash do
|
|
17
|
-
optional(:enabled).filled(:bool)
|
|
18
|
-
end
|
|
19
|
-
optional(:wordpress_api).hash do
|
|
20
|
-
optional(:enabled).filled(:bool)
|
|
21
|
-
end
|
|
22
|
-
optional(:sitemap).hash do
|
|
23
|
-
optional(:enabled).filled(:bool)
|
|
24
|
-
optional(:min_priority).filled(:float)
|
|
25
|
-
optional(:max_age_days).filled(:integer, gt?: 0)
|
|
26
|
-
end
|
|
27
|
-
optional(:schema).hash do
|
|
28
|
-
optional(:enabled).filled(:bool)
|
|
29
|
-
end
|
|
30
|
-
optional(:microdata).hash do
|
|
31
|
-
optional(:enabled).filled(:bool)
|
|
32
|
-
end
|
|
33
|
-
optional(:microformats2).hash do
|
|
34
|
-
optional(:enabled).filled(:bool)
|
|
35
|
-
end
|
|
36
|
-
optional(:json_state).hash do
|
|
37
|
-
optional(:enabled).filled(:bool)
|
|
38
|
-
end
|
|
39
|
-
optional(:json_document).hash do
|
|
40
|
-
optional(:enabled).filled(:bool)
|
|
41
|
-
end
|
|
42
|
-
optional(:xhr_articles).hash do
|
|
43
|
-
optional(:enabled).filled(:bool)
|
|
44
|
-
end
|
|
45
|
-
optional(:meta_oembed).hash do
|
|
46
|
-
optional(:enabled).filled(:bool)
|
|
47
|
-
end
|
|
48
|
-
optional(:semantic_html).hash do
|
|
49
|
-
optional(:enabled).filled(:bool)
|
|
50
|
-
optional(:fallback_anchorless).filled(:bool)
|
|
51
|
-
end
|
|
52
|
-
optional(:html).hash do
|
|
53
|
-
optional(:enabled).filled(:bool)
|
|
54
|
-
optional(:minimum_selector_frequency).filled(:integer, gt?: 0)
|
|
55
|
-
optional(:use_top_selectors).filled(:integer, gt?: 0)
|
|
56
|
-
optional(:fallback_anchorless).filled(:bool)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
64
|
+
optional(:scraper).hash
|
|
59
65
|
|
|
60
66
|
optional(:cleanup).hash do
|
|
61
67
|
optional(:keep_different_domain).filled(:bool)
|
|
68
|
+
optional(:keep_sponsored).filled(:bool)
|
|
62
69
|
end
|
|
63
70
|
end
|
|
64
71
|
end
|
|
@@ -6,6 +6,9 @@ module Html2rss
|
|
|
6
6
|
# Maps internal Dry::Validation messages to {ValidationIssue} (predicate → code).
|
|
7
7
|
# Not a public API — Dry stays inside Validator / this mapper.
|
|
8
8
|
module IssueMapper
|
|
9
|
+
# Raised when the schema yields a Dry predicate this mapper has no code for (a mapping-table gap).
|
|
10
|
+
class InvalidError < Html2rss::ContractError; end
|
|
11
|
+
|
|
9
12
|
# Dry schema predicates → closed {ValidationIssue::CODES}.
|
|
10
13
|
PREDICATE_CODES = {
|
|
11
14
|
key?: :missing_key,
|
|
@@ -65,7 +68,7 @@ module Html2rss
|
|
|
65
68
|
return :invalid_value if predicate.nil?
|
|
66
69
|
|
|
67
70
|
PREDICATE_CODES.fetch(predicate) do
|
|
68
|
-
raise
|
|
71
|
+
raise InvalidError, "unmapped Dry validation predicate: #{predicate.inspect}"
|
|
69
72
|
end
|
|
70
73
|
end
|
|
71
74
|
|
|
@@ -50,13 +50,18 @@ module Html2rss
|
|
|
50
50
|
# @param strategy [Symbol, nil]
|
|
51
51
|
# @param max_redirects [Integer, nil]
|
|
52
52
|
# @param max_requests [Integer, nil]
|
|
53
|
+
# @param total_timeout_seconds [Integer, nil] wall-clock bound for the whole request chain
|
|
53
54
|
# @return [RequestControls]
|
|
54
|
-
def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil)
|
|
55
|
+
def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil, total_timeout_seconds: nil)
|
|
56
|
+
strategy = Option.strategy(strategy) unless strategy.nil?
|
|
57
|
+
check_budgets!(max_redirects, max_requests)
|
|
58
|
+
Option.integer(:total_timeout_seconds, total_timeout_seconds, min: 1, optional: true)
|
|
55
59
|
keys = []
|
|
56
60
|
keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
|
|
57
61
|
keys << :max_redirects unless max_redirects.nil?
|
|
58
62
|
keys << :max_requests unless max_requests.nil?
|
|
59
|
-
|
|
63
|
+
keys << :total_timeout_seconds unless total_timeout_seconds.nil?
|
|
64
|
+
new(strategy:, max_redirects:, max_requests:, total_timeout_seconds:, explicit_keys: keys)
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
##
|
|
@@ -68,7 +73,8 @@ module Html2rss
|
|
|
68
73
|
# @param max_requests [Integer, nil]
|
|
69
74
|
# @return [RequestControls]
|
|
70
75
|
def self.from_cli_options(strategy:, max_redirects: nil, max_requests: nil)
|
|
71
|
-
strategy_sym = strategy
|
|
76
|
+
strategy_sym = Option.strategy(strategy) if strategy
|
|
77
|
+
check_budgets!(max_redirects, max_requests)
|
|
72
78
|
keys = []
|
|
73
79
|
keys << :strategy if strategy
|
|
74
80
|
keys << :max_redirects unless max_redirects.nil?
|
|
@@ -76,7 +82,12 @@ module Html2rss
|
|
|
76
82
|
new(strategy: strategy_sym, max_redirects:, max_requests:, explicit_keys: keys)
|
|
77
83
|
end
|
|
78
84
|
|
|
79
|
-
|
|
85
|
+
def self.check_budgets!(max_redirects, max_requests)
|
|
86
|
+
Option.integer(:max_redirects, max_redirects, min: 0, optional: true)
|
|
87
|
+
Option.integer(:max_requests, max_requests, min: 1, optional: true)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private_class_method :explicit_keys_for, :request_value_for, :request_key?, :check_budgets!
|
|
80
91
|
|
|
81
92
|
##
|
|
82
93
|
# @param strategy [Symbol, nil] effective request strategy
|
|
@@ -162,7 +173,7 @@ module Html2rss
|
|
|
162
173
|
return config[:request] = {} if request_config.nil?
|
|
163
174
|
return if request_config.is_a?(Hash)
|
|
164
175
|
|
|
165
|
-
raise
|
|
176
|
+
raise InvalidOption, 'request config must be a hash'
|
|
166
177
|
end
|
|
167
178
|
end
|
|
168
179
|
end
|
|
@@ -7,8 +7,6 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# JSON export adapter from the runtime validators to the packaged schema artifact.
|
|
9
9
|
module Schema
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
10
|
# Canonical filename for the exported config JSON schema artifact.
|
|
13
11
|
SCHEMA_FILENAME = 'html2rss-config.schema.json'
|
|
14
12
|
|
|
@@ -16,7 +14,7 @@ module Html2rss
|
|
|
16
14
|
# Returns the exported configuration JSON Schema.
|
|
17
15
|
#
|
|
18
16
|
# @return [Hash{String => Object}] JSON Schema represented as a Ruby hash
|
|
19
|
-
def json_schema
|
|
17
|
+
def self.json_schema
|
|
20
18
|
load_json_schema_extension!
|
|
21
19
|
Builder.call
|
|
22
20
|
end
|
|
@@ -26,7 +24,7 @@ module Html2rss
|
|
|
26
24
|
#
|
|
27
25
|
# @param pretty [Boolean] whether to pretty-print the JSON output
|
|
28
26
|
# @return [String] serialized JSON Schema
|
|
29
|
-
def json_schema_json(pretty: true)
|
|
27
|
+
def self.json_schema_json(pretty: true)
|
|
30
28
|
pretty ? JSON.pretty_generate(json_schema) : JSON.generate(json_schema)
|
|
31
29
|
end
|
|
32
30
|
|
|
@@ -34,7 +32,7 @@ module Html2rss
|
|
|
34
32
|
# Resolves the packaged schema path used by downstream tools.
|
|
35
33
|
#
|
|
36
34
|
# @return [String] absolute path to the packaged JSON schema file
|
|
37
|
-
def path
|
|
35
|
+
def self.path
|
|
38
36
|
search_path = File.expand_path(__dir__)
|
|
39
37
|
|
|
40
38
|
loop do
|
|
@@ -51,7 +49,7 @@ module Html2rss
|
|
|
51
49
|
end
|
|
52
50
|
|
|
53
51
|
# @return [void]
|
|
54
|
-
def load_json_schema_extension!
|
|
52
|
+
def self.load_json_schema_extension!
|
|
55
53
|
require 'dry/schema/extensions/json_schema'
|
|
56
54
|
Dry::Schema.load_extensions(:json_schema)
|
|
57
55
|
end
|
|
@@ -107,6 +105,17 @@ module Html2rss
|
|
|
107
105
|
request[:botasaurus] = exported
|
|
108
106
|
end
|
|
109
107
|
|
|
108
|
+
# Scraper options are splatted into scraper constructors, so the scraper map and each
|
|
109
|
+
# scraper object reject keys the contract does not name.
|
|
110
|
+
def auto_source_schema
|
|
111
|
+
schema = Html2rss::Config::AutoSourceContract.json_schema(loose: true)
|
|
112
|
+
scraper = Html2rss::Config::AutoSourceScraperContract.json_schema(loose: true).except(:$schema)
|
|
113
|
+
scraper[:additionalProperties] = false
|
|
114
|
+
scraper.fetch(:properties).each_value { |options| options[:additionalProperties] = false }
|
|
115
|
+
schema.fetch(:properties)[:scraper] = scraper
|
|
116
|
+
schema.merge(default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG))
|
|
117
|
+
end
|
|
118
|
+
|
|
110
119
|
# @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
|
|
111
120
|
def registry_catalog_defs
|
|
112
121
|
{
|
|
@@ -129,8 +138,9 @@ module Html2rss
|
|
|
129
138
|
default: Html2rss::Selectors::DEFAULT_ITEMS_SELECTOR
|
|
130
139
|
)
|
|
131
140
|
items_schema[:properties][:enhance] = items_schema.fetch(:properties).fetch(:enhance).merge(
|
|
132
|
-
description: '
|
|
133
|
-
'
|
|
141
|
+
description: 'Fills missing title, link, description, date, image and categories from each ' \
|
|
142
|
+
"item's own markup via Html::ArticleExtractor. Explicit selectors win; no extra " \
|
|
143
|
+
'requests are made.'
|
|
134
144
|
)
|
|
135
145
|
items_schema[:properties][:pagination] = {
|
|
136
146
|
description: 'Pagination configuration or maximum page count integer.',
|
|
@@ -177,9 +187,7 @@ module Html2rss
|
|
|
177
187
|
description: 'Collection of stylesheets to attach to the RSS feed.',
|
|
178
188
|
items: Html2rss::Config::Validator::StylesheetConfig.json_schema(loose: true)
|
|
179
189
|
},
|
|
180
|
-
auto_source:
|
|
181
|
-
default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG)
|
|
182
|
-
),
|
|
190
|
+
auto_source: auto_source_schema,
|
|
183
191
|
selectors: {
|
|
184
192
|
type: 'object',
|
|
185
193
|
description: 'Selectors used to extract article attributes.',
|
|
@@ -221,7 +229,7 @@ module Html2rss
|
|
|
221
229
|
#
|
|
222
230
|
# @param schema [Hash] dry-schema JSON schema fragment for a selector
|
|
223
231
|
# @return [Hash] same schema with registry $ref wiring applied
|
|
224
|
-
def apply_selector_registry_refs!(schema)
|
|
232
|
+
def self.apply_selector_registry_refs!(schema)
|
|
225
233
|
properties = schema.fetch(:properties)
|
|
226
234
|
properties[:extractor] = extractor_wire_schema
|
|
227
235
|
properties[:post_process] = post_process_wire_schema
|
|
@@ -229,7 +237,7 @@ module Html2rss
|
|
|
229
237
|
end
|
|
230
238
|
|
|
231
239
|
# @return [Hash{Symbol => Object}]
|
|
232
|
-
def extractor_wire_schema
|
|
240
|
+
def self.extractor_wire_schema
|
|
233
241
|
{
|
|
234
242
|
description: 'Extractor used to pull a value from the selected element.',
|
|
235
243
|
oneOf: registry_ref_list('extractors', Selectors::Extractors::NAME_TO_CLASS)
|
|
@@ -237,7 +245,7 @@ module Html2rss
|
|
|
237
245
|
end
|
|
238
246
|
|
|
239
247
|
# @return [Hash{Symbol => Object}]
|
|
240
|
-
def post_process_wire_schema
|
|
248
|
+
def self.post_process_wire_schema
|
|
241
249
|
{
|
|
242
250
|
type: 'array',
|
|
243
251
|
description: 'Ordered transforms applied to the extracted value.',
|
|
@@ -251,7 +259,7 @@ module Html2rss
|
|
|
251
259
|
# @param catalog [String] $defs catalog name (`extractors` or `post_processors`)
|
|
252
260
|
# @param registry [Hash{Symbol => Class}]
|
|
253
261
|
# @return [Array<Hash>] oneOf entries of `{ '$ref' => ... }`
|
|
254
|
-
def registry_ref_list(catalog, registry)
|
|
262
|
+
def self.registry_ref_list(catalog, registry)
|
|
255
263
|
registry.keys.sort.map do |name|
|
|
256
264
|
{ '$ref' => "#/$defs/#{catalog}/#{name}" }
|
|
257
265
|
end
|
|
@@ -142,7 +142,7 @@ module Html2rss
|
|
|
142
142
|
end
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
-
# Same compile as Gsub#call.
|
|
145
|
+
# Same compile as Gsub#call. InvalidOption is the bound; Gsub maps
|
|
146
146
|
# parser errors onto that path so admit and execute still share it.
|
|
147
147
|
#
|
|
148
148
|
# @param klass [Class]
|
|
@@ -156,7 +156,7 @@ module Html2rss
|
|
|
156
156
|
|
|
157
157
|
Selectors::PostProcessors::Gsub.compiled_pattern(pattern)
|
|
158
158
|
nil
|
|
159
|
-
rescue
|
|
159
|
+
rescue InvalidOption => error
|
|
160
160
|
error.message
|
|
161
161
|
end
|
|
162
162
|
end
|
|
@@ -216,6 +216,7 @@ module Html2rss
|
|
|
216
216
|
def array_selector_shape_error(key, selector)
|
|
217
217
|
return "`#{key}` must be an array" unless selector.is_a?(Array)
|
|
218
218
|
return "`#{key}` must contain at least one element" if selector.empty?
|
|
219
|
+
return "`#{key}` must contain only selector names" unless selector.all? { _1 in String | Symbol }
|
|
219
220
|
|
|
220
221
|
nil
|
|
221
222
|
end
|
|
@@ -10,6 +10,9 @@ module Html2rss
|
|
|
10
10
|
# +path+ is JSON-pointer-friendly segments. +code+ is closed ({CODES}).
|
|
11
11
|
# +expected+ / +actual+ are JSON-ish or +nil+ (never invented).
|
|
12
12
|
class ValidationIssue
|
|
13
|
+
# Raised when an issue is built with a code outside {CODES}.
|
|
14
|
+
class InvalidError < Html2rss::ContractError; end
|
|
15
|
+
|
|
13
16
|
# Closed set of issue codes (mapper owns Dry predicate → code).
|
|
14
17
|
CODES = Set[
|
|
15
18
|
:missing_key,
|
|
@@ -27,7 +30,7 @@ module Html2rss
|
|
|
27
30
|
# @param expected [Hash, String, Numeric, true, false, nil]
|
|
28
31
|
# @param actual [Hash, String, Numeric, true, false, nil]
|
|
29
32
|
def initialize(path:, code:, message:, expected: nil, actual: nil)
|
|
30
|
-
raise
|
|
33
|
+
raise InvalidError, "unknown ValidationIssue code: #{code.inspect}" unless CODES.include?(code)
|
|
31
34
|
|
|
32
35
|
super(
|
|
33
36
|
path: Array(path).freeze,
|
|
@@ -153,20 +153,31 @@ module Html2rss
|
|
|
153
153
|
end
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
+
rule(auto_source: :scraper) do
|
|
157
|
+
next unless value
|
|
158
|
+
|
|
159
|
+
result = Config::AutoSourceScraperContract.call(value)
|
|
160
|
+
next if result.success?
|
|
161
|
+
|
|
162
|
+
result.errors.each do |error|
|
|
163
|
+
key([:auto_source, :scraper, *error.path]).failure(error.text)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
156
167
|
# URL validation delegated to Url class
|
|
157
168
|
rule(:channel) do
|
|
158
169
|
if (url_string = values.dig(:channel, :url)) && !url_string.empty?
|
|
159
170
|
begin
|
|
160
171
|
Html2rss::Url.for_channel(url_string)
|
|
161
|
-
rescue
|
|
172
|
+
rescue Html2rss::Url::InvalidUrlError => error
|
|
162
173
|
key(%i[channel url]).failure(error.message)
|
|
163
174
|
end
|
|
164
175
|
end
|
|
165
176
|
|
|
166
177
|
if (image_string = values.dig(:channel, :image)) && !image_string.empty?
|
|
167
178
|
begin
|
|
168
|
-
Html2rss::Url.
|
|
169
|
-
rescue
|
|
179
|
+
Html2rss::Url.from_absolute_web(image_string)
|
|
180
|
+
rescue Html2rss::Url::InvalidUrlError => error
|
|
170
181
|
key(%i[channel image]).failure(error.message)
|
|
171
182
|
end
|
|
172
183
|
end
|