html2rss 0.27.2 → 0.29.1

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -25
  3. data/html2rss.gemspec +0 -1
  4. data/lib/html2rss/article.rb +1 -2
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +92 -1
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
  8. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
  9. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
  10. data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +6 -4
  13. data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
  14. data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
  15. data/lib/html2rss/auto_source/segmenter.rb +1 -2
  16. data/lib/html2rss/auto_source.rb +32 -11
  17. data/lib/html2rss/batch.rb +169 -0
  18. data/lib/html2rss/capture/README.md +15 -6
  19. data/lib/html2rss/capture.rb +158 -15
  20. data/lib/html2rss/channel.rb +1 -2
  21. data/lib/html2rss/cli/probe_view.rb +66 -0
  22. data/lib/html2rss/cli/render.rb +124 -0
  23. data/lib/html2rss/cli/validate.rb +91 -0
  24. data/lib/html2rss/cli.rb +318 -193
  25. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  26. data/lib/html2rss/config/request_headers.rb +4 -14
  27. data/lib/html2rss/config/schema.rb +7 -5
  28. data/lib/html2rss/config/validator.rb +1 -0
  29. data/lib/html2rss/config.rb +64 -18
  30. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  31. data/lib/html2rss/feed_builder/rss.rb +1 -2
  32. data/lib/html2rss/feed_pipeline/README.md +7 -5
  33. data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
  34. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  35. data/lib/html2rss/feed_pipeline.rb +48 -21
  36. data/lib/html2rss/feed_resolution/README.md +52 -0
  37. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  38. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  39. data/lib/html2rss/feed_resolution/options.rb +31 -0
  40. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  41. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  42. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  43. data/lib/html2rss/feed_resolution.rb +268 -0
  44. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  45. data/lib/html2rss/html/article_extractor.rb +4 -9
  46. data/lib/html2rss/html/feed_link.rb +4 -2
  47. data/lib/html2rss/html/navigator.rb +4 -39
  48. data/lib/html2rss/html/probe.rb +82 -0
  49. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  50. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  51. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  52. data/lib/html2rss/mcp/README.md +80 -0
  53. data/lib/html2rss/mcp/contract.rb +135 -13
  54. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  55. data/lib/html2rss/mcp/outcome.rb +112 -40
  56. data/lib/html2rss/mcp/runtime.rb +45 -0
  57. data/lib/html2rss/mcp/server/tools.rb +277 -0
  58. data/lib/html2rss/mcp/server.rb +91 -232
  59. data/lib/html2rss/mcp.rb +3 -0
  60. data/lib/html2rss/page_recon/README.md +69 -0
  61. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  62. data/lib/html2rss/page_recon.rb +297 -0
  63. data/lib/html2rss/recon.rb +252 -0
  64. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  65. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  66. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  67. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  68. data/lib/html2rss/request_service/policy.rb +1 -2
  69. data/lib/html2rss/request_service/response.rb +27 -4
  70. data/lib/html2rss/request_service/strategy.rb +1 -2
  71. data/lib/html2rss/request_service.rb +15 -1
  72. data/lib/html2rss/request_session.rb +4 -2
  73. data/lib/html2rss/scrape_target.rb +24 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  75. data/lib/html2rss/selectors/post_processors.rb +0 -1
  76. data/lib/html2rss/selectors.rb +33 -10
  77. data/lib/html2rss/sst/attrs.rb +1 -2
  78. data/lib/html2rss/sst/normalizer.rb +49 -11
  79. data/lib/html2rss/status.rb +48 -24
  80. data/lib/html2rss/surface_category.rb +63 -0
  81. data/lib/html2rss/syndication/README.md +21 -0
  82. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  83. data/lib/html2rss/syndication/discovery.rb +215 -0
  84. data/lib/html2rss/syndication/parser.rb +137 -0
  85. data/lib/html2rss/syndication.rb +10 -0
  86. data/lib/html2rss/test/enhance_audit.rb +192 -0
  87. data/lib/html2rss/test.rb +458 -0
  88. data/lib/html2rss/url.rb +23 -2
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +157 -35
  91. data/schema/html2rss-config.schema.json +47 -29
  92. metadata +34 -18
  93. data/lib/html2rss/mcp/inspect.rb +0 -241
  94. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -2,28 +2,73 @@
