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
data/lib/html2rss/cli.rb CHANGED
@@ -9,202 +9,345 @@ module Html2rss
9
9
  # The Html2rss command line interface.
10
10
  class CLI < Thor # rubocop:disable Metrics/ClassLength
11
11
  check_unknown_options!
12
- # Supported CLI strategy plan option values (:auto plus concrete strategies).
13
- #
14
- # @return [Array<String>]
12
+
13
+ map 'feed' => 'apply'
14
+ map 'auto' => 'scrape'
15
+
16
+ # Supported CLI strategy names.
15
17
  STRATEGY_OPTION_ENUM = Html2rss::FeedPipeline::StrategyPlan.accepted_names.map(&:to_s).freeze
18
+ # CLI strategy option description text.
19
+ STRATEGY_OPTION_DESC =
20
+ 'Optional request strategy (defaults to auto; ' \
21
+ "auto tries #{Html2rss::FeedPipeline::AutoFallback::CHAIN.join(' -> ')})".freeze
16
22
 
17
- # User-facing strategy help text that reflects the current fallback chain.
18
- #
19
- # @return [String]
20
- STRATEGY_OPTION_DESC = [
21
- 'Optional request strategy (defaults to auto; auto tries',
22
- "#{Html2rss::FeedPipeline::AutoFallback::CHAIN.join(' -> ')})"
23
- ].join(' ').freeze
23
+ # CLI `--file` description for inspect/recon candidate lists.
24
+ RECON_FILE_DESC = 'Candidate list file (one URL or slug\turl per line)'
24
25
 
25
- # @return [Boolean] whether Thor should terminate process on command failures
26
+ ##
27
+ # @return [Boolean] whether Thor should exit on command failure
26
28
  def self.exit_on_failure?
27
29
  true
28
30
  end
29
31
 
30
- desc 'feed YAML_FILE [feed_name]', 'Print RSS built from the YAML_FILE file to stdout'
31
- method_option :params,
32
- type: :hash,
33
- optional: true,
34
- required: false,
35
- default: {}
36
- method_option :strategy,
37
- type: :string,
38
- desc: STRATEGY_OPTION_DESC,
39
- enum: STRATEGY_OPTION_ENUM
40
- method_option :max_redirects,
41
- type: :numeric,
42
- desc: 'Maximum redirects to follow per request'
43
- method_option :max_requests,
44
- type: :numeric,
45
- desc: 'Maximum requests to allow for this feed build'
46
- method_option :input,
47
- type: :string,
48
- desc: 'Local HTML file path to read input from'
49
- # @param yaml_file [String] path to YAML config
50
- # @param feed_name [String, nil] optional named feed in multi-feed config
32
+ # Shared Thor options for inspect/recon URL targets.
33
+ def self.probe_target_options
34
+ method_option :strategy, type: :string, desc: STRATEGY_OPTION_DESC, enum: STRATEGY_OPTION_ENUM
35
+ method_option :file, type: :string, desc: RECON_FILE_DESC
36
+ end
37
+
38
+ # Shared Thor request/transport options for feed-producing commands.
39
+ def self.feed_transport_options
40
+ method_option :strategy, type: :string, desc: STRATEGY_OPTION_DESC, enum: STRATEGY_OPTION_ENUM
41
+ method_option :max_redirects, type: :numeric, desc: 'Max redirects to follow'
42
+ method_option :max_requests, type: :numeric, desc: 'Max request budget'
43
+ method_option :input, type: :string, desc: 'Local HTML file path'
44
+ end
45
+
46
+ # Shared Thor output options for apply/scrape.
47
+ def self.feed_emit_options
48
+ method_option :format, type: :string, enum: %w[rss jsonfeed], default: 'rss'
49
+ method_option :explain, type: :boolean, desc: 'Print status JSON to stderr', default: false
50
+ end
51
+
52
+ probe_target_options
53
+ desc 'inspect [TARGET]', 'Fetch diagnostics for a URL (final URL, status, alternates, surface)'
54
+ method_option :format, type: :string, enum: %w[text json], default: 'text'
55
+ method_option :deep, type: :boolean, default: false,
56
+ desc: 'Single Botasaurus diagnostic hop when BOTASAURUS_SCRAPER_URL is set'
57
+ # @param target [String, nil] URL, file, or '-' for stdin
51
58
  # @return [void]
