html2rss 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -3
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +34 -7
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_document.rb +135 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +74 -11
  10. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +4 -8
  11. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  12. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  13. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  14. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  16. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  17. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  18. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  19. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  20. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  21. data/lib/html2rss/auto_source/scraper.rb +12 -17
  22. data/lib/html2rss/auto_source/segment.rb +7 -4
  23. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  24. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  25. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  26. data/lib/html2rss/auto_source.rb +58 -3
  27. data/lib/html2rss/batch.rb +3 -1
  28. data/lib/html2rss/capture/README.md +6 -4
  29. data/lib/html2rss/capture.rb +67 -35
  30. data/lib/html2rss/channel.rb +3 -3
  31. data/lib/html2rss/cli/probe_view.rb +5 -3
  32. data/lib/html2rss/cli/render.rb +6 -5
  33. data/lib/html2rss/cli/validate.rb +28 -27
  34. data/lib/html2rss/cli.rb +6 -2
  35. data/lib/html2rss/config/auto_source_contract.rb +3 -0
  36. data/lib/html2rss/config/issue_mapper.rb +108 -0
  37. data/lib/html2rss/config/request_headers.rb +2 -3
  38. data/lib/html2rss/config/schema.rb +16 -1
  39. data/lib/html2rss/config/selectors_validator.rb +92 -70
  40. data/lib/html2rss/config/validation_issue.rb +60 -0
  41. data/lib/html2rss/config/validation_report.rb +55 -0
  42. data/lib/html2rss/config/validator.rb +10 -6
  43. data/lib/html2rss/config.rb +44 -114
  44. data/lib/html2rss/defaults.rb +42 -30
  45. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  46. data/lib/html2rss/doctor.rb +8 -0
  47. data/lib/html2rss/error.rb +1 -1
  48. data/lib/html2rss/feed_builder/rss.rb +9 -14
  49. data/lib/html2rss/feed_pipeline/auto_fallback.rb +11 -2
  50. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  51. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  52. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  53. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  54. data/lib/html2rss/feed_resolution/options.rb +3 -1
  55. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  56. data/lib/html2rss/feed_resolution.rb +30 -2
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor.rb +2 -4
  59. data/lib/html2rss/html/feed_link.rb +3 -1
  60. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  61. data/lib/html2rss/html/navigator.rb +10 -10
  62. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  63. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  64. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  65. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  66. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  67. data/lib/html2rss/mcp/README.md +2 -0
  68. data/lib/html2rss/mcp/config_argument.rb +3 -1
  69. data/lib/html2rss/mcp/contract.rb +7 -21
  70. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  71. data/lib/html2rss/mcp/outcome.rb +36 -43
  72. data/lib/html2rss/mcp/server/tools.rb +111 -131
  73. data/lib/html2rss/mcp/server.rb +26 -18
  74. data/lib/html2rss/page_recon/README.md +5 -5
  75. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  76. data/lib/html2rss/page_recon.rb +9 -5
  77. data/lib/html2rss/recon.rb +19 -30
  78. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  79. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  80. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  81. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  82. data/lib/html2rss/request_service/network_guard.rb +7 -5
  83. data/lib/html2rss/request_service/response.rb +6 -4
  84. data/lib/html2rss/request_service/strategy.rb +3 -8
  85. data/lib/html2rss/request_service.rb +11 -2
  86. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  87. data/lib/html2rss/scoring/engine.rb +8 -8
  88. data/lib/html2rss/scoring/observation.rb +5 -3
  89. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  90. data/lib/html2rss/scrape_target.rb +3 -1
  91. data/lib/html2rss/selector_candidates.rb +234 -0
  92. data/lib/html2rss/selectors/README.md +64 -0
  93. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  94. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  95. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  96. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  97. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  98. data/lib/html2rss/selectors/extractors.rb +33 -20
  99. data/lib/html2rss/selectors/item_env.rb +31 -0
  100. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  101. data/lib/html2rss/selectors/option_spec.rb +140 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  103. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  104. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  105. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  106. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  107. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  108. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  109. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  110. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  111. data/lib/html2rss/selectors/post_processors.rb +6 -5
  112. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  113. data/lib/html2rss/selectors/step_env.rb +24 -0
  114. data/lib/html2rss/selectors.rb +231 -142
  115. data/lib/html2rss/sst/attrs.rb +5 -3
  116. data/lib/html2rss/sst/document.rb +3 -1
  117. data/lib/html2rss/sst/index.rb +3 -1
  118. data/lib/html2rss/sst/node.rb +29 -10
  119. data/lib/html2rss/sst/normalizer.rb +12 -16
  120. data/lib/html2rss/sst/tags.rb +7 -7
  121. data/lib/html2rss/status.rb +13 -8
  122. data/lib/html2rss/surface_category.rb +14 -32
  123. data/lib/html2rss/syndication/parser.rb +2 -2
  124. data/lib/html2rss/test/enhance_audit.rb +1 -3
  125. data/lib/html2rss/test/policy.rb +106 -0
  126. data/lib/html2rss/test.rb +126 -173
  127. data/lib/html2rss/url.rb +25 -4
  128. data/lib/html2rss/version.rb +1 -1
  129. data/lib/html2rss.rb +32 -19
  130. data/lib/tasks/config_schema.rake +2 -2
  131. data/schema/html2rss-config.schema.json +26 -8
  132. metadata +16 -6
  133. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -25,12 +25,15 @@ module Html2rss
