html2rss 0.26.0 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -18
  3. data/html2rss.gemspec +1 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +121 -56
  6. data/lib/html2rss/auto_source/scraper.rb +13 -0
  7. data/lib/html2rss/auto_source.rb +34 -8
  8. data/lib/html2rss/capture/README.md +61 -0
  9. data/lib/html2rss/capture.rb +120 -117
  10. data/lib/html2rss/cli.rb +35 -17
  11. data/lib/html2rss/config/schema.rb +12 -0
  12. data/lib/html2rss/config/validator.rb +31 -8
  13. data/lib/html2rss/config.rb +28 -0
  14. data/lib/html2rss/error.rb +24 -6
  15. data/lib/html2rss/feed_pipeline/README.md +42 -0
  16. data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
  17. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  18. data/lib/html2rss/feed_pipeline.rb +30 -12
  19. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  20. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  21. data/lib/html2rss/html/article_extractor.rb +95 -17
  22. data/lib/html2rss/html/article_rules/category.rb +28 -11
  23. data/lib/html2rss/html/article_rules/date.rb +60 -6
  24. data/lib/html2rss/html/article_rules/description.rb +122 -0
  25. data/lib/html2rss/html/card_walk.rb +42 -0
  26. data/lib/html2rss/html/feed_link.rb +34 -0
  27. data/lib/html2rss/html/navigator.rb +18 -0
  28. data/lib/html2rss/html/sst_article_extractor.rb +119 -32
  29. data/lib/html2rss/link_destination/path_classifier.rb +49 -35
  30. data/lib/html2rss/mcp/config_argument.rb +42 -0
  31. data/lib/html2rss/mcp/contract.rb +173 -0
  32. data/lib/html2rss/mcp/inspect.rb +241 -0
  33. data/lib/html2rss/mcp/outcome.rb +188 -0
  34. data/lib/html2rss/mcp/server.rb +253 -409
  35. data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
  36. data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
  37. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  38. data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
  39. data/lib/html2rss/request_service/policy.rb +1 -1
  40. data/lib/html2rss/request_service/response.rb +57 -6
  41. data/lib/html2rss/request_service.rb +6 -1
  42. data/lib/html2rss/selectors.rb +2 -1
  43. data/lib/html2rss/status.rb +27 -11
  44. data/lib/html2rss/url.rb +20 -0
  45. data/lib/html2rss/version.rb +1 -1
  46. data/lib/html2rss.rb +30 -6
  47. data/schema/html2rss-config.schema.json +26 -15
  48. metadata +15 -4
@@ -6,8 +6,8 @@ module Html2rss
6
6
  # Thin MCP wire adapter over public html2rss APIs.
7
7
  #
8
8
  # Ownership: scraping/capture/validate/feed stay on gem entrypoints
9
- # ({Html2rss.auto_json_feed}, {Capture.build}, {Config.validate}, {Html2rss.feed}).
10
- # This module only maps MCP kwargs those APIs and shapes Tool::Response.
9
+ # ({Html2rss.auto_feed_result}, {Capture.build}, {Config.validate}, {Html2rss.feed_result}).
10
+ # This module maps MCP kwargs to those APIs, then {Outcome} + {Contract} shape the envelope.
11
11
  #
12
12
  # Strategy note: MCP +auto+ passes through to FeedPipeline AutoFallback
13
13
  # (faraday → botasaurus). Concrete strategies are used as-is.
@@ -24,30 +24,34 @@ module Html2rss
24
24
  ##
25
25
  # Starts the MCP server with the given transport.
26
26
  #
27
+ # Points {Html2rss.logger} at +$stderr+ so stdio JSON-RPC on stdout stays
28
+ # intact, and raises the process log level to +info+ unless +LOG_LEVEL+ is set.
29
+ # A foreground watcher then sees the start banner, tool calls, and pipeline warns.
30
+ #
27
31
  # @param transport [Symbol] +:stdio+ or +:http+
28
32
  # @param port [Integer] port for HTTP transport
29
33
  def start(transport: :stdio, port: 8080)
34
+ raise ArgumentError, "Unknown transport: #{transport.inspect}" unless %i[stdio http].include?(transport)
35
+
36
+ configure_daemon_logging!
30
37
  app = build
38
+ Log.info(start_banner(transport:, port:))
39
+ return start_http(app, port:) if transport == :http
31
40
 
32
- case transport
33
- when :stdio
34
- ::MCP::Server::Transports::StdioTransport.new(app).open
35
- when :http
36
- start_http(app, port:)
37
- else
38
- raise ArgumentError, "Unknown transport: #{transport.inspect}"
39
- end
41
+ ::MCP::Server::Transports::StdioTransport.new(app).open
40
42
  end