52
- def feed(yaml_file, feed_name = nil)
53
- config = Html2rss.config_from_yaml_file(yaml_file, feed_name)
54
- config[:params] = options[:params] || {}
55
- apply_runtime_request_overrides!(config)
56
- apply_local_file_input!(config, options[:input]) if options[:input]
59
+ def inspect(target = nil)
60
+ with_recon_targets(target) do |urls, batch_mode|
61
+ results = if batch_mode
62
+ Html2rss.batch_inspect(urls, strategy: current_strategy).results
63
+ else
64
+ [Html2rss.inspect(urls.first, strategy: current_strategy, deep: options[:deep]).to_wire_h]
65
+ end
66
+ Render.inspect_output(results, format: options[:format], batch_mode:)
67
+ end
68
+ end
57
69
 
58
- puts(execute_feed { Html2rss.feed(config) })
59
- end
60
-
61
- desc 'auto [URL]', 'Automatically sources an RSS feed from the URL'
62
- method_option :strategy,
63
- type: :string,
64
- desc: STRATEGY_OPTION_DESC,
65
- enum: STRATEGY_OPTION_ENUM
66
- method_option :format,
67
- type: :string,
68
- desc: 'Output format for the auto-sourced feed',
69
- enum: %w[rss jsonfeed],
70
- default: 'rss'
71
- method_option :items_selector, type: :string, desc: 'CSS selector for items (will be enhanced) (optional)'
72
- method_option :max_redirects,
73
- type: :numeric,
74
- desc: 'Maximum redirects to follow per request'
75
- method_option :max_requests,
76
- type: :numeric,
77
- desc: 'Maximum requests to allow for this feed build'
78
- method_option :limit,
79
- type: :numeric,
80
- desc: 'Maximum number of articles to keep (default: 25)'
81
- method_option :input,
82
- type: :string,
83
- desc: 'Local HTML file path to read input from'
84
- method_option :explain,
85
- type: :boolean,
86
- desc: 'Print Status JSON to stderr (stdout stays the feed)',
87
- default: false
88
- # @param url [String, nil] source page URL for auto discovery
70
+ probe_target_options
71
+ desc 'recon [TARGET]', 'Probe a URL or candidate list for redirect chains, native feeds, and surface readiness'
72
+ method_option :cache_dir, type: :string, desc: 'Directory to cache raw HTML snapshots'
73
+ method_option :verdict, type: :string, desc: 'Filter batch output by verdict (BUILD, DEFER, DROP)'
74
+ method_option :url_only, type: :boolean, desc: 'Emit only URLs matching verdict (for pipe chaining)', default: false
75
+ method_option :format, type: :string, enum: %w[text json tsv], default: 'text'
76
+ # @param target [String, nil] URL, file, or '-' for stdin
89
77
  # @return [void]