25
25
  CHROME_DROP_THRESHOLD = 3
26
26
  private_constant :CHROME_DROP_THRESHOLD
27
27
 
28
- ##
29
- # Result of a capture operation (config plus quality meta).
30
28
  CaptureResult = Data.define(
31
29
  :config, :yaml, :articles_count, :channel_title, :has_selectors, :segment_strategy,
32
- :admission_drops, :selected_strategy, :inferred_topics, :native_feed, :suggested_channel_url
33
- ) do
30
+ :admission_drops, :selected_strategy, :inferred_topics, :native_feed, :suggested_channel_url,
31
+ :candidates
32
+ )
33
+
34
+ ##
35
+ # Result of a capture operation (config plus quality meta).
36
+ class CaptureResult
34
37
  # rubocop:disable Metrics/ParameterLists
35
38
  ##
36
39
  # @param config [Hash]
@@ -44,9 +47,10 @@ module Html2rss
44
47
  # @param inferred_topics [Array<String>]
45
48
  # @param native_feed [String, nil]
46
49
  # @param suggested_channel_url [String, nil]
50
+ # @param candidates [Hash{Symbol=>Array}, nil] ranked selector buckets from {SelectorCandidates}
47
51
  def initialize(config:, articles_count:, channel_title:, has_selectors:, segment_strategy:, # rubocop:disable Metrics/MethodLength
48
52
  yaml: nil, admission_drops: {}, selected_strategy: nil, inferred_topics: [], native_feed: nil,
49
- suggested_channel_url: nil)
53
+ suggested_channel_url: nil, candidates: nil)
50
54
  super(
51
55
  config:,
52
56
  yaml: yaml || "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
@@ -58,7 +62,8 @@ module Html2rss
58
62
  selected_strategy:,
59
63
  inferred_topics:,
60
64
  native_feed:,
61
- suggested_channel_url:
65
+ suggested_channel_url:,
66
+ candidates: candidates || SelectorCandidates.empty
62
67
  )
63
68
  end
64
69
  # rubocop:enable Metrics/ParameterLists
@@ -127,7 +132,7 @@ module Html2rss
127
132
  def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
128
133
  outcome = FeedPipeline.new(raw_config).to_outcome
129
134
  @admission_drops = outcome.admission_drops
130
- selectors, segment_strategy = derive_selectors(outcome.response, outcome.articles)
135
+ selectors, segment_strategy, candidates = derive_selectors(outcome.response, outcome.articles)
131
136
  ch_title = @title || channel_title_from(outcome.response)
132
137
  topics = @topics || infer_topics("#{@url} #{ch_title}")
133
138
  native_feed = probe_native_feed(outcome.response) unless @force
@@ -151,7 +156,8 @@ module Html2rss
151
156
  selected_strategy: outcome.selected_strategy,
152
157
  inferred_topics: topics,
153
158
  native_feed:,