41
43
 
42
44
  ##
43
45
  # Builds the configured MCP protocol server (tools/resources/prompts).
44
46
  #
45
47
  # @return [::MCP::Server]
46
- def build
48
+ def build # rubocop:disable Metrics/MethodLength -- protocol server construction
47
49
  ::MCP::Server.new(
48
50
  name: SERVER_NAME,
51
+ title: SERVER_NAME,
49
52
  version: SERVER_VERSION,
50
- instructions: instructions_text
53
+ instructions: instructions_text,
54
+ configuration: protocol_configuration
51
55
  ).tap do |server|
52
56
  register_tools(server)
53
57
  register_resources(server)
@@ -55,34 +59,68 @@ module Html2rss
55
59
  end
56
60
  end
57
61
 
58
- ##
59
- # Maps MCP strategy shortcut to a feed-level strategy plan.
60
- # +:auto+ passes through as +:auto+ so the FeedPipeline's AutoFallback
61
- # chain (faraday → botasaurus) is triggered for JS-rendered sites.
62
- #
63
- # @param strategy [String, Symbol, nil]
64
- # @return [Symbol]
65
- def resolve_mcp_strategy(strategy)
66
- (strategy || :auto).to_sym
62
+ private
63
+
64
+ def configure_daemon_logging!
65
+ Html2rss.configure do |config|
66
+ config.logger = Logger.new($stderr)
67
+ config.log_level = ENV.fetch('LOG_LEVEL', :info)
68
+ end
67
69
  end
68
70
 
69
- ##
70
- # @param text [String]
71
- # @param error [Boolean]
72
- # @param meta [Hash, nil]
73
- # @return [::MCP::Tool::Response]
74
- def text_response(text, error: false, meta: nil)
75
- ::MCP::Tool::Response.new([{ type: 'text', text: }], error:, meta:)
71
+ def start_banner(transport:, port:)
72
+ bind = transport == :http ? " bind=#{HTTP_BIND_HOST}:#{port}" : ''
73
+ "html2rss MCP #{SERVER_VERSION} starting transport=#{transport}#{bind}"
76
74
  end
77
75
 
78
- ##
79
- # @param error [Exception]
80
- # @return [::MCP::Tool::Response]
81
- def error_response(error)
82
- text_response("Error: #{error.message}", error: true)
76
+ def protocol_configuration
77
+ ::MCP::Configuration.new.tap do |config|
78
+ config.exception_reporter = method(:report_protocol_exception)
79
+ config.around_request = method(:around_protocol_request)
80
+ config.validate_tool_call_results = true
81
+ end
83
82
  end
84
83
 
85
- private
84
+ def report_protocol_exception(error, server_context)
85
+ detail = server_context.is_a?(Hash) && server_context[:error]
86
+ suffix = detail ? " (#{detail})" : ''
87
+ Log.error("#{error.class}: #{error.message}#{suffix}")
88
+ end
89
+
90
+ def around_protocol_request(data)
91
+ return yield unless log_protocol_request?(data)
92
+
93
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
94
+ begin
95
+ Log.info(protocol_request_line('start', data))
96
+ yield
97
+ ensure
98
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
99
+ Log.info(protocol_request_line('done', data, duration:))
100
+ end
101
+ end
102
+
103
+ def log_protocol_request?(data)
104
+ method = data[:method]
105
+ method.is_a?(String) &&
106
+ method != ::MCP::Methods::PING &&
107
+ !::MCP::Methods.notification?(method)
108
+ end
109
+
110
+ def protocol_request_line(phase, data, duration: nil)
111
+ parts = ['mcp', phase, data[:method], *protocol_request_labels(data)]
112
+ parts << format('%.2fs', duration) if duration
113
+ parts << "error=#{data[:error]}" if data[:error]
114
+ parts.join(' ')
115
+ end
116
+
117
+ def protocol_request_labels(data)
118
+ [
119
+ data[:tool_name] && "tool=#{data[:tool_name]}",
120
+ data[:prompt_name] && "prompt=#{data[:prompt_name]}",
121
+ data[:resource_uri] && "uri=#{data[:resource_uri]}"
122
+ ].compact
123
+ end
86
124
 
87
125
  def start_http(app, port:) # rubocop:disable Metrics/MethodLength -- require + bind + LoadError message
88
126
  require 'rackup'
@@ -106,19 +144,54 @@ module Html2rss
106
144
  <<~TEXT.strip
107
145
  html2rss MCP — decide which tool to call:
108
146
 