2
2
 
3
3
  module Html2rss
4
4
  ##
5
- # Analyzes a URL and produces a durable feed config: items selector + +enhance: true+.
5
+ # Analyzes a URL and produces a durable feed config: items selector + metadata.
6
6
  #
7
7
  # Fetches via {FeedPipeline} (including AutoFallback for +:auto+), extracts articles,
8
- # then derives a reusable items CSS selector from SST segments (list cluster semantic).
8
+ # derives reusable items CSS selectors from SST segments, and infers directory catalog metadata.
9
9
  #
10
10
  # {include:file:lib/html2rss/capture/README.md}
11
11
  class Capture # rubocop:disable Metrics/ClassLength -- segment strategies + CSS trim stay co-located
12
12
  LEADING_TRIM_TAGS = %w[html body].freeze
13
13
  private_constant :LEADING_TRIM_TAGS
14
14
 
15
+ # Packaged YAML language server modeline.
16
+ SCHEMA_MODELINE = '# yaml-language-server: $schema=https://raw.githubusercontent.com/html2rss/html2rss/refs/heads/master/schema/html2rss-config.schema.json'
17
+
15
18
  # Ordered Segmenter strategies tried until the items selector quality gate passes.
16
19
  SEGMENT_STRATEGIES = %i[list cluster semantic].freeze
17
20
 
18
21
  # Minimum matched segment/article pairs required to emit an items selector.
19
22
  MIN_SELECTOR_MATCHES = 2
20
23
 
24
+ # Admission drops at or above this sum default enhance to false (chrome-heavy listing).
25
+ CHROME_DROP_THRESHOLD = 3
26
+ private_constant :CHROME_DROP_THRESHOLD
27
+
21
28
  ##
22
29
  # Result of a capture operation (config plus quality meta).
23
30
  CaptureResult = Data.define(
24
- :config, :articles_count, :channel_title, :has_selectors, :segment_strategy,
25
- :admission_drops, :selected_strategy
26
- )
31
+ :config, :yaml, :articles_count, :channel_title, :has_selectors, :segment_strategy,
32
+ :admission_drops, :selected_strategy, :inferred_topics, :native_feed, :suggested_channel_url
33
+ ) do
34
+ # rubocop:disable Metrics/ParameterLists
35
+ ##
36
+ # @param config [Hash]
37
+ # @param articles_count [Integer]
38
+ # @param channel_title [String]
39
+ # @param has_selectors [Boolean]
40
+ # @param segment_strategy [Symbol]
41
+ # @param yaml [String, nil]
42
+ # @param admission_drops [Hash]
43
+ # @param selected_strategy [Symbol, nil]
44
+ # @param inferred_topics [Array<String>]
45
+ # @param native_feed [String, nil]
46
+ # @param suggested_channel_url [String, nil]
47
+ def initialize(config:, articles_count:, channel_title:, has_selectors:, segment_strategy:, # rubocop:disable Metrics/MethodLength
48
+ yaml: nil, admission_drops: {}, selected_strategy: nil, inferred_topics: [], native_feed: nil,
49
+ suggested_channel_url: nil)
50
+ super(
51
+ config:,
52
+ yaml: yaml || "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
53
+ articles_count:,
54
+ channel_title:,
55
+ has_selectors:,
56
+ segment_strategy:,
57
+ admission_drops:,
58
+ selected_strategy:,
59
+ inferred_topics:,
60
+ native_feed:,
61
+ suggested_channel_url:
62
+ )
63
+ end
64
+ # rubocop:enable Metrics/ParameterLists
65
+
66
+ ##
67
+ # @return [Boolean] whether first-party RSS was detected
68
+ def native_feed?
69
+ !native_feed.nil?
70
+ end
71
+ end
27
72
 