90
- def auto(url = nil)
91
- format = options.fetch(:format, 'rss')
92
- strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
93
- feed_result = execute_feed { auto_feed_result_for(url, strategy, local_file_path) }
94
-
95
- explain_status!(feed_result.status) if options[:explain]
96
- puts(format == 'jsonfeed' ? JSON.pretty_generate(feed_result.to_json_feed) : feed_result.to_rss)
97
- end
98
-
99
- desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
100
- method_option :strategy,
101
- type: :string,
102
- desc: STRATEGY_OPTION_DESC,
103
- enum: STRATEGY_OPTION_ENUM
104
- method_option :items_selector, type: :string, desc: 'CSS selector hint for items (optional)'
105
- method_option :max_redirects,
106
- type: :numeric,
107
- desc: 'Maximum redirects to follow per request'
108
- method_option :max_requests,
109
- type: :numeric,
110
- desc: 'Maximum requests to allow for this feed build'
111
- method_option :limit,
112
- type: :numeric,
113
- desc: 'Maximum number of articles to keep (default: 25)'
114
- method_option :input,
115
- type: :string,
116
- desc: 'Local HTML file path to read input from'
117
- method_option :explain,
118
- type: :boolean,
119
- desc: 'Print capture quality JSON to stderr (stdout stays YAML)',
120
- default: false
121
- ##
122
- # Captures a URL and prints a reusable YAML config.
123
- #
124
- # @param url [String, nil] source page URL for capture
78
+ def recon(target = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
79
+ with_recon_targets(target) do |urls, batch_mode|
80
+ results = Html2rss::Recon.batch(
81
+ urls,
82
+ strategy: current_strategy,
83
+ cache_dir: options[:cache_dir]
84
+ )
85
+ filtered = filter_recon_results(results, options[:verdict])
86
+ assert_recon_verdict_match!(filtered, results, batch_mode:)
87
+
88
+ Render.recon_output(
89
+ filtered,
90
+ format: options[:format],
91
+ batch_mode:,
92
+ url_only: options[:url_only]
93
+ )
94
+
95
+ return if batch_mode || filtered.empty?
96
+
97
+ exit(3) if filtered.first.defer?
98
+ exit(1) if filtered.first.drop?
99
+ end
100
+ end
101
+
102
+ feed_transport_options
103
+ desc 'capture [TARGET]', 'Analyze a URL or HTML and output a curated YAML feed config'
104
+ method_option :items_selector, type: :string, desc: 'CSS selector hint for items'
105
+ method_option :output_dir, aliases: '-o', type: :string,
106
+ desc: 'Base directory to write <domain>/index.yml'
107
+ method_option :write, aliases: '-w', type: :string, desc: 'Specific file path to write YAML to'
108
+ method_option :topics, aliases: '-t', type: :string, desc: 'Comma-separated directory topics'
109
+ method_option :title, type: :string, desc: 'Directory and channel title override'
110
+ method_option :summary, type: :string, desc: 'Directory summary override'
111
+ method_option :enhance, type: :boolean, desc: 'Force enhance: true on items selector'
112
+ method_option :force, type: :boolean, desc: 'Bypass native feed check', default: false
113
+ method_option :limit, type: :numeric, desc: 'Article limit (default: 25)'
114
+ method_option :explain, type: :boolean, desc: 'Print capture diagnostics to stderr', default: false
115
+ # @param target [String, nil] URL or local HTML file
125
116
  # @return [void]
126
- def capture(url = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- CLI option wiring
127
- strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
117
+ def capture(target = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
118
+ strategy, local_file_path, url = prepare_auto_inputs(target, options[:input])
119
+ topics = options[:topics]&.split(',')&.map(&:strip)
120
+
128
121
  result = Html2rss::Capture.build(
129
122
  url,
130
123
  strategy:,
131
124
  items_selector: options[:items_selector],
125
+ topics:,
126
+ title: options[:title],
127
+ summary: options[:summary],
128
+ force: options[:force],
129
+ enhance: options[:enhance],
132
130
  limit: options[:limit]&.to_i,
133
131
  max_redirects: options[:max_redirects],
134
132
  max_requests: options[:max_requests],
135
133
  local_file_path:
136
134
  )
137
- explain_capture!(result) if options[:explain]
138
- puts Config.to_yaml(result.config)
139
- end
140
-
141
- desc 'schema', 'Print the exported config JSON Schema'
142
- method_option :pretty,
143
- type: :boolean,
144
- desc: 'Pretty-print the schema JSON',
145
- default: true
146
- method_option :write,
147
- type: :string,
148
- desc: 'Write the schema JSON to the given file path'
149
- ##
150
- # Prints or writes the exported configuration JSON Schema.
151
- #
135
+
136
+ 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
138
+ exit(3)
139
+ end
140
+
141
+ explain_json!(capture_explain_payload(result)) if options[:explain]
142
+ handle_capture_output(result, url)
143
+ end
144
+
145
+ desc 'test [CONFIG_INPUT] [feed_name]', 'Validate schema AND execute live extraction (fails on 0 items)'
146
+ method_option :params, type: :hash, default: {}
147
+ method_option :min_items, type: :numeric, default: 1, desc: 'Minimum required articles to pass'
148
+ method_option :strict_quality, type: :boolean, default: false,
149
+ desc: 'Fail when ship-quality audit thresholds are exceeded'
150
+ method_option :compare_enhance, type: :boolean, default: false,
151
+ desc: 'Compare extraction with enhance on vs off (diagnostic only)'
152
+ method_option :strategy, type: :string, desc: STRATEGY_OPTION_DESC, enum: STRATEGY_OPTION_ENUM
153
+ method_option :json, type: :boolean, desc: 'Output test outcome as JSON', default: false
154
+ method_option :xml, type: :boolean, desc: 'Dump RSS XML alongside summary', default: false
155
+ method_option :quiet, aliases: '-q', type: :boolean, default: false
156
+ # @param config_input [String, nil] YAML file path, content, or '-' for stdin
157
+ # @param feed_name [String, nil] optional feed name
152
158
  # @return [void]
153
- def schema
154
- schema_json = Html2rss::Config.json_schema_json(pretty: options.fetch(:pretty, true))
159
+ def test(config_input = nil, feed_name = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
160
+ 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
+ )
170
+
171
+ if options[:json]
172
+ puts JSON.pretty_generate(result.to_h)
173
+ elsif options[:quiet]
174
+ $stderr.puts(result.error_message) unless result.success # rubocop:disable Style/StderrPuts
175
+ elsif input_source == 'stdin' && !$stdout.tty? && !options[:xml]
176
+ # Piped filter: echo config to stdout on success, write failure to stderr
177
+ if result.success
178
+ puts raw_content
179
+ else
180
+ $stderr.puts "Test failed: #{result.error_message}" # rubocop:disable Style/StderrPuts
181
+ end
182
+ else
183
+ Render.test_card(result, input_source)
184
+ puts result.rss if options[:xml] && result.success
185
+ end
186
+
187
+ raise Thor::Error, (result.error_message || 'Test failed') unless result.success
188
+ end
189
+
190
+ feed_transport_options
191
+ feed_emit_options
192
+ desc 'apply [CONFIG_INPUT] [feed_name]', 'Print RSS built from YAML config to stdout'
193
+ method_option :params, type: :hash, default: {}
194
+ # @param source [String, nil] YAML file path or '-'
195
+ # @param feed_name [String, nil] optional named feed
196
+ # @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
204
+ config[:params] = options[:params] || {}
205
+ apply_runtime_request_overrides!(config)
206
+ apply_local_file_input!(config, options[:input]) if options[:input]
207
+
208
+ run_feed_command { Html2rss.apply(config) }
209
+ end
210
+
211
+ feed_transport_options
212
+ feed_emit_options
213
+ desc 'scrape [URL]', 'Automatically source an RSS feed from a URL (one-shot auto-source)'
214
+ method_option :items_selector, type: :string, desc: 'CSS selector hint for items'
215
+ method_option :limit, type: :numeric, desc: 'Max articles (auto-source)'
216
+ # @param url [String, nil]
217
+ # @return [void]
218
+ def scrape(url = nil)
219
+ strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
220
+ run_feed_command { scrape_feed_result_for(url, strategy, local_file_path) }
221
+ end
222
+
223
+ desc 'validate [CONFIG_FILES...]', 'Validate one or more YAML configs against the JSON Schema'
224
+ method_option :params, type: :hash, default: {}
225
+ method_option :quiet, aliases: '-q', type: :boolean, default: false
226
+ # @param files [Array<String>] file paths or globs
227
+ # @return [void]
228
+ def validate(*files)
229
+ Validate.run(files, params: options[:params] || {}, quiet: options[:quiet])
230
+ end
155
231
 
232
+ desc 'schema', 'Print or export the configuration JSON Schema'
233
+ method_option :pretty, type: :boolean, default: true, desc: 'Pretty-print JSON'
234
+ method_option :write, aliases: '-w', type: :string, desc: 'Write schema to file path'
235
+ # @return [void]
236
+ def schema
237
+ schema_json = Html2rss.schema_json(pretty: options.fetch(:pretty, true))
156
238
  if options[:write]
157
239
  FileUtils.mkdir_p(File.dirname(options[:write]))
158
240
  File.write(options[:write], "#{schema_json}\n")
159
241
  puts options[:write]
160
- return
242
+ else
243
+ puts schema_json
161
244
  end
162
-
163
- puts schema_json
164
245
  end
165
246
 
166
- desc 'mcp', 'Start the MCP server for AI client consumption'
167
- method_option :transport,
168
- type: :string,
169
- desc: 'MCP transport protocol',
170
- enum: %w[stdio http],
171
- default: 'stdio'
172
- method_option :port,
173
- type: :numeric,
174
- desc: 'Port for HTTP transport (binds 127.0.0.1)',
175
- default: 8080
176
- ##
177
- # Starts the MCP server for AI client consumption.
178
- #
247
+ desc 'mcp', 'Start the MCP server for AI agent and IDE consumption'
248
+ method_option :transport, type: :string, enum: %w[stdio http], default: 'stdio'
249
+ method_option :port, type: :numeric, default: 8080
179
250
  # @return [void]
180
251
  def mcp
181
- Html2rss::MCP.start(
182
- transport: options[:transport].to_sym,
183
- port: options[:port]
184
- )
252
+ Html2rss::MCP.start(transport: options[:transport].to_sym, port: options[:port])
185
253
  end
186
254
 
187
- desc 'validate YAML_FILE [feed_name]', 'Validate a YAML config with the runtime validator'
188
- method_option :params,
189
- type: :hash,
190
- optional: true,
191
- required: false,
192
- default: {}
193
- ##
194
- # Validates a YAML config and prints the result.
195
- #
196
- # @param yaml_file [String] the YAML file to validate
197
- # @param feed_name [String, nil] optional feed name for multi-feed files
255
+ desc 'doctor [TYPE]', 'Runtime preflight checks (botasaurus)'
256
+ method_option :sample, type: :string, desc: 'Optional URL for a Botasaurus sample scrape'
257
+ # @param type [String, nil] check type (default: botasaurus)
198
258
  # @return [void]
199
- def validate(yaml_file, feed_name = nil)
200
- result = Html2rss::Config.validate_yaml(yaml_file, feed_name, params: options[:params] || {})
259
+ def doctor(type = 'botasaurus')
260
+ case type.to_s
261
+ when 'botasaurus'
262
+ result = Html2rss::Doctor::Botasaurus.call(sample_url: options[:sample])
263
+ explain_json!(result.to_h)
264
+ raise Thor::Error, result.message unless result.ok
265
+ else
266
+ raise Thor::Error, "Unknown doctor type: #{type}. Supported: botasaurus"
267
+ end
268
+ end
269
+
270
+ private
201
271
 
202
- raise Thor::Error, "Invalid configuration: #{result.errors.to_h}" unless result.success?
272
+ def with_recon_targets(target)
273
+ urls, batch_mode = resolve_recon_targets(target, options[:file])
274
+ raise Thor::Error, 'A target URL, --file, or stdin (-) is required' if urls.empty?
203
275
 
204
- puts 'Configuration is valid'
276
+ yield urls, batch_mode
205
277
  end
206
278
 
207
- private
279
+ def resolve_recon_targets(target, file_opt)
280
+ if file_opt
281
+ [parse_recon_lines(File.readlines(file_opt, chomp: true)), true]
282
+ elsif target == '-'
283
+ [parse_recon_lines($stdin.readlines.map(&:chomp)), true]
284
+ elsif target
285
+ [Array(target), false]
286
+ else
287
+ [[], false]
288
+ end
289
+ end
290
+
291
+ def parse_recon_lines(lines)
292
+ lines.filter_map do |line|
293
+ stripped = line.strip
294
+ next if stripped.empty? || stripped.start_with?('#')
295
+
296
+ stripped.include?("\t") ? stripped.split("\t", 2).last.strip : stripped
297
+ end
298
+ end
299
+
300
+ def filter_recon_results(results, verdict_filter)
301
+ return results unless verdict_filter
302
+
303
+ expected = Recon::Verdict.coerce(verdict_filter.downcase)
304
+ results.select { |r| r.verdict == expected }
305
+ rescue ArgumentError => error
306
+ raise Thor::Error, error.message
307
+ end
308
+
309
+ def assert_recon_verdict_match!(filtered, results, batch_mode:)
310
+ return if batch_mode || !options[:verdict] || !filtered.empty? || results.empty?
311
+
312
+ raise Thor::Error, "No results matched verdict #{options[:verdict].upcase}"
313
+ end
314
+
315
+ def run_feed_command(&)
316
+ feed_res = execute_feed(&)
317
+ explain_json!(feed_res.status.to_h) if options[:explain]
318
+ emit_feed_result(feed_res, options.fetch(:format, 'rss'))
319
+ end
320
+
321
+ def emit_feed_result(feed_res, format)
322
+ payload = format == 'jsonfeed' ? JSON.pretty_generate(feed_res.to_json_feed) : feed_res.to_rss
323
+ puts payload
324
+ end
325
+
326
+ def handle_capture_output(result, url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
327
+ if options[:output_dir]
328
+ dir = File.join(options[:output_dir], URI.parse(url).host.to_s.delete_prefix('www.'))
329
+ FileUtils.mkdir_p(dir)
330
+ file_path = File.join(dir, 'index.yml')
331
+ File.write(file_path, result.yaml)
332
+ puts "Wrote captured config to #{file_path}"
333
+ elsif options[:write]
334
+ FileUtils.mkdir_p(File.dirname(options[:write]))
335
+ File.write(options[:write], result.yaml)
336
+ puts "Wrote captured config to #{options[:write]}"
337
+ else
338
+ puts result.yaml
339
+ end
340
+ end
341
+
342
+ def read_config_input(input)
343
+ if input.nil? || input == '-'
344
+ [$stdin.read, 'stdin']
345
+ elsif File.file?(input)
346
+ [File.read(input), input]
347
+ else
348
+ [input, 'raw_string']
349
+ end
350
+ end
208
351
 
209
352
  def apply_runtime_request_overrides!(config)
210
353
  clear_blank_request_overrides!(config)
@@ -262,32 +405,15 @@ module Html2rss
262
405
  options[:strategy]&.to_sym || :auto
263
406
  end
264
407
 
265
- def current_max_redirects
266
- options.fetch(:max_redirects, Html2rss::RequestService::Policy::DEFAULTS[:max_redirects])
267
- end
268
-
269
- def current_max_requests
270
- options.fetch(:max_requests, Html2rss::RequestService::Policy::DEFAULTS[:max_requests])
271
- end
272
-
273
- def suggested_max_redirects
274
- current_max_redirects + 1
275
- end
276
-
277
- def suggested_max_requests
278
- current_max_requests + 1
279
- end
280
-
281
408
  def execute_feed # rubocop:disable Metrics/MethodLength
282
409
  yield
283
- rescue Faraday::FollowRedirects::RedirectLimitReached => error
410
+ rescue Html2rss::RequestService::RedirectLimitReached => error
284
411
  raise Thor::Error,
285
412
  "#{error.message}. already retried the last redirect hop once; " \
286
- "retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
413
+ 'retry with higher --max-redirects or use the final URL directly.'
287
414
  rescue Html2rss::RequestService::RequestBudgetExceeded => error
288
415
  raise Thor::Error,
289
- "#{error.message}. retry with --max-requests #{suggested_max_requests} " \
290
- 'or increase request.max_requests in the config.'
416
+ "#{error.message}. retry with higher --max-requests or increase request.max_requests in config."
291
417
  rescue Html2rss::RequestService::BotasaurusConfigurationError,
292
418
  Html2rss::RequestService::BotasaurusConnectionFailed,
293
419
  Html2rss::RequestService::BotasaurusServiceError,
@@ -296,8 +422,8 @@ module Html2rss
296
422
  raise Thor::Error, error.message
297
423
  end
298
424
 
299
- def auto_feed_result_for(url, strategy, local_file_path)
300
- Html2rss.auto_feed_result(
425
+ def scrape_feed_result_for(url, strategy, local_file_path)
426
+ Html2rss.scrape(
301
427
  url,
302
428
  strategy:,
303
429
  items_selector: options[:items_selector],
@@ -308,21 +434,20 @@ module Html2rss
308
434
  )
309
435
  end
310
436
 
311
- def explain_status!(status)
312
- $stderr.puts JSON.pretty_generate(status.to_h) # rubocop:disable Style/StderrPuts -- CLI explain contract
437
+ def explain_json!(payload)
438
+ $stderr.puts JSON.pretty_generate(payload) # rubocop:disable Style/StderrPuts
313
439
  end
314
440
 
315
- def explain_capture!(result)
316
- $stderr.puts JSON.pretty_generate( # rubocop:disable Style/StderrPuts -- CLI explain contract
317
- {
318
- articles_count: result.articles_count,
319
- channel_title: result.channel_title,
320
- has_selectors: result.has_selectors,
321
- segment_strategy: result.segment_strategy,
322
- selected_strategy: result.selected_strategy,
323
- admission_drops: result.admission_drops
324
- }.compact
325
- )
441
+ def capture_explain_payload(result)
442
+ {
443
+ articles_count: result.articles_count,
444
+ channel_title: result.channel_title,
445
+ has_selectors: result.has_selectors,
446
+ segment_strategy: result.segment_strategy,
447
+ selected_strategy: result.selected_strategy,
448
+ inferred_topics: result.inferred_topics,
449
+ admission_drops: result.admission_drops
450
+ }.compact
326
451
  end
327
452
 
328
453
  def check_file_exists!(path)
@@ -7,8 +7,15 @@ module Html2rss
7
7
  # Runtime source of truth for validating auto-source config values.
8
8
  AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
9
9
  optional(:limit).filled(:integer, gt?: 0)
10
+ optional(:entry_resolution).hash do
11
+ optional(:enabled).filled(:bool)
12
+ optional(:max_probes).filled(:integer, gt?: 0)
13
+ end
10
14
 
11
15
  optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
16
+ optional(:native_feed).hash do
17
+ optional(:enabled).filled(:bool)
18
+ end
12
19
  optional(:wordpress_api).hash do
13
20
  optional(:enabled).filled(:bool)
14
21
  end
@@ -59,20 +59,17 @@ module Html2rss
59
59
  #
60
60
  # @param headers [Hash, nil] caller provided headers
61
61
  # @param channel_language [String, nil] language defined on the channel
62
- # @param url [String] request URL used to infer the Host header
63
62
  # @return [Hash{String => String}] normalized HTTP headers
64
- def normalize(headers, channel_language:, url:)
65
- new(headers || {}, channel_language:, url:).to_h
63
+ def normalize(headers, channel_language:)
64
+ new(headers || {}, channel_language:).to_h
66
65
  end
67
66
  end
68
67
 
69
68
  # @param headers [Hash{String, Symbol => String}] caller-provided headers
70
69
  # @param channel_language [String, nil] channel language hint for Accept-Language
71
- # @param url [String, Html2rss::Url, nil] request URL used to infer Host
72
- def initialize(headers, channel_language:, url:)
70
+ def initialize(headers, channel_language:)
73
71
  @headers = headers
74
72
  @channel_language = channel_language
75
- @url = url
76
73
  end
77
74
 
78
75
  ##
@@ -86,14 +83,13 @@ module Html2rss
86
83
 
87
84
  defaults['Accept'] = normalize_accept(accept_override)
88
85
  defaults['Accept-Language'] = build_accept_language
89
- defaults['Host'] ||= request_host
90
86
 
91
87
  defaults.compact
92
88
  end
93
89
 
94
90
  private
95
91
 
96
- attr_reader :headers, :channel_language, :url
92
+ attr_reader :headers, :channel_language
97
93
 
98
94
  def normalize_custom_headers(custom)
99
95
  custom.transform_keys { canonicalize(_1) }
@@ -134,12 +130,6 @@ module Html2rss
134
130
 
135
131
  "#{primary}-#{region},#{primary};q=0.9"
136
132
  end
137
-
138
- def request_host
139
- return nil if url.nil? || url.empty?
140
-
141
- Html2rss::Url.from_absolute(url).host
142
- end
143
133
  end
144
134
  end
145
135
  end