154
- suggested_channel_url: suggested_channel_url(outcome)
159
+ suggested_channel_url: suggested_channel_url(outcome),
160
+ candidates:
155
161
  )
156
162
  end
157
163
 
@@ -254,40 +260,68 @@ module Html2rss
254
260
  { strategy: :local_file, request: { local_file_path: @local_file_path } }
255
261
  end
256
262
 
257
- # @return [Array(Hash, Symbol, nil)] selectors hash and winning segment strategy
263
+ # @return [Array(Hash, Symbol, nil, Hash)] selectors, segment strategy, candidate buckets
258
264
  def derive_selectors(response, articles)
259
- return hint_selectors if @items_selector_hint
260
- return [{}, nil] if articles.empty? || !response.html_response?
265
+ return [*hint_selectors, SelectorCandidates.empty] if @items_selector_hint
266
+ return [{}, nil, SelectorCandidates.empty] unless response.html_response?
267
+ return [*default_selectors, SelectorCandidates.empty] if articles.empty?
261
268
 
262
269
  sst = SST::Normalizer.call(response.body)
263
- return [{}, nil] unless sst
270
+ return [*default_selectors, SelectorCandidates.empty] unless sst
264
271
 
265
272
  select_enhance_selectors(sst, articles)
266
273
  rescue ArgumentError => error
267
274
  Log.warn("Capture selector derivation failed: #{error.message}")
268
- [{}, nil]
275
+ [*default_selectors, SelectorCandidates.empty]
269
276
  end
270
277
 
271
278
  def hint_selectors
272
279
  [{ items: { selector: @items_selector_hint, enhance: resolve_enhance } }, :hint]
273
280
  end
274
281
 
275
- def select_enhance_selectors(sst, articles) # rubocop:disable Metrics/MethodLength -- strategy loop + gate
282
+ def default_selectors
283
+ [{ items: { selector: Selectors::DEFAULT_ITEMS_SELECTOR, enhance: resolve_enhance } }, :default]
284
+ end
285
+
286
+ def select_enhance_selectors(sst, articles)
287
+ evidence = collect_items_evidence(sst, articles)
288
+ candidates = SelectorCandidates.call(items_evidence: evidence, min_matches: MIN_SELECTOR_MATCHES)
289
+ top = candidates[:items].first
290
+ return [*default_selectors, candidates] unless top
291
+
292
+ strategy = evidence.find { |entry| entry[:selector] == top[:selector] }&.fetch(:strategy)
293
+ items = { selector: top[:selector], enhance: top[:enhance] }
294
+ [{ items: }, strategy, candidates]
295
+ end
296
+
297
+ def collect_items_evidence(sst, articles)
276
298
  link_resolver = Scoring::LinkResolver.new(@url)
277
299
  enhance = resolve_enhance
278
300
 
279
- SEGMENT_STRATEGIES.each do |strategy|
301
+ SEGMENT_STRATEGIES.flat_map do |strategy|
280
302
  segments = AutoSource::Segmenter.call(
281
303
  sst, base_url: @url, strategy:, permit_unanchored: false, link_resolver:
282
304
  )
283
305
  matched = match_segments_to_articles(segments, articles)
284
- items_sel = items_selector(matched)
285
- next unless items_sel && matched.size >= MIN_SELECTOR_MATCHES
306
+ next [] if matched.size < MIN_SELECTOR_MATCHES
307
+
308
+ items_evidence_for(matched, strategy:, enhance:)
309
+ end
310
+ end
286
311
 
287
- return [{ items: { selector: items_sel, enhance: } }, strategy]
312
+ def items_evidence_for(matched, strategy:, enhance:)
313
+ roots = lift_heading_link_roots(matched.map { |pair| pair[:segment].root_node })
314
+ selector_variants_for(roots).map do |kind, selector|
315
+ { selector:, enhance:, strategy:, kind:, match_count: matched.size, roots: }
288
316
  end
317
+ end
289
318
 
290
- [{}, nil]
319
+ def selector_variants_for(roots)
320
+ [
321
+ [:shared_class, shared_class_items_selector(roots)],
322
+ [:unique_tag, unique_tag_items_selector(roots)],
323
+ [:path, path_items_selector(roots)]
324
+ ].filter_map { |kind, selector| [kind, selector] if selector }
291
325
  end