28
73
  class << self
29
74
  ##
@@ -32,6 +77,11 @@ module Html2rss
32
77
  # @param url [String] source page URL
33
78
  # @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
34
79
  # @option options [String, nil] :items_selector optional selector hint
80
+ # @option options [Array<String>, nil] :topics optional directory topics override
81
+ # @option options [String, nil] :title optional title override
82
+ # @option options [String, nil] :summary optional summary override
83
+ # @option options [Boolean] :force whether to ignore native feed detection
84
+ # @option options [Boolean, nil] :enhance whether to force enhance: true
35
85
  # @option options [Integer, nil] :max_redirects optional redirect limit override
36
86
  # @option options [Integer, nil] :max_requests optional request budget override
37
87
  # @option options [Integer, nil] :limit max articles to keep
@@ -45,16 +95,25 @@ module Html2rss
45
95
  ##
46
96
  # @param url [String] source page URL
47
97
  # @param strategy [Symbol] request strategy
48
- # @param options [Hash] additional options
49
98
  # @option options [String, nil] :items_selector optional selector hint
99
+ # @option options [Array<String>, nil] :topics optional directory topics override
100
+ # @option options [String, nil] :title optional title override
101
+ # @option options [String, nil] :summary optional summary override
102
+ # @option options [Boolean] :force whether to ignore native feed detection
103
+ # @option options [Boolean, nil] :enhance whether to force enhance: true
50
104
  # @option options [Integer, nil] :max_redirects optional redirect limit override
51
105
  # @option options [Integer, nil] :max_requests optional request budget override
52
106
  # @option options [Integer, nil] :limit max articles to keep
53
107
  # @option options [String, nil] :local_file_path optional local HTML file path
54
- def initialize(url, strategy: :auto, **options)
108
+ def initialize(url, strategy: :auto, **options) # rubocop:disable Metrics/MethodLength
55
109
  @url = url
56
110
  @strategy = strategy
57
111
  @items_selector_hint = options.delete(:items_selector)
112
+ @topics = options.delete(:topics)
113
+ @title = options.delete(:title)
114
+ @summary = options.delete(:summary)
115
+ @force = options.delete(:force) || false
116
+ @enhance_option = options.delete(:enhance)
58
117
  @max_redirects = options.delete(:max_redirects)
59
118
  @max_requests = options.delete(:max_requests)
60
119
  @limit = options.delete(:limit)
@@ -65,12 +124,17 @@ module Html2rss
65
124
  # Runs the capture pipeline.
66
125
  #
67
126
  # @return [CaptureResult]
68
- def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- outcome + selector + result assembly
127
+ def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
69
128
  outcome = FeedPipeline.new(raw_config).to_outcome
129
+ @admission_drops = outcome.admission_drops
70
130
  selectors, segment_strategy = derive_selectors(outcome.response, outcome.articles)
131
+ ch_title = @title || channel_title_from(outcome.response)
132
+ topics = @topics || infer_topics("#{@url} #{ch_title}")
133
+ native_feed = probe_native_feed(outcome.response) unless @force
71
134
 
