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.
Files changed (202) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -25
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +127 -53
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. 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, Metrics/CyclomaticComplexity
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 = Html2rss::Capture.build(
122
- url,
123
- strategy:,
124
- items_selector: options[:items_selector],
125
- topics:,
126
- title: options[:title],
127
- summary: options[:summary],
128
- force: options[:force],
129
- enhance: options[:enhance],
130
- limit: options[:limit]&.to_i,
131
- max_redirects: options[:max_redirects],
132
- max_requests: options[:max_requests],
133
- local_file_path:
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 = Html2rss.test(
162
- raw_content,
163
- feed_name,
164
- min_items: options.fetch(:min_items, 1).to_i,
165
- params: options[:params] || {},
166
- strategy: options[:strategy],
167
- strict_quality: options.fetch(:strict_quality, false),
168
- compare_enhance: options.fetch(:compare_enhance, false)
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) # rubocop:disable Metrics/AbcSize
198
- config = if File.file?(source.to_s)
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 ArgumentError => error
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], URI.parse(url).host.to_s.delete_prefix('www.'))
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 => error
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]&.to_i,
437
- keep_different_domain: options[:keep_different_domain]
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 # rubocop:disable Metrics/BlockLength
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 do # rubocop:disable Metrics/BlockLength
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 ArgumentError, "unmapped Dry validation predicate: #{predicate.inspect}"
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
- new(strategy:, max_redirects:, max_requests:, explicit_keys: keys)
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&.to_sym
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
- private_class_method :explicit_keys_for, :request_value_for, :request_key?
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 ArgumentError, 'request config must be a hash'
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: 'List-card enrichment: run Html::ArticleExtractor on each matched item node ' \
133
- 'to fill missing fields from the card HTML.'
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: Html2rss::Config::AutoSourceContract.json_schema(loose: true).merge(
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. ArgumentError is the bound; Gsub maps
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 ArgumentError => error
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 ArgumentError, "unknown ValidationIssue code: #{code.inspect}" unless CODES.include?(code)
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 ArgumentError => error
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.from_absolute(image_string)
169
- rescue ArgumentError => error
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