292
326
 
293
327
  def match_segments_to_articles(segments, articles)
@@ -322,21 +356,6 @@ module Html2rss
322
356
  segment.root_node.visible_text.to_s.strip
323
357
  end
324
358
 
325
- def items_selector(matched)
326
- return nil if matched.empty?
327
-
328
- roots = lift_heading_link_roots(matched.map { |m| m[:segment].root_node })
329
- shared = shared_class_items_selector(roots)
330
- return shared if shared
331
-
332
- paths = roots.map(&:tag_path)
333
- common = common_path_prefix(paths)
334
- tag_path = common.empty? ? paths.first.to_s : common
335
- return nil if tag_path.empty?
336
-
337
- css_from_trimmed_tag_path(tag_path)
338
- end
339
-
340
359
  def lift_heading_link_roots(roots)
341
360
  roots.map { |root| lift_heading_link_root(root, roots) }
342
361
  end
@@ -371,7 +390,6 @@ module Html2rss
371
390
 
372
391
  def wrapping_anchor_root?(node)
373
392
  return false unless node.name.to_s == 'a'
374
- return false unless node.respond_to?(:find)
375
393
 
376
394
  tags = Html::Navigator::WRAPPING_ANCHOR_CHILD_TAGS
377
395
  node.find { |child| !child.equal?(node) && tags.include?(child.name.to_s) }
@@ -396,6 +414,20 @@ module Html2rss
396
414
  "#{roots.first.name}.#{shared.min}"
397
415
  end
398
416
 
417
+ def unique_tag_items_selector(roots)
418
+ names = roots.map { |root| root.name.to_s }.uniq
419
+ names.join(', ') if names.size > 1
420
+ end
421
+
422
+ def path_items_selector(roots)
423
+ paths = roots.map(&:tag_path)
424
+ common = common_path_prefix(paths)
425
+ tag_path = common.empty? ? paths.first.to_s : common
426
+ return if tag_path.empty?
427
+
428
+ css_from_trimmed_tag_path(tag_path)
429
+ end
430
+
399
431
  def css_from_trimmed_tag_path(tag_path)
400
432
  segments = tag_path.split('/').reject(&:empty?)
401
433
  trimmed = trim_leading_items_segments(segments)
@@ -2,8 +2,9 @@
2
2
 
3
3
  require 'time'
4
4
 
5
- # rubocop:disable Metrics/ModuleLength -- Channel value object owns extract + freeze + Marshal
6
5
  module Html2rss
6
+ Channel = Data.define(:title, :url, :description, :language, :ttl, :last_build_date, :image, :author)
7
+
7
8
  ##
8
9
  # Materialized channel metadata for feed builders and {FeedResult}.
9
10
  #
@@ -27,7 +28,7 @@ module Html2rss
27
28
  # @return [Html2rss::Url, nil]
28
29
  # @!attribute [r] author
29
30
  # @return [String, nil]
30
- Channel = Data.define(:title, :url, :description, :language, :ttl, :last_build_date, :image, :author) do
31
+ class Channel # rubocop:disable Metrics/ClassLength -- Channel value object owns extract + freeze + Marshal
31
32
  class << self
32
33
  ##
33
34
  # Materializes channel attributes from an HTTP response and optional overrides.
@@ -193,5 +194,4 @@ module Html2rss
193
194
  Time.httpdate(value)
194
195
  end
195
196
  end
196
- # rubocop:enable Metrics/ModuleLength
197
197
  end
@@ -2,8 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  class CLI
5
- ##
6
- # Display-only probe facts for inspect and recon text cards.
7
5
  ProbeView = Data.define(
8
6
  :requested,
9
7
  :final,
@@ -15,7 +13,11 @@ module Html2rss
15
13
  :alternate_feeds,
16
14
  :notes,
17
15
  :strategy
18
- ) do
16
+ )
17
+
18
+ ##
19
+ # Display-only probe facts for inspect and recon text cards.
20
+ class ProbeView
19
21
  class << self
20
22
  ##
21
23
  # @param data [Hash] inspect wire payload