72
135
  config = {
73
- channel: build_channel(outcome.response),
136
+ directory: build_directory(ch_title, topics),
137
+ channel: build_channel(outcome.response, ch_title),
74
138
  selectors: selectors.empty? ? nil : selectors,
75
139
  **strategy_stamp(outcome),
76
140
  **local_file_request_overlay
@@ -78,17 +142,75 @@ module Html2rss
78
142
 
79
143
  CaptureResult.new(
80
144
  config:,
145
+ yaml: "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
81
146
  articles_count: outcome.articles.size,
82
- channel_title: channel_title_from(outcome.response),
147
+ channel_title: ch_title,
83
148
  has_selectors: !selectors.empty?,
84
149
  segment_strategy:,
85
150
  admission_drops: outcome.admission_drops,
86
- selected_strategy: outcome.selected_strategy
151
+ selected_strategy: outcome.selected_strategy,
152
+ inferred_topics: topics,
153
+ native_feed:,
154
+ suggested_channel_url: suggested_channel_url(outcome)
87
155
  )
88
156
  end
89
157
 
90
158
  private
91
159
 
160
+ def build_directory(title, topics)
161
+ summary_text = @summary || "#{title} updates and announcements."
162
+ {
163
+ topics: topics.take(2),
164
+ title:,
165
+ summary: summary_text[0, 160]
166
+ }.compact
167
+ end
168
+
169
+ def probe_native_feed(response)
170
+ return nil unless response && @url
171
+
172
+ Syndication::Discovery.best_feed_url(
173
+ page_url: @url,
174
+ request_session: discovery_session,
175
+ parsed_body: (response.parsed_body if response.html_response?),
176
+ html: response.body
177
+ )&.to_s
178
+ rescue StandardError
179
+ nil
180
+ end
181
+
182
+ def discovery_session
183
+ config = Config.from_hash(raw_config)
184
+ resources = FeedPipeline::RuntimePolicy.resources_for(config)
185
+ strategy = FeedPipeline::StrategyPlan.concrete_for_diagnostic(@strategy)
186
+ RequestSession.build(
187
+ config:,
188
+ strategy:,
189
+ budget: resources.budget,
190
+ policy: resources.policy
191
+ )
192
+ end
193
+
194
+ def infer_topics(text) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/MethodLength
195
+ t = text.to_s.downcase
196
+ inferred = []
197
+ inferred << 'tech' if /tech|developer|software|cloud|api|compute|ai|data|model|hardware|cyber/.match?(t)
198
+ inferred << 'security' if /security|vulnerab|advis|threat|breach|cve|cert|patch/.match?(t)
199
+ inferred << 'science' if /science|space|astron|physics|bio|research|institut|discover|nature/.match?(t)
200
+ inferred << 'energy' if /energy|wind|solar|power|grid|renewab|oil|gas|hydro/.match?(t)
201
+ inferred << 'finance' if /financ|bank|invest|monetary|market|regulat|treasury|econom/.match?(t)
202
+ inferred << 'civic' if /govern|policy|council|parliament|court|treaty|public/.match?(t)
203
+ inferred << 'environment' if /climat|environ|planet|sustainab|carbon|ecolog|earth/.match?(t)
204
+ inferred << 'consumer' if /consum|recall|safety|rating|test|product/.match?(t)
205
+ inferred << 'travel' if /travel|touris|destination|trip|flight|hotel|transit|rail/.match?(t)
206
+ inferred << 'sports' if /sport|olympic|racing|football|soccer|tennis|game/.match?(t)
207
+ inferred << 'health' if /health|medic|pharma|hospital|disease|patient/.match?(t)
208
+ inferred << 'education' if /educat|universit|school|student|academi|learn/.match?(t)
209
+ inferred << 'transport' if /transport|traffic|automotive|vehicle|train|bus/.match?(t)
210
+ valid = inferred.select { |topic| Config::Validator::DIRECTORY_TOPICS.include?(topic) }
211
+ valid.empty? ? ['news'] : valid.first(2)
212
+ end
213
+
92
214
  def strategy_stamp(outcome)
93
215
  concrete = outcome.selected_strategy || concrete_request_strategy
94
216
  return {} if concrete.nil? || concrete == :auto
@@ -147,11 +269,12 @@ module Html2rss
147
269
  end
148
270
 
149
271
  def hint_selectors
150
- [{ items: { selector: @items_selector_hint, enhance: true } }, :hint]
272
+ [{ items: { selector: @items_selector_hint, enhance: resolve_enhance } }, :hint]
151
273
  end
152
274
 
153
275
  def select_enhance_selectors(sst, articles) # rubocop:disable Metrics/MethodLength -- strategy loop + gate
154
276
  link_resolver = Scoring::LinkResolver.new(@url)
277
+ enhance = resolve_enhance
155
278
 
156
279
  SEGMENT_STRATEGIES.each do |strategy|
157
280
  segments = AutoSource::Segmenter.call(
@@ -161,7 +284,7 @@ module Html2rss
161
284
  items_sel = items_selector(matched)
162
285
  next unless items_sel && matched.size >= MIN_SELECTOR_MATCHES
163
286
 
164
- return [{ items: { selector: items_sel, enhance: true } }, strategy]
287
+ return [{ items: { selector: items_sel, enhance: } }, strategy]
165
288
  end
166
289
 
167
290
  [{}, nil]
@@ -301,10 +424,10 @@ module Html2rss
301
424
  "/#{prefix.join('/')}"
302
425
  end
303
426
 
304
- def build_channel(response)
427
+ def build_channel(response, title = nil)
305
428
  {
306
429
  url: @url,
307
- title: channel_title_from(response),
430
+ title: title || channel_title_from(response),
308
431
  time_zone: 'UTC'
309
432
  }.compact
310
433
  end
@@ -314,5 +437,25 @@ module Html2rss
314
437
  rescue StandardError
315
438
  nil
316
439
  end
440
+
441
+ def resolve_enhance
442
+ return @enhance_option unless @enhance_option.nil?
443
+
444
+ drops = (@admission_drops || {}).values.sum
445
+ drops < CHROME_DROP_THRESHOLD
446
+ end
447
+
448
+ def suggested_channel_url(outcome)
449
+ entry = outcome.scrape_target.entry_url.to_s
450
+ effective = outcome.scrape_target.effective_url.to_s
451
+ return effective unless Url.from_absolute(entry).same_document?(Url.from_absolute(effective))
452
+
453
+ final = outcome.response.url.to_s
454
+ return final unless Url.from_absolute(@url).same_document?(Url.from_absolute(final))
455
+
456
+ nil
457
+ rescue ArgumentError
458
+ nil
459
+ end
317
460
  end
318
461
  end
@@ -155,7 +155,7 @@ module Html2rss
155
155
  # @param last_build_date [Time, String]
156
156
  # @param image [Html2rss::Url, String, nil]
157
157
  # @param author [String, nil]
158
- # rubocop:disable Metrics/ParameterLists -- Data.define members are the channel contract
158
+ # rubocop:disable-next Metrics/ParameterLists -- Data.define members are the channel contract
159
159
  def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
160
160
  super(
161
161
  title: freeze_string(title),
@@ -168,7 +168,6 @@ module Html2rss
168
168
  author: author && freeze_string(author)
169
169
  )
170
170
  end
171
- # rubocop:enable Metrics/ParameterLists
172
171
 
173
172
  private
174
173
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class CLI
5
+ ##
6
+ # Display-only probe facts for inspect and recon text cards.
7
+ ProbeView = Data.define(
8
+ :requested,
9
+ :final,
10
+ :status,
11
+ :surface,
12
+ :articles_count,
13
+ :verdict,
14
+ :native_feed,
15
+ :alternate_feeds,
16
+ :notes,
17
+ :strategy
18
+ ) do
19
+ class << self
20
+ ##
21
+ # @param data [Hash] inspect wire payload
22
+ # @return [ProbeView]
23
+ def from_wire(data) # rubocop:disable Metrics/MethodLength
24
+ new(
25
+ requested: wire_val(data, :requested_url),
26
+ final: wire_val(data, :final_url),
27
+ status: wire_val(data, :status),
28
+ surface: wire_val(data, :surface_category),
29
+ articles_count: wire_val(data, :articles_count),
30
+ verdict: nil,
31
+ native_feed: nil,
32
+ alternate_feeds: wire_val(data, :alternate_feeds) || [],
33
+ notes: [],
34
+ strategy: wire_val(data, :strategy)
35
+ )
36
+ end
37
+
38
+ ##
39
+ # @param result [Html2rss::Recon::Result]
40
+ # @return [ProbeView]
41
+ def from_recon(result) # rubocop:disable Metrics/MethodLength
42
+ new(
43
+ requested: result.requested_url.to_s,
44
+ final: result.final_url.to_s,
45
+ status: result.status,
46
+ surface: result.surface_category,
47
+ articles_count: result.articles_count,
48
+ verdict: result.verdict,
49
+ native_feed: result.native_feed&.to_s,
50
+ alternate_feeds: [],
51
+ notes: result.notes,
52
+ strategy: nil
53
+ )
54
+ end
55
+
56
+ ##
57
+ # @param data [Hash]
58
+ # @param key [Symbol]
59
+ # @return [Object, nil]
60
+ def wire_val(data, key)
61
+ data[key] || data[key.to_s]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Html2rss
6
+ class CLI
7
+ ##
8
+ # Text and JSON rendering for CLI command output (display only; no policy).
9
+ module Render
10
+ # ANSI colors for recon verdict labels in text output.
11
+ VERDICT_COLORS = { build: "\e[32m", defer: "\e[33m" }.freeze
12
+
13
+ module_function
14
+
15
+ ##
16
+ # @param results [Array<Hash>]
17
+ # @param format [String]
18
+ # @param batch_mode [Boolean]
19
+ # @return [void]
20
+ def inspect_output(results, format:, batch_mode:)
21
+ if format == 'json'
22
+ json(results, batch_mode)
23
+ else
24
+ results.each { |data| probe_card(ProbeView.from_wire(data)) }
25
+ end
26
+ end
27
+
28
+ ##
29
+ # @param results [Array<Html2rss::Recon::Result>]
30
+ # @param format [String]
31
+ # @param batch_mode [Boolean]
32
+ # @param url_only [Boolean]
33
+ # @return [void]
34
+ def recon_output(results, format:, batch_mode:, url_only: false) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
35
+ if url_only
36
+ results.each { |r| puts r.requested_url }
37
+ elsif format == 'json'
38
+ json(results.map(&:to_h), batch_mode)
39
+ elsif format == 'tsv'
40
+ puts %w[verdict status requested_url final_url native_feed notes].join("\t")
41
+ results.each do |r|
42
+ row = [
43
+ r.verdict.to_s.upcase,
44
+ r.status || '-',
45
+ r.requested_url,
46
+ r.final_url,
47
+ r.native_feed || '-',
48
+ r.notes.join('; ')
49
+ ]
50
+ puts row.join("\t")
51
+ end
52
+ else
53
+ results.each { |r| probe_card(ProbeView.from_recon(r)) }
54
+ end
55
+ end
56
+
57
+ ##
58
+ # @param view [ProbeView]
59
+ # @return [void]
60
+ def probe_card(view) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
61
+ if view.verdict
62
+ color = VERDICT_COLORS.fetch(view.verdict.to_sym, "\e[31m")
63
+ puts "#{color}[#{view.verdict.to_s.upcase}]\e[0m #{view.requested}"
64
+ else
65
+ puts view.requested
66
+ end
67
+ probe_lines(view)
68
+ if view.alternate_feeds.any?
69
+ hrefs = view.alternate_feeds.filter_map { |f| ProbeView.wire_val(f, :href) }
70
+ puts " Feeds: #{hrefs.join(', ')}" if hrefs.any?
71
+ end
72
+ puts " Feed: #{view.native_feed}" if view.native_feed
73
+ puts " Notes: #{view.notes.join(', ')}" if view.notes.any?
74
+ puts " Strategy: #{view.strategy}" if view.strategy
75
+ puts ''
76
+ end
77
+
78
+ ##
79
+ # @param result [Html2rss::Test::Result]
80
+ # @param source [String]
81
+ # @return [void]
82
+ def test_card(result, source) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
83
+ if result.success
84
+ puts "\e[32m✓ Schema valid\e[0m (#{source})"
85
+ dur = "#{result.duration_seconds}s"
86
+ puts "\e[32m✓ Extracted #{result.item_count} items in #{dur}\e[0m (strategy: #{result.strategy_used})"
87
+ puts "\nChannel: #{result.channel_title} (#{result.channel_url})"
88
+ if result.sample_items.any?
89
+ puts 'Sample items:'
90
+ result.sample_items.each_with_index do |item, i|
91
+ puts " #{i + 1}. #{"#{item[:published_at]} | " if item[:published_at]}#{item[:title]}"
92
+ puts " #{item[:url]}"
93
+ end
94
+ end
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(', ')}" }
99
+ end
100
+ end
101
+
102
+ ##
103
+ # @param data [Object]
104
+ # @param batch_mode [Boolean]
105
+ # @return [void]
106
+ def json(data, batch_mode)
107
+ payload = batch_mode ? data : data.first
108
+ puts JSON.pretty_generate(payload)
109
+ end
110
+
111
+ ##
112
+ # @param view [ProbeView]
113
+ # @return [void]
114
+ def probe_lines(view)
115
+ if view.final && view.final != view.requested
116
+ puts " Final: #{view.final} (HTTP #{view.status || 'ERR'})"
117
+ end
118
+ surface = view.surface.respond_to?(:to_s) ? view.surface.to_s : view.surface
119
+ puts " Surface: #{surface} (#{view.articles_count} articles)"
120
+ end
121
+ private_class_method :probe_lines
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class CLI
5
+ ##
6
+ # Validate command: file/glob/stdin branching and multi-file reporting.
7
+ module Validate
8
+ module_function
9
+
10
+ ##
11
+ # @param files [Array<String>]
12
+ # @param params [Hash]
13
+ # @param quiet [Boolean]
14
+ # @return [void]
15
+ # @raise [Thor::Error]
16
+ def run(files, params:, quiet:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
17
+ if files.size == 2 && File.file?(files[0].to_s) && !File.exist?(files[1].to_s)
18
+ raise Thor::Error, "No such file: #{files[1]}" if path_like_config?(files[1])
19
+
20
+ return run_named_feed(files[0], files[1], params:, quiet:)
21
+ end
22
+
23
+ target_files = resolve_files(files)
24
+ failed = []
25
+
26
+ target_files.each do |file|
27
+ result = validate_file(file, params:)
28
+
29
+ if result.success?
30
+ puts(target_files.size == 1 ? 'Configuration is valid' : "ok #{file}") unless quiet
31
+ else
32
+ error_details = result.errors.to_h
33
+ raise Thor::Error, "Invalid configuration: #{error_details}" if target_files.size == 1
34
+
35
+ warn "FAIL #{file}"
36
+ error_details.each { |key, msg| warn " #{key}: #{Array(msg).join(', ')}" }
37
+ failed << file
38
+ end
39
+ end
40
+
41
+ return if failed.empty?
42
+
43
+ raise Thor::Error, "#{failed.size}/#{target_files.size} configurations failed validation."
44
+ end
45
+
46
+ ##
47
+ # @param files [Array<String>]
48
+ # @return [Array<String>]
49
+ def resolve_files(files)
50
+ return ['-'] if files.empty? || files == ['-']
51
+
52
+ resolved = []
53
+ files.each do |f|
54
+ matched = Dir.glob(f)
55
+ resolved.concat(matched.empty? ? [f] : matched)
56
+ end
57
+ resolved.uniq
58
+ end
59
+
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?
63
+
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:)
74
+ end
75
+ end
76
+ module_function :validate_file
77
+ private_class_method :validate_file
78
+
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?('*')
84
+
85
+ false
86
+ end
87
+ module_function :path_like_config?
88
+ private_class_method :path_like_config?
89
+ end
90
+ end
91
+ end