109
- 1. Need articles now (no saved config)? → scrape_url
110
- - strategy "auto" triggers faradaybotasaurus fallback chain for JS-rendered sites.
111
- - If botasaurus is unconfigured and auto fails, try explicit "faraday" or set up Botasaurus.
112
- 2. Need a reusable feed YAML/config? → capture_config, then validate_config, then apply_config
113
- 3. Debugging why scrape/capture is weak? inspect_url (scrapers/SST/segments/blocked_surface), then retry scrape/capture
114
- 4. Have a config already? validate_config (must succeed) → apply_config for RSS XML
115
- 5. Schema / extractor / strategy lists resources html2rss://schema|extractors|strategies
116
-
117
- Prefer capture_config when the goal is a durable config; prefer scrape_url for one-shot extraction.
118
- Botasaurus needs BOTASAURUS_SCRAPER_URL (see docker-compose.botasaurus.yml).
147
+ 1. Need articles now (no saved config)? → scrape_url (1 call)
148
+ - strategy "auto" runs FaradayBotasaurus AutoFallback. Do not retry with explicit faraday after auto.
149
+ - Empty scrape is still success (articles-now). Follow next_step / guidance (read_runtime if Botasaurus unset).
150
+ 2. Need a reusable feed YAML? → capture_config validate_config apply_config
151
+ - capture_config returns YAML inside payload.yaml. Draft only: if destination is html2rss-configs, rewrite for directory.topics and explicit channel title/url. Strive enhance: true (false only when chrome leaks).
152
+ - validate_config / apply_config accept config hash XOR yaml string.
153
+ - apply_config isError when zero RSS items (ship gate). Confirm payload.item_count.
154
+ 3. Weak scrape/capture or recon (final URL, status, https→http, rel=alternate feeds)? → inspect_url only if weak or recon.
155
+ 4. Have a config already? validate_config (must succeed) apply_config
156
+ 5. Schema / extractors / strategies / runtime → resources html2rss://schema|extractors|strategies|runtime
157
+
158
+ Prefer capture_config for durable config; scrape_url for one-shot extraction.
159
+ Follow envelope next_step and guidance. Botasaurus needs BOTASAURUS_SCRAPER_URL in this process env (boolean at html2rss://runtime; the URL is never returned).
119
160
  TEXT
120
161
  end
121
162
 
163
+ def botasaurus_configured?
164
+ !ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
165
+ end
166
+
167
+ def tool_error_response(error)
168
+ Log.error("mcp error #{error.class}: #{error.message}")
169
+ Contract.response(Outcome.from_error(error))
170
+ end
171
+
172
+ def handle_tool_call
173
+ Contract.response(yield)
174
+ rescue StandardError => error
175
+ tool_error_response(error)
176
+ end
177
+
178
+ # rubocop:disable Metrics/MethodLength -- listing fields stay together
179
+ def define_envelope_tool(server, name:, description:, input_schema:,
180
+ annotations: Contract::ANNOTATIONS_OPEN_WORLD)
181
+ run = method(:handle_tool_call)
182
+ server.define_tool(
183
+ name:,
184
+ title: Contract::TITLES.fetch(name.to_sym),
185
+ description:,
186
+ annotations:,
187
+ input_schema:,
188
+ output_schema: Contract.output_schema
189
+ ) do |**kwargs|
190
+ run.call { yield(**kwargs) }
191
+ end
192
+ end
193
+ # rubocop:enable Metrics/MethodLength
194
+
122
195
  def register_tools(server)
123
196
  register_scrape_url(server)
124
197
  register_inspect_url(server)
@@ -127,174 +200,118 @@ module Html2rss
127
200
  register_apply_config(server)
128
201
  end
129
202
 
130
- def register_scrape_url(server) # rubocop:disable Metrics/MethodLength
131
- server.define_tool(
203
+ def register_scrape_url(server)
204
+ define_envelope_tool(
205
+ server,
132
206
  name: 'scrape_url',
133
207
  description: 'One-shot article extraction as JSON Feed items. ' \
134
208
  'Use when you need articles now without a saved config. ' \
135
209
  'strategy "auto" triggers fallback chain (faraday → botasaurus) for JS-rendered sites.',
136
- input_schema: {
137
- type: 'object',
138
- properties: {
139
- url: { type: 'string', description: 'Source page URL' },
140
- strategy: {
141
- type: 'string',
142
- enum: %w[auto faraday botasaurus],
143
- default: 'auto',
144
- description: 'Request strategy (auto collapses to faraday in MCP)'
145
- },
146
- limit: {
147
- type: 'integer',
148
- description: 'Max articles to keep (default 25)',
149
- default: 25
150
- },
151
- items_selector: {
152
- type: 'string',
153
- description: 'Optional CSS selector hint for items'
154
- }
155
- },
156
- required: ['url']
157
- }
210
+ input_schema: Contract::SCRAPE_INPUT_SCHEMA
158
211
  ) do |server_context:, url:, strategy: 'auto', limit: 25, items_selector: nil| # rubocop:disable Lint/UnusedBlockArgument
159
- resolved = Server.resolve_mcp_strategy(strategy)
160
- feed = Html2rss.auto_json_feed(url, strategy: resolved, limit:, items_selector:)
161
- items = feed[:items] || []
162
- Server.text_response(JSON.generate(items), meta: {
163
- total: items.size,
164
- strategy: resolved.to_s,
165
- channel_title: feed[:title]
166
- })
167
- rescue StandardError => error
168
- Server.error_response(error)
212
+ scrape_outcome(url:, strategy:, limit:, items_selector:)
169
213
  end
170
214
  end
171
215
 
172
- def register_inspect_url(server) # rubocop:disable Metrics/MethodLength
173
- server.define_tool(
216
+ def scrape_outcome(url:, strategy:, limit:, items_selector:)
217
+ plan = (strategy || :auto).to_sym
218
+ feed_result = Html2rss.auto_feed_result(url, strategy: plan, limit:, items_selector:)
219
+ feed = feed_result.to_json_feed
220
+ Outcome.scrape(
221
+ items: feed[:items] || [],
222
+ requested_strategy: plan,
223
+ channel_title: feed[:title],
224
+ admission_drops: feed_result.status.admission_drops,
225
+ botasaurus_configured: botasaurus_configured?
226
+ )
227
+ end
228
+
229
+ def register_inspect_url(server)
230
+ define_envelope_tool(
231
+ server,
174
232
  name: 'inspect_url',
175
- description: 'Diagnostic page analysis (scrapers, SST, segments). ' \
176
- 'Use when scrape_url/capture_config returns little and you need to see why.',
177
- input_schema: {
178
- type: 'object',
179
- properties: {
180
- url: { type: 'string', description: 'Source page URL' },
181
- strategy: {
182
- type: 'string',
183
- enum: %w[auto faraday botasaurus],
184
- default: 'auto',
185
- description: 'Request strategy (auto collapses to faraday in MCP)'
186
- }
187
- },
188
- required: ['url']
189
- }
233
+ description: 'Diagnostic page analysis (scrapers, SST, segments) plus recon: ' \
234
+ 'final_url, status, scheme_downgrade, rel=alternate RSS/Atom feeds. ' \
235
+ 'Use when scrape/capture is weak or you need those recon facts.',
236
+ input_schema: Contract::INSPECT_INPUT_SCHEMA
190
237
  ) do |server_context:, url:, strategy: 'auto'| # rubocop:disable Lint/UnusedBlockArgument
191
- Server.text_response(JSON.pretty_generate(Inspect.call(url:, strategy:)))
192
- rescue StandardError => error
193
- Server.error_response(error)
238
+ Outcome.inspect(payload: Inspect.call(url:, strategy:))
194
239
  end
195
240
  end
196
241
 
197
- def register_capture_config(server) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
198
- server.define_tool(
242
+ def register_capture_config(server) # rubocop:disable Metrics/MethodLength
243
+ define_envelope_tool(
244
+ server,
199
245
  name: 'capture_config',
200
246
  description: 'Derive a reusable html2rss feed config from a URL. ' \
201
- 'Use when the goal is a durable YAML/config (then validate_config). ' \
202
- 'Returns config plus quality meta (articles_count, selectors presence). ' \
203
- 'Full schema options live in resource html2rss://schema.',
204
- input_schema: {
205
- type: 'object',
206
- properties: {
207
- url: { type: 'string', description: 'Source page URL' },
208
- strategy: {
209
- type: 'string',
210
- enum: %w[auto faraday botasaurus],
211
- default: 'auto',
212
- description: 'Request strategy (auto collapses to faraday in MCP)'
213
- },
214
- items_selector: {
215
- type: 'string',
216
- description: 'Optional CSS selector hint for items'
217
- }
218
- },
219
- required: ['url']
220
- }
247
+ 'Use when the goal is a durable YAML (then validate_config). ' \
248
+ 'Returns YAML inside payload.yaml (same serializer as CLI capture). ' \
249
+ 'Draft only catalog feeds still need directory.topics and title/url; ' \
250
+ 'strive enhance: true. Full schema options live in resource html2rss://schema.',
251
+ input_schema: Contract::CAPTURE_INPUT_SCHEMA
221
252
  ) do |server_context:, url:, strategy: 'auto', items_selector: nil| # rubocop:disable Lint/UnusedBlockArgument
222
- resolved = Server.resolve_mcp_strategy(strategy)
223
- result = Html2rss::Capture.build(url, strategy: resolved, items_selector:)
224
- selectors = result.config[:selectors]
225
- Server.text_response(
226
- JSON.pretty_generate(result.config),
227
- meta: {
228
- articles_count: result.articles_count,
229
- channel_title: result.channel_title,
230
- has_selectors: !selectors.nil? && !selectors.empty?,
231
- strategy: resolved.to_s
232
- }
233
- )
234
- rescue StandardError => error
235
- Server.error_response(error)
253
+ capture_outcome(url:, strategy:, items_selector:)
236
254
  end
237
255
  end
238
256
 
239
- def register_validate_config(server) # rubocop:disable Metrics/MethodLength
240
- server.define_tool(
257
+ def capture_outcome(url:, strategy:, items_selector:) # rubocop:disable Metrics/MethodLength -- CaptureResult maps 1:1 onto Outcome
258
+ plan = (strategy || :auto).to_sym
259
+ result = Html2rss::Capture.build(url, strategy: plan, items_selector:)
260
+ Outcome.capture(
261
+ yaml: Config.to_yaml(result.config),
262
+ articles_count: result.articles_count,
263
+ has_selectors: result.has_selectors,
264
+ channel_title: result.channel_title,
265
+ requested_strategy: plan,
266
+ segment_strategy: result.segment_strategy,
267
+ selected_strategy: result.selected_strategy,
268
+ admission_drops: result.admission_drops
269
+ )
270
+ end
271
+
272
+ def register_validate_config(server) # rubocop:disable Metrics/MethodLength -- description is the published contract
273
+ define_envelope_tool(
274
+ server,
241
275
  name: 'validate_config',
242
- description: 'Validate a feed config hash against the html2rss JSON schema. ' \
243
- 'Call before apply_config. Failures return isError with structured error details. ' \
276
+ description: 'Validate a feed config hash XOR yaml string against the html2rss JSON schema. ' \
277
+ 'Call before apply_config. Failures return isError with payload.errors. ' \
244
278
  'Full schema lives in resource html2rss://schema.',
245
- input_schema: {
246
- type: 'object',
247
- properties: {
248
- config: {
249
- type: 'object',
250
- description: 'Feed configuration hash with channel and selectors'
251
- }
252
- },
253
- required: ['config']
254
- }
255
- ) do |server_context:, config:| # rubocop:disable Lint/UnusedBlockArgument
256
- config_hash = HashUtil.deep_symbolize_keys(config, context: 'config')
257
- validation = Html2rss::Config.validate(config_hash)
258
-
259
- if validation.success?
260
- Server.text_response('Config is valid.')
261
- else
262
- Server.text_response(JSON.generate(validation.errors.to_h), error: true)
263
- end
264
- rescue StandardError => error
265
- Server.error_response(error)
279
+ input_schema: Contract::CONFIG_XOR_SCHEMA,
280
+ annotations: Contract::ANNOTATIONS_VALIDATE
281
+ ) do |server_context:, config: nil, yaml: nil| # rubocop:disable Lint/UnusedBlockArgument
282
+ validate_outcome(config:, yaml:)
266
283
  end
267
284
  end
268
285
 
269
- def register_apply_config(server) # rubocop:disable Metrics/MethodLength
270
- server.define_tool(
286
+ def validate_outcome(config:, yaml:)
287
+ validation = Html2rss::Config.validate(ConfigArgument.parse(config:, yaml:).config)
288
+ Outcome.validate(errors: validation.success? ? nil : validation.errors.to_h)
289
+ end
290
+
291
+ def register_apply_config(server)
292
+ define_envelope_tool(
293
+ server,
271
294
  name: 'apply_config',
272
- description: 'Apply a validated feed config and return RSS XML. ' \
295
+ description: 'Apply a validated feed config (hash XOR yaml) and return RSS XML in payload.rss. ' \
296
+ 'isError when the feed has zero items (ship gate). payload.item_count is RSS item count. ' \
273
297
  'Use after validate_config succeeds.',
274
- input_schema: {
275
- type: 'object',
276
- properties: {
277
- url: { type: 'string', description: 'Source page URL (fills channel.url if missing)' },
278
- config: {
279
- type: 'object',
280
- description: 'Feed configuration hash with selectors'
281
- }
282
- },
283
- required: %w[url config]
284
- }
285
- ) do |server_context:, url:, config:| # rubocop:disable Lint/UnusedBlockArgument
286
- feed_config = HashUtil.deep_symbolize_keys(config, context: 'config')
287
- feed_config[:channel] ||= {}
288
- feed_config[:channel][:url] ||= url
289
-
290
- rss = Html2rss.feed(feed_config)
291
- Server.text_response(rss.to_s)
292
- rescue StandardError => error
293
- Server.error_response(error)
298
+ input_schema: Contract::APPLY_INPUT_SCHEMA
299
+ ) do |server_context:, url:, config: nil, yaml: nil| # rubocop:disable Lint/UnusedBlockArgument
300
+ apply_outcome(url:, config:, yaml:)
294
301
  end
295
302
  end
296
303
 
304
+ def apply_outcome(url:, config:, yaml:)
305
+ feed_config = ConfigArgument.parse(config:, yaml:).config
306
+ feed_config[:channel] ||= {}
307
+ feed_config[:channel][:url] ||= url
308
+ feed_result = Html2rss.feed_result(feed_config)
309
+ rss = feed_result.to_rss
310
+ Outcome.apply(rss: rss.to_s, item_count: rss.items.size, empty: feed_result.empty?)
311
+ end
312
+
297
313
  def register_resources(server) # rubocop:disable Metrics/MethodLength
314
+ configured = method(:botasaurus_configured?)
298
315
  server.define_resource(
299
316
  uri: 'html2rss://schema',
300
317
  name: 'Configuration JSON Schema',
@@ -309,7 +326,8 @@ module Html2rss
309
326
  uri: 'html2rss://extractors',
310
327
  name: 'Available Extractors',
311
328
  description: 'Registered extractor names for selector configs ' \
312
- '(full option docs live in html2rss://schema $defs)'
329
+ '(full option docs live in html2rss://schema $defs)',
330
+ mime_type: 'application/json'
313
331
  ) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
314
332
  extractors = Html2rss::Selectors::Extractors::NAME_TO_CLASS.keys.map(&:to_s).sort
315
333
  [{ uri: 'html2rss://extractors', mimeType: 'application/json',
@@ -319,258 +337,84 @@ module Html2rss
319
337
  server.define_resource(
320
338
  uri: 'html2rss://strategies',
321
339
  name: 'Available Strategies',
322
- description: 'Registered request strategy names'
340
+ description: 'Published MCP request strategy names',
341
+ mime_type: 'application/json'
323
342
  ) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
324
- strategies = Html2rss::RequestService.instance.strategy_names
325
343
  [{ uri: 'html2rss://strategies', mimeType: 'application/json',
326
- text: JSON.pretty_generate(strategies) }]
344
+ text: JSON.generate(Contract::STRATEGIES) }]
327
345
  end
346
+
347
+ server.define_resource(
348
+ uri: 'html2rss://runtime',
349
+ name: 'Runtime capabilities',
350
+ description: 'Whether optional transports are configured in this MCP process (never leaks secrets)',
351
+ mime_type: 'application/json'
352
+ ) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
353
+ [{ uri: 'html2rss://runtime', mimeType: 'application/json',
354
+ text: JSON.pretty_generate(botasaurus_configured: configured.call) }]
355
+ end
356
+ end
357
+
358
+ def register_prompts(server)
359
+ register_scrape_webpage_prompt(server)
360
+ register_capture_feed_config_prompt(server)
328
361
  end
329
362
 
330
- def register_prompts(server) # rubocop:disable Metrics/MethodLength
363
+ def register_scrape_webpage_prompt(server) # rubocop:disable Metrics/MethodLength -- SDK prompt types stay together
364
+ to_result = method(:prompt_result)
365
+ text_for = method(:scrape_webpage_text)
331
366
  server.define_prompt(
332
367
  name: 'scrape-webpage',
333
- description: 'Guided one-shot scrape: scrape_url then inspect/retry with botasaurus if needed',
368
+ description: 'Guided one-shot scrape: one scrape_url call (auto already falls back)',
334
369
  arguments: [
335
- { name: 'url', description: 'URL to scrape', required: true }
370
+ ::MCP::Prompt::Argument.new(name: 'url', description: 'URL to scrape', required: true)
336
371
  ]
337
372
  ) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
338
- url = args.fetch(:url)
339
- {
340
- messages: [
341
- {
342
- role: 'user',
343
- content: {
344
- type: 'text',
345
- text: <<~MSG.strip
346
- Scrape #{url} with the scrape_url tool (strategy auto first).
347
- If articles are empty or look JS-gated, call inspect_url, then scrape_url again with strategy botasaurus.
348
- Return the structured articles JSON.
349
- MSG
350
- }
351
- }
352
- ]
353
- }
373
+ to_result.call(text_for.call(args.fetch(:url)))
354
374
  end
375
+ end
355
376
 
377
+ def register_capture_feed_config_prompt(server) # rubocop:disable Metrics/MethodLength -- SDK prompt types stay together
378
+ to_result = method(:prompt_result)
379
+ text_for = method(:capture_feed_config_text)
356
380
  server.define_prompt(
357
381
  name: 'capture-feed-config',
358
- description: 'Guided capture → validate → optional apply for a reusable feed config',
382
+ description: 'Guided capture → validate → apply; YAML draft plus catalog rewrite',
359
383
  arguments: [
360
- { name: 'url', description: 'URL to analyze', required: true }
384
+ ::MCP::Prompt::Argument.new(name: 'url', description: 'URL to analyze', required: true)
361
385
  ]
362
386
  ) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
363
- url = args.fetch(:url)
364
- {
365
- messages: [
366
- {
367
- role: 'user',
368
- content: {
369
- type: 'text',
370
- text: <<~MSG.strip
371
- Build a reusable html2rss feed config for #{url}:
372
- 1) capture_config — check _meta.articles_count and has_selectors
373
- 2) If weak, inspect_url and/or retry capture_config with strategy botasaurus
374
- 3) validate_config on the config (must not be isError)
375
- 4) Optionally apply_config to confirm RSS XML
376
- Return the validated config hash suitable for YAML.
377
- MSG
378
- }
379
- }
380
- ]
381
- }
382
- end
383
- end
384
- end
385
-
386
- ##
387
- # Diagnostic inspect path (not Capture ownership). Fetches once for SST/scraper stats.
388
- module Inspect # rubocop:disable Metrics/ModuleLength -- diagnostic helpers stay co-located
389
- module_function
390
-
391
- ##
392
- # Resolves feed-level strategy plans to concrete strategies for diagnostic fetch.
393
- # +:auto+ collapses to +:faraday+ (inspect is a single-request diagnostic, not a fallback run).
394
- #
395
- # @param strategy [String, Symbol]
396
- # @return [Symbol]
397
- def concrete_strategy(strategy)
398
- plan = FeedPipeline::StrategyPlan.resolve(Server.resolve_mcp_strategy(strategy))
399
- plan.is_a?(FeedPipeline::StrategyPlan::Auto) ? :faraday : plan.strategy
400
- end
401
-
402
- ##
403
- # @param url [String]
404
- # @param strategy [String, Symbol]
405
- # @return [Hash]
406
- def call(url:, strategy: :auto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
407
- resolved = concrete_strategy(strategy)
408
- response = fetch_response(url, resolved)
409
- parsed = response.parsed_body
410
-
411
- result = {
412
- url:,
413
- strategy: resolved,
414
- content_type: response.content_type,
415
- html_response: response.html_response?,
416
- scraper_eligibility: scraper_info(parsed),
417
- sst_stats: sst_stats_from(response)
418
- }
419
-
420
- if response.html_response?
421
- sst = sst_document(response)
422
- if sst
423
- result[:sst] = {
424
- node_count: sst.node_count,
425
- degraded: sst.degraded,
426
- segment_stats: segment_stats(sst, url)
427
- }
428
- end
387
+ to_result.call(text_for.call(args.fetch(:url)))
429
388
  end
430
-
431
- blocked = Html2rss::RequestService::BlockedSurface.interstitial_signature_for(response.body)
432
- result[:blocked_surface] = blocked[:key].to_s if blocked
433
- result[:xhr_capture] = xhr_capture_info(response) if resolved == :botasaurus
434
-
435
- result
436
- end
437
-
438
- ##
439
- # @param response [Html2rss::RequestService::Response]
440
- # @return [Hash] redacted XHR capture diagnostics (no query strings)
441
- def xhr_capture_info(response)
442
- captured = response.captured_responses
443
- {
444
- count: captured.size,
445
- sample_endpoints: captured.first(5).filter_map { |entry| redacted_endpoint(entry) },
446
- candidate_articles: captured.any? { |entry| xhr_candidate_articles?(entry) }
447
- }
448
- end
449
- module_function :xhr_capture_info
450
-
451
- ##
452
- # @param entry [Hash] captured response hash
453
- # @return [String, nil] scheme+host+path only
454
- def redacted_endpoint(entry)
455
- raw = entry['url'] || entry[:url]
456
- return unless raw
457
-
458
- uri = URI.parse(raw.to_s)
459
- return unless uri.scheme && uri.host
460
-
461
- "#{uri.scheme}://#{uri.host}#{uri.path}"
462
- rescue URI::InvalidURIError
463
- nil
464
389
  end
465
- module_function :redacted_endpoint
466
-
467
- ##
468
- # @param entry [Hash] captured response hash
469
- # @return [Boolean]
470
- def xhr_candidate_articles?(entry)
471
- body = entry['body'] || entry[:body]
472
- return false unless body.is_a?(String)
473
390
 
474
- document = JSON.parse(body, symbolize_names: true)
475
- AutoSource::Scraper::JsonState::CandidateDetector.candidate_array?(document)
476
- rescue JSON::ParserError
477
- false
478
- end
479
- module_function :xhr_candidate_articles?
480
-
481
- ##
482
- # @param url [String]
483
- # @param strategy [Symbol]
484
- # @return [Html2rss::RequestService::Response]
485
- def fetch_response(url, strategy) # rubocop:disable Metrics/MethodLength -- session construction
486
- raw_config = Config.auto_source_config(
487
- url:,
488
- request_controls: Config::RequestControls.from_shortcut(strategy:)
489
- )
490
- raw_config[:strategy] = strategy
491
- config = Config.from_hash(raw_config)
492
- resources = FeedPipeline::RuntimePolicy.resources_for(config)
493
- session = RequestSession.build(
494
- config:,
495
- strategy: config.strategy,
496
- budget: resources.budget,
497
- policy: resources.policy
391
+ def prompt_result(text)
392
+ ::MCP::Prompt::Result.new(
393
+ messages: [
394
+ ::MCP::Prompt::Message.new(role: 'user', content: ::MCP::Content::Text.new(text))
395
+ ]
498
396
  )
499
- session.fetch_initial_response
500
- end
501
- module_function :fetch_response
502
-
503
- ##
504
- # @param parsed [Object] parsed response body
505
- # @return [Array<String>, Hash]
506
- def scraper_info(parsed)
507
- return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
508
-
509
- begin
510
- Html2rss::AutoSource::Scraper.from(parsed).map(&:name)
511
- rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
512
- { none_found: error.category.to_s }
513
- end
514
397
  end
515
- module_function :scraper_info
516
-
517
- ##
518
- # @param response [Html2rss::RequestService::Response]
519
- # @return [Hash, nil]
520
- def sst_stats_from(response)
521
- return nil unless response.html_response?
522
-
523
- doc = sst_document(response)
524
- return nil unless doc
525
398
 
526
- { node_count: doc.node_count, degraded: doc.degraded }
527
- rescue StandardError
528
- nil
529
- end
530
- module_function :sst_stats_from
531
-
532
- ##
533
- # @param response [Html2rss::RequestService::Response]
534
- # @return [Html2rss::SST::Document, nil]
535
- def sst_document(response)
536
- Html2rss::SST::Normalizer.call(response.body)
537
- rescue ArgumentError
538
- nil
399
+ def scrape_webpage_text(url)
400
+ <<~MSG.strip
401
+ Scrape #{url} with scrape_url (strategy auto). One call is enough — auto already runs Faraday then Botasaurus.
402
+ Follow envelope next_step and guidance. Call inspect_url only if articles are empty/weak or you need recon (final_url, status, scheme_downgrade, alternate_feeds).
403
+ Do not retry scrape_url with explicit faraday after auto. Read html2rss://runtime if next_step is read_runtime.
404
+ Return payload.items (not a raw JSON array).
405
+ MSG
539
406
  end
540
- module_function :sst_document
541
407
 
542
- ##
543
- # @param sst [Html2rss::SST::Document]
544
- # @param url [String]
545
- # @return [Hash]
546
- def segment_stats(sst, url)
547
- segments = discover_segments(sst, url)
548
- return { found: 0 } if segments.empty?
549
-
550
- {
551
- found: segments.size,
552
- strategies: segments.map(&:strategy).uniq,
553
- sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
554
- }
555
- end
556
- module_function :segment_stats
557
-
558
- ##
559
- # @param sst [Html2rss::SST::Document]
560
- # @param url [String]
561
- # @return [Array]
562
- def discover_segments(sst, url)
563
- link_resolver = Scoring::LinkResolver.new(url)
564
- AutoSource::Segmenter.call(
565
- sst,
566
- base_url: url,
567
- strategy: :list,
568
- link_resolver:
569
- )
570
- rescue StandardError
571
- []
408
+ def capture_feed_config_text(url)
409
+ <<~MSG.strip
410
+ Build a reusable html2rss feed config for #{url}:
411
+ 1) capture_config — YAML is payload.yaml. Check payload.articles_count and payload.has_selectors. Strive to keep enhance: true (false only when chrome leaks into items).
412
+ 2) Follow next_step. If weak or you need recon, inspect_url. Auto already hops to Botasaurus; do not retry capture with botasaurus unless Faraday was blocked.
413
+ 3) validate_config with yaml (or config hash) — must not be isError
414
+ 4) apply_config isError if zero items. Confirm payload.item_count before shipping.
415
+ If the destination is html2rss-configs, rewrite the draft for directory.topics and explicit channel title/url. Return YAML.
416
+ MSG
572
417
  end
573
- module_function :discover_segments
574
418
  end
575
419
  end
576
420
  end