@@ -93,9 +93,11 @@ module Html2rss
93
93
  end
94
94
  end
95
95
  else
96
- warn "\e[31m✗ Test failed\e[0m (#{source})"
97
- warn " Error: #{result.error_message}" if result.error_message
98
- result.validation_errors&.each { |k, v| warn " Schema error [#{k}]: #{Array(v).join(', ')}" }
96
+ $stderr.puts "\e[31m✗ Test failed\e[0m (#{source})" # rubocop:disable Style/StderrPuts
97
+ $stderr.puts " Error: #{result.error_message}" if result.error_message # rubocop:disable Style/StderrPuts
98
+ result.validation_issues&.each do |issue|
99
+ $stderr.puts " Schema error #{issue}" # rubocop:disable Style/StderrPuts
100
+ end
99
101
  end
100
102
  end
101
103
 
@@ -115,8 +117,7 @@ module Html2rss
115
117
  if view.final && view.final != view.requested
116
118
  puts " Final: #{view.final} (HTTP #{view.status || 'ERR'})"
117
119
  end
118
- surface = view.surface.respond_to?(:to_s) ? view.surface.to_s : view.surface
119
- puts " Surface: #{surface} (#{view.articles_count} articles)"
120
+ puts " Surface: #{view.surface} (#{view.articles_count} articles)"
120
121
  end
121
122
  private_class_method :probe_lines
122
123
  end
@@ -29,11 +29,10 @@ module Html2rss
29
29
  if result.success?
30
30
  puts(target_files.size == 1 ? 'Configuration is valid' : "ok #{file}") unless quiet
31
31
  else
32
- error_details = result.errors.to_h
33
- raise Thor::Error, "Invalid configuration: #{error_details}" if target_files.size == 1
32
+ raise Thor::Error, format_failure(result) if target_files.size == 1
34
33
 
35
34
  warn "FAIL #{file}"
36
- error_details.each { |key, msg| warn " #{key}: #{Array(msg).join(', ')}" }
35
+ result.issues.each { |issue| warn " #{issue}" }
37
36
  failed << file
38
37
  end
39
38
  end
@@ -57,35 +56,37 @@ module Html2rss
57
56
  resolved.uniq
58
57
  end
59
58
 
60
- def run_named_feed(file, feed_name, params:, quiet:)
61
- result = Html2rss.validate(file, feed_name, params:)
62
- raise Thor::Error, "Invalid configuration: #{result.errors.to_h}" unless result.success?
59
+ class << self
60
+ private
63
61
 
64
- puts 'Configuration is valid' unless quiet
65
- end
66
- module_function :run_named_feed
67
- private_class_method :run_named_feed
68
-
69
- def validate_file(file, params:)
70
- if file == '-'
71
- Html2rss.validate($stdin.read, params:)
72
- else
73
- Html2rss.validate(file, params:)
62
+ def run_named_feed(file, feed_name, params:, quiet:)
63
+ result = Html2rss.validate(file, feed_name, params:)
64
+ raise Thor::Error, format_failure(result) unless result.success?
65
+
66
+ puts 'Configuration is valid' unless quiet
67
+ end
68
+
69
+ def validate_file(file, params:)
70
+ if file == '-'
71
+ Html2rss.validate($stdin.read, params:)
72
+ else
73
+ Html2rss.validate(file, params:)
74
+ end
74
75
  end
75
- end
76
- module_function :validate_file
77
- private_class_method :validate_file
78
76
 
79
- def path_like_config?(arg)
80
- path = arg.to_s
81
- return true if path.start_with?('-')
82
- return true if path.end_with?('.yml', '.yaml')
83
- return true if path.include?('/') || path.include?('*')
77
+ def format_failure(report)
78
+ "Invalid configuration: #{report}"
79
+ end
84
80
 
85
- false
81
+ def path_like_config?(arg)
82
+ path = arg.to_s
83
+ return true if path.start_with?('-')
84
+ return true if path.end_with?('.yml', '.yaml')
85
+ return true if path.include?('/') || path.include?('*')
86
+
87
+ false
88
+ end
86
89
  end
87
- module_function :path_like_config?
88
- private_class_method :path_like_config?
89
90
  end
90
91
  end
91
92
  end
data/lib/html2rss/cli.rb CHANGED
@@ -213,6 +213,9 @@ module Html2rss
213
213
  desc 'scrape [URL]', 'Automatically source an RSS feed from a URL (one-shot auto-source)'
214
214
  method_option :items_selector, type: :string, desc: 'CSS selector hint for items'
215
215
  method_option :limit, type: :numeric, desc: 'Max articles (auto-source)'
216
+ method_option :keep_different_domain, type: :boolean, default: false,
217
+ desc: 'Allow published items to link to any domain ' \
218
+ 'supplied by the source (no affiliation/safety check)'
216
219
  # @param url [String, nil]
217
220
  # @return [void]
218
221
  def scrape(url = nil)
@@ -300,7 +303,7 @@ module Html2rss
300
303
  def filter_recon_results(results, verdict_filter)
301
304
  return results unless verdict_filter
302
305
 
303
- expected = Recon::Verdict.coerce(verdict_filter.downcase)
306
+ expected = Recon::Verdict[verdict_filter.downcase]
304
307
  results.select { |r| r.verdict == expected }
305
308
  rescue ArgumentError => error
306
309
  raise Thor::Error, error.message
@@ -430,7 +433,8 @@ module Html2rss
430
433
  max_redirects: options[:max_redirects],
431
434
  max_requests: options[:max_requests],
432
435
  local_file_path:,
433
- limit: options[:limit]&.to_i
436
+ limit: options[:limit]&.to_i,
437
+ keep_different_domain: options[:keep_different_domain]
434
438
  )
435
439
  end
436
440
 
@@ -36,6 +36,9 @@ module Html2rss
36
36
  optional(:json_state).hash do
37
37
  optional(:enabled).filled(:bool)
38
38
  end
39
+ optional(:json_document).hash do
40
+ optional(:enabled).filled(:bool)
41
+ end
39
42
  optional(:xhr_articles).hash do
40
43
  optional(:enabled).filled(:bool)
41
44
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Config
5
+ ##
6
+ # Maps internal Dry::Validation messages to {ValidationIssue} (predicate → code).
7
+ # Not a public API — Dry stays inside Validator / this mapper.
8
+ module IssueMapper
9
+ # Dry schema predicates → closed {ValidationIssue::CODES}.
10
+ PREDICATE_CODES = {
11
+ key?: :missing_key,
12
+ filled?: :missing_key,
13
+ str?: :type_mismatch,
14
+ int?: :type_mismatch,
15
+ bool?: :type_mismatch,
16
+ hash?: :type_mismatch,
17
+ array?: :type_mismatch,
18
+ type?: :type_mismatch,
19
+ format?: :invalid_value,
20
+ included_in?: :unknown_value,
21
+ gt?: :constraint,
22
+ gteq?: :constraint,
23
+ lt?: :constraint,
24
+ lteq?: :constraint,
25
+ max_size?: :constraint,
26
+ min_size?: :constraint
27
+ }.freeze
28
+
29
+ class << self
30
+ ##
31
+ # @param dry_result [Dry::Validation::Result]
32
+ # @param values [Hash, nil] config under validation (for actual/expected dig)
33
+ # @return [Html2rss::Config::ValidationReport]
34
+ def from(dry_result, values: nil)
35
+ return ValidationReport.ok if dry_result.success?
36
+
37
+ ValidationReport.failure(dry_result.errors.map { |message| issue_for(message, values:) })
38
+ end
39
+
40
+ ##
41
+ # @param message [String]
42
+ # @return [Html2rss::Config::ValidationReport]
43
+ def parse_failure(message)
44
+ ValidationReport.failure(
45
+ [
46
+ ValidationIssue.new(path: %i[parse], code: :parse, message:)
47
+ ]
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ def issue_for(message, values:)
54
+ path = Array(message.path).compact
55
+ ValidationIssue.new(
56
+ path:,
57
+ code: code_for(message.predicate),
58
+ message: message.text,
59
+ expected: expected_for(path, values),
60
+ actual: actual_for(path, values, message)
61
+ )
62
+ end
63
+
64
+ def code_for(predicate)
65
+ return :invalid_value if predicate.nil?
66
+
67
+ PREDICATE_CODES.fetch(predicate) do
68
+ raise ArgumentError, "unmapped Dry validation predicate: #{predicate.inspect}"
69
+ end
70
+ end
71
+
72
+ def actual_for(path, values, message)
73
+ dug = dig_path(values, path)
74
+ return dug unless dug.nil?
75
+ return message.input if message.is_a?(Dry::Schema::Message) && !message.input.nil?
76
+
77
+ nil
78
+ end
79
+
80
+ def expected_for(path, values)
81
+ return if values.nil? || path.empty?
82
+ return unless path.last.is_a?(Symbol)
83
+
84
+ parent = dig_path(values, path[0...-1])
85
+ return unless parent.is_a?(Hash)
86
+
87
+ option = Selectors::OptionSpec.option_for(parent, path.last)
88
+ return unless option
89
+
90
+ {
91
+ type: option.json_type,
92
+ required: option.required
93
+ }
94
+ end
95
+
96
+ def dig_path(values, path)
97
+ return if values.nil? || path.empty?
98
+
99
+ path.reduce(values) do |cursor, segment|
100
+ break unless cursor.is_a?(Hash) || (cursor.is_a?(Array) && segment.is_a?(Integer))
101
+
102
+ cursor[segment]
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -33,18 +33,17 @@ module Html2rss
33
33
  }.freeze
34
34
 
35
35
  # Hop-by-hop headers forbidden in HTTP/2 requests (RFC 7540 §8.1.2.2 / RFC 9113 §8.2.1).
36
- FORBIDDEN_H2_HEADERS = %w[connection keep-alive proxy-connection transfer-encoding upgrade].to_set.freeze
36
+ FORBIDDEN_H2_HEADERS = Set['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade'].freeze
37
37
 
38
38
  class << self
39
39
  ##
40
- # :reek:ManualDispatch
41
40
  # :reek:TooManyStatements
42
41
  #
43
42
  # @return [Hash{String => String}] the default header set merged with global defaults
44
43
  def browser_defaults
45
44
  defaults = DEFAULT_HEADERS.dup
46
45
  global_headers = Html2rss.defaults.headers
47
- global_headers = global_headers.call if global_headers.respond_to?(:call)
46
+ global_headers = global_headers.call if global_headers.is_a?(Proc)
48
47
 
49
48
  if global_headers.is_a?(Hash)
50
49
  global_headers.each do |key, value|
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Html2rss
4
6
  class Config
5
7
  ##
@@ -19,6 +21,15 @@ module Html2rss
19
21
  Builder.call
20
22
  end
21
23
 
24
+ ##
25
+ # Returns the exported JSON Schema as JSON.
26
+ #
27
+ # @param pretty [Boolean] whether to pretty-print the JSON output
28
+ # @return [String] serialized JSON Schema
29
+ def json_schema_json(pretty: true)
30
+ pretty ? JSON.pretty_generate(json_schema) : JSON.generate(json_schema)
31
+ end
32
+
22
33
  ##
23
34
  # Resolves the packaged schema path used by downstream tools.
24
35
  #
@@ -107,12 +118,16 @@ module Html2rss
107
118
  # @param registry [Hash{Symbol => Class}]
108
119
  # @return [Hash{String => Hash}]
109
120
  def catalog_from_registry(registry)
110
- registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
121
+ registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_export] }
111
122
  end
112
123
 
113
124
  # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
114
125
  def overlay
115
126
  items_schema = Html2rss::Config::SelectorsValidator::Items.new.schema.json_schema(loose: true)
127
+ items_schema[:required] = Array(items_schema[:required]).reject { |key| key.to_sym == :selector }
128
+ items_schema[:properties][:selector] = items_schema.fetch(:properties).fetch(:selector).merge(
129
+ default: Html2rss::Selectors::DEFAULT_ITEMS_SELECTOR
130
+ )
116
131
  items_schema[:properties][:enhance] = items_schema.fetch(:properties).fetch(:enhance).merge(
117
132
  description: 'List-card enrichment: run Html::ArticleExtractor on each matched item node ' \
118
133
  'to fill missing fields from the card HTML.'