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
@@ -5,10 +5,10 @@ module Html2rss
5
5
  Outcome = Data.define(:ok, :next_step, :guidance, :payload)
6
6
 
7
7
  ##
8
- # Typed MCP tool result. Owns next-step policy and guidance copy so the
9
- # protocol adapter does not branch on quality heuristics.
10
- class Outcome
11
- # Matches {ConfigArgument} XOR {ArgumentError} messages.
8
+ # Typed MCP tool result. Owns next-step policy; guidance copy lives in
9
+ # {Playbook}.
10
+ class Outcome # rubocop:disable Metrics/ClassLength -- next-step policy + factories stay co-located
11
+ # Matches {ConfigArgument} XOR +ArgumentError+ messages.
12
12
  XOR_ERROR = /exactly one of config or yaml/
13
13
  NextStep = Data.define(:name, :guidance)
14
14
 
@@ -16,19 +16,7 @@ module Html2rss
16
16
  # Closed set of agent next actions. Invalid names cannot be constructed.
17
17
  class NextStep
18
18
  # Wire names for +next_step+.
19
- NAMES = %i[done inspect_url validate_config apply_config scrape_url capture_config read_runtime].freeze
20
- # Default guidance copy keyed by {NAMES}.
21
- GUIDANCE = {
22
- done: 'Done. Read payload for the result.',
23
- inspect_url: 'Call inspect_url next. Read payload for recon (final_url, status, ' \
24
- 'scheme_downgrade, alternate_feeds).',
25
- validate_config: 'Call validate_config with payload.yaml or a config hash (XOR, not both).',
26
- apply_config: 'Call apply_config next. Confirm payload.item_count before shipping.',
27
- scrape_url: 'Call scrape_url for articles now. strategy auto already runs Faraday then Botasaurus.',
28
- capture_config: 'Call capture_config for a reusable YAML draft, then follow next_step.',
29
- read_runtime: 'Read html2rss://runtime. Set BOTASAURUS_SCRAPER_URL on the MCP process ' \
30
- 'if botasaurus_configured is false.'
31
- }.freeze
19
+ NAMES = %i[done inspect recon validate apply scrape capture read_runtime test].freeze
32
20
 
33
21
  ##
34
22
  # @param name [Symbol, String]
@@ -37,7 +25,7 @@ module Html2rss
37
25
  step = name.to_sym
38
26
  raise ArgumentError, "unknown next_step: #{name.inspect}" unless NAMES.include?(step)
39
27
 
40
- super(name: step, guidance: (guidance || GUIDANCE.fetch(step)).freeze)
28
+ super(name: step, guidance: (guidance || Playbook::GUIDANCE.fetch(step)).freeze)
41
29
  end
42
30
 
43
31
  class << self
@@ -78,11 +66,21 @@ module Html2rss
78
66
  end
79
67
 
80
68
  ##
81
- # @param payload [Hash] inspect recon Hash
69
+ # @param report [PageRecon::Diagnostics::Report]
82
70
  # @return [Outcome]
83
- def inspect(payload:)
84
- next_step = inspect_next_step(payload)
85
- new(ok: true, next_step:, guidance: next_step.guidance, payload:)
71
+ def inspect(report:)
72
+ next_step = inspect_next_step(report)
73
+ guidance = Playbook.inspect_guidance(report)
74
+ new(ok: true, next_step:, guidance:, payload: report.to_wire_h)
75
+ end
76
+
77
+ ##
78
+ # @param result [Html2rss::Recon::Result]
79
+ # @return [Outcome]
80
+ def recon(result:)
81
+ next_step = recon_next_step(result)
82
+ guidance = Playbook.recon_guidance(result, next_step)
83
+ new(ok: true, next_step:, guidance:, payload: result.to_h)
86
84
  end
87
85
 
88
86
  ##
@@ -94,13 +92,17 @@ module Html2rss
94
92
  # @param segment_strategy [Symbol, String, nil]
95
93
  # @param selected_strategy [Symbol, String, nil]
96
94
  # @param admission_drops [Hash]
95
+ # @param native_feed [String, nil]
96
+ # @param suggested_channel_url [String, nil]
97
97
  # @return [Outcome]
98
98
  def capture(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
99
- segment_strategy: nil, selected_strategy: nil, admission_drops: {})
100
- next_step = capture_next_step(articles_count:, has_selectors:)
99
+ segment_strategy: nil, selected_strategy: nil, admission_drops: {}, native_feed: nil,
100
+ suggested_channel_url: nil)
101
+ next_step = capture_next_step(articles_count:, has_selectors:, native_feed:)
101
102
  new(ok: true, next_step:, guidance: next_step.guidance, payload: capture_payload(
102
103
  yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:,
103
- segment_strategy:, selected_strategy:, admission_drops:
104
+ segment_strategy:, selected_strategy:, admission_drops:, native_feed:,
105
+ suggested_channel_url:
104
106
  ))
105
107
  end
106
108
 
@@ -109,19 +111,50 @@ module Html2rss
109
111
  # @return [Outcome]
110
112
  def validate(errors:)
111
113
  ok = errors.nil?
112
- next_step = ok ? NextStep.apply_config : NextStep.validate_config
114
+ next_step = ok ? NextStep.test : NextStep.validate
113
115
  new(ok:, next_step:, guidance: next_step.guidance, payload: ok ? {} : { errors: })
114
116
  end
115
117
 
118
+ ##
119
+ # @param test_result [Html2rss::Test::Result]
120
+ # @return [Outcome]
121
+ def test(test_result)
122
+ next_step = test_next_step(test_result)
123
+ new(
124
+ ok: test_result.success,
125
+ next_step:,
126
+ guidance: test_guidance(test_result, next_step),
127
+ payload: test_result.to_h
128
+ )
129
+ end
130
+
131
+ ##
132
+ # @param batch_result [Html2rss::Batch::BatchResult]
133
+ # @return [Outcome]
134
+ def batch_scrape(batch_result) = batch(batch_result, NextStep.scrape)
135
+
136
+ ##
137
+ # @param batch_result [Html2rss::Batch::BatchResult]
138
+ # @return [Outcome]
139
+ def batch_inspect(batch_result) = batch(batch_result, NextStep.inspect)
140
+
141
+ ##
142
+ # @param batch_result [Html2rss::Batch::BatchResult]
143
+ # @return [Outcome]
144
+ def batch_recon(batch_result) = batch(batch_result, NextStep.recon)
145
+
116
146
  ##
117
147
  # @param rss [String]
118
148
  # @param item_count [Integer]
119
149
  # @param empty [Boolean] {FeedResult#empty?} (ship gate); defaults to zero items
150
+ # @param quality_report [Hash, nil] optional ship-quality audit summary
120
151
  # @return [Outcome]
121
- def apply(rss:, item_count:, empty: item_count.zero?)
152
+ def apply(rss:, item_count:, empty: item_count.zero?, quality_report: nil)
122
153
  ok = !empty
123
- next_step = ok ? NextStep.done : NextStep.inspect_url
124
- new(ok:, next_step:, guidance: next_step.guidance, payload: { rss:, item_count: })
154
+ next_step = ok ? NextStep.done : NextStep.inspect
155
+ payload = { rss:, item_count: }
156
+ payload[:quality_report] = quality_report if quality_report
157
+ new(ok:, next_step:, guidance: next_step.guidance, payload:)
125
158
  end
126
159
 
127
160
  ##
@@ -135,11 +168,16 @@ module Html2rss
135
168
 
136
169
  private
137
170
 
171
+ def batch(batch_result, failure_step)
172
+ step = batch_result.successful.positive? ? NextStep.done : failure_step
173
+ new(ok: true, next_step: step, guidance: step.guidance, payload: batch_result.to_h)
174
+ end
175
+
138
176
  def scrape_next_step(empty, botasaurus_configured:)
139
177
  return NextStep.done unless empty
140
178
  return NextStep.read_runtime unless botasaurus_configured
141
179
 
142
- NextStep.inspect_url
180
+ NextStep.inspect
143
181
  end
144
182
 
145
183
  def scrape_payload(items:, requested_strategy:, channel_title:, admission_drops:)
@@ -149,21 +187,55 @@ module Html2rss
149
187
  }
150
188
  end
151
189
 
152
- def inspect_next_step(payload)
153
- return NextStep.done if Array(payload[:alternate_feeds]).any?
154
- return NextStep.capture_config if payload[:articles_count].to_i.positive?
190
+ def inspect_next_step(report)
191
+ return NextStep.recon if report.alternate_feeds?
192
+ return NextStep.capture if report.articles_count.positive?
155
193
 
156
- NextStep.scrape_url
194
+ NextStep.scrape
157
195
  end
158
196
 
159
- def capture_next_step(articles_count:, has_selectors:)
160
- articles_count.positive? && has_selectors ? NextStep.validate_config : NextStep.inspect_url
197
+ def recon_next_step(result)
198
+ return NextStep.done if result.defer?
199
+ return NextStep.capture if result.build?
200
+
201
+ NextStep.scrape
202
+ end
203
+
204
+ def capture_next_step(articles_count:, has_selectors:, native_feed: nil)
205
+ return NextStep.done if native_feed
206
+
207
+ articles_count.positive? && has_selectors ? NextStep.test : NextStep.inspect
208
+ end
209
+
210
+ def test_next_step(test_result)
211
+ return NextStep.apply if test_result.success
212
+
213
+ kind = test_result.failure_kind
214
+ return NextStep.validate if kind&.schema?
215
+ return NextStep.capture if kind&.execution? || kind&.min_items? || kind&.quality?
216
+
217
+ NextStep.capture
218
+ end
219
+
220
+ def test_guidance(test_result, next_step)
221
+ base = if test_result.success
222
+ next_step.guidance
223
+ else
224
+ test_result.error_message || next_step.guidance
225
+ end
226
+ warnings = test_result.quality_report&.warnings
227
+ return base if warnings.nil? || warnings.empty?
228
+
229
+ "#{base} Review payload.quality_report warnings: #{warnings.join(', ')}."
161
230
  end
162
231
 
163
232
  def capture_payload(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
164
- segment_strategy:, selected_strategy:, admission_drops:)
233
+ segment_strategy:, selected_strategy:, admission_drops:, native_feed: nil,
234
+ suggested_channel_url: nil)
165
235
  {
166
236
  yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy: requested_strategy.to_s,
237
+ **(native_feed ? { native_feed: native_feed.to_s } : {}),
238
+ **(suggested_channel_url ? { suggested_channel_url: suggested_channel_url.to_s } : {}),
167
239
  **(segment_strategy ? { segment_strategy: segment_strategy.to_s } : {}),
168
240
  **(selected_strategy ? { selected_strategy: selected_strategy.to_s } : {}),
169
241
  **(admission_drops.any? ? { admission_drops: } : {})
@@ -173,14 +245,14 @@ module Html2rss
173
245
  def next_step_for_error(error)
174
246
  case error
175
247
  when RequestService::BotasaurusConfigurationError then NextStep.read_runtime
176
- when Contract::UnpublishedRequestError then NextStep.validate_config
248
+ when Contract::UnpublishedRequestError then NextStep.validate
177
249
  when ArgumentError then argument_error_next_step(error)
178
- else NextStep.inspect_url
250
+ else NextStep.inspect
179
251
  end
180
252
  end
181
253
 
182
254
  def argument_error_next_step(error)
183
- XOR_ERROR.match?(error.message) ? NextStep.validate_config : NextStep.inspect_url
255
+ XOR_ERROR.match?(error.message) ? NextStep.validate : NextStep.inspect
184
256
  end
185
257
  end
186
258
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module MCP
5
+ ##
6
+ # Runtime facts for the MCP process (env configuration, wire coercion).
7
+ module Runtime
8
+ # Published +html2rss://runtime+ resource (never leaks secrets).
9
+ Snapshot = Data.define(
10
+ :version,
11
+ :mcp_contract_version,
12
+ :catalog_fingerprint,
13
+ :tools,
14
+ :botasaurus_configured
15
+ )
16
+
17
+ module_function
18
+
19
+ ##
20
+ # @return [Boolean] whether Botasaurus transport is configured in this process
21
+ def botasaurus_configured?
22
+ !ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
23
+ end
24
+
25
+ ##
26
+ # @return [Snapshot] runtime capabilities and catalog identity for MCP clients
27
+ def snapshot
28
+ Snapshot.new(
29
+ version: Html2rss::VERSION,
30
+ mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
31
+ catalog_fingerprint: Contract.catalog_fingerprint,
32
+ tools: Contract.catalog_tools,
33
+ botasaurus_configured: botasaurus_configured?
34
+ )
35
+ end
36
+
37
+ ##
38
+ # @param strategy [String, Symbol, nil]
39
+ # @return [Symbol]
40
+ def coerce_strategy(strategy)
41
+ (strategy || :auto).to_sym
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module MCP
5
+ module Server
6
+ ##
7
+ # MCP tool registration and outcome mapping over public html2rss APIs.
8
+ module Tools # rubocop:disable Metrics/ModuleLength -- declarative registry + substantive handlers
9
+ # Declarative MCP tool registrations consumed by {register_all}.
10
+ TOOLS = [
11
+ {
12
+ name: 'scrape',
13
+ kind: :url,
14
+ description: 'One-shot article extraction as JSON Feed items. ' \
15
+ 'Use when you need articles now without a saved config. ' \
16
+ 'strategy "auto" triggers fallback chain (faraday → botasaurus) for JS-rendered sites.',
17
+ input_schema: Contract::SCRAPE_INPUT_SCHEMA,
18
+ handler: :scrape_outcome
19
+ },
20
+ {
21
+ name: 'inspect',
22
+ kind: :url,
23
+ description: 'Diagnostic page analysis (scrapers, SST, segments, final URL, status, ' \
24
+ 'rel=alternate feeds). Use recon for BUILD/DEFER/DROP verdict and native_feed preference.',
25
+ input_schema: Contract::INSPECT_INPUT_SCHEMA,
26
+ call: lambda { |url:, strategy: 'auto', **|
27
+ Outcome.inspect(
28
+ report: PageRecon::Diagnostics.call(
29
+ url:, strategy: Runtime.coerce_strategy(strategy), deep: false
30
+ )
31
+ )
32
+ }
33
+ },
34
+ {
35
+ name: 'recon',
36
+ kind: :url,
37
+ description: 'Curation verdict and native_feed preference for a URL. ' \
38
+ 'Use after inspect when alternates warrant deeper recon, or when you need BUILD/DEFER/DROP.',
39
+ input_schema: Contract::RECON_INPUT_SCHEMA,
40
+ call: lambda { |url:, strategy: 'auto', **|
41
+ Outcome.recon(result: Html2rss.recon(url, strategy: Runtime.coerce_strategy(strategy)))
42
+ }
43
+ },
44
+ {
45
+ name: 'batch_scrape',
46
+ kind: :batch,
47
+ batch_method: :batch_scrape,
48
+ limit_default: 10,
49
+ description: 'Scrape multiple URLs in parallel with per-URL error isolation. ' \
50
+ 'Returns structured JSON Feed items and extraction counts.',
51
+ input_schema: Contract::BATCH_SCRAPE_INPUT_SCHEMA
52
+ },
53
+ {
54
+ name: 'batch_inspect',
55
+ kind: :batch,
56
+ batch_method: :batch_inspect,
57
+ description: 'Inspect multiple URLs in parallel with per-URL error isolation. ' \
58
+ 'Returns final redirected URLs, status codes, and rel="alternate" feeds.',
59
+ input_schema: Contract::BATCH_INSPECT_INPUT_SCHEMA
60
+ },
61
+ {
62
+ name: 'batch_recon',
63
+ kind: :batch,
64
+ batch_method: :batch_recon,
65
+ description: 'Run recon across multiple URLs in parallel with per-URL error isolation. ' \
66
+ 'Returns verdict, native_feed, and surface classification per URL.',
67
+ input_schema: Contract::BATCH_RECON_INPUT_SCHEMA
68
+ },
69
+ {
70
+ name: 'capture',
71
+ kind: :capture,
72
+ description: 'Derive a reusable html2rss feed config from a URL. ' \
73
+ 'Use when the goal is a durable YAML (then test → apply). ' \
74
+ 'Returns YAML inside payload.yaml (same serializer as CLI capture). ' \
75
+ 'Draft only — catalog feeds still need directory.topics and title/url; ' \
76
+ 'enhance defaults from admission evidence (false when chrome drops are high). ' \
77
+ 'Full schema options live in resource html2rss://schema.',
78
+ input_schema: Contract::CAPTURE_INPUT_SCHEMA,
79
+ handler: :capture_outcome
80
+ },
81
+ {
82
+ name: 'validate',
83
+ kind: :config_xor,
84
+ description: 'Validate a feed config hash XOR yaml string against the html2rss JSON schema. ' \
85
+ 'Call before test. Failures return isError with payload.errors. ' \
86
+ 'Full schema lives in resource html2rss://schema.',
87
+ input_schema: Contract::CONFIG_XOR_SCHEMA,
88
+ annotations: Contract::ANNOTATIONS_VALIDATE,
89
+ call: lambda { |config: nil, yaml: nil, **|
90
+ validation = Html2rss::Config.validate(ConfigArgument.parse(config:, yaml:).config)
91
+ Outcome.validate(errors: validation.success? ? nil : validation.errors.to_h)
92
+ }
93
+ },
94
+ {
95
+ name: 'test',
96
+ kind: :config_xor,
97
+ description: 'Validate schema and execute live extraction (asserting >= min_items items). ' \
98
+ 'Call after capture or validate; on success next_step is apply. ' \
99
+ 'Returns test summary in payload with sample items, timing, failure_kind, ' \
100
+ 'and quality_report (warnings for duplicate URLs, junk titles, native feed). ' \
101
+ 'Set strict_quality to fail on duplicate URLs, >50% junk titles, or short titles.',
102
+ input_schema: Contract::TEST_INPUT_SCHEMA,
103
+ call: lambda { |config: nil, yaml: nil, min_items: 1, strict_quality: false,
104
+ compare_enhance: false, **kwargs|
105
+ feed_config = ConfigArgument.parse(config:, yaml:).config
106
+ test_args = { min_items:, strict_quality:, compare_enhance: }
107
+ test_args[:strategy] = Runtime.coerce_strategy(kwargs[:strategy]) if kwargs.key?(:strategy)
108
+ test_result = Html2rss.test(feed_config, **test_args)
109
+ Outcome.test(test_result)
110
+ }
111
+ },
112
+ {
113
+ name: 'apply',
114
+ kind: :config_xor,
115
+ description: 'Apply a validated feed config (hash XOR yaml) and return RSS XML in payload.rss. ' \
116
+ 'isError when the feed has zero items (ship gate). payload.item_count is RSS item count. ' \
117
+ 'Use after test succeeds.',
118
+ input_schema: Contract::APPLY_INPUT_SCHEMA,
119
+ handler: :apply_outcome
120
+ }
121
+ ].freeze
122
+
123
+ class << self # rubocop:disable Metrics/ClassLength -- registration engine + handlers
124
+ ##
125
+ # Registers all MCP tools on +server+ via +registrar+ (Server.define_envelope_tool).
126
+ #
127
+ # @param server [::MCP::Server]
128
+ # @param registrar [Proc]
129
+ # @return [void]
130
+ def register_all(server, registrar:)
131
+ TOOLS.each { |entry| register_tool(server, registrar, entry) }
132
+ end
133
+
134
+ private
135
+
136
+ def register_tool(server, registrar, entry)
137
+ case entry[:kind]
138
+ when :url then register_url_tool(server, registrar, entry)
139
+ when :batch then register_batch_tool(server, registrar, entry)
140
+ when :config_xor then register_config_xor_tool(server, registrar, entry)
141
+ when :capture then register_capture_tool(server, registrar, entry)
142
+ else raise ArgumentError, "unknown tool kind: #{entry[:kind].inspect}"
143
+ end
144
+ end
145
+
146
+ def register_url_tool(server, registrar, entry)
147
+ handler = entry[:handler] ? method(entry[:handler]) : entry[:call]
148
+ registrar.call(
149
+ server,
150
+ name: entry[:name],
151
+ description: entry[:description],
152
+ input_schema: entry[:input_schema],
153
+ annotations: entry.fetch(:annotations, Contract::ANNOTATIONS_OPEN_WORLD)
154
+ ) do |**kwargs|
155
+ handler.call(**tool_kwargs(kwargs))
156
+ end
157
+ end
158
+
159
+ def register_batch_tool(server, registrar, entry) # rubocop:disable Metrics/MethodLength
160
+ name = entry[:name]
161
+ batch_method = entry[:batch_method]
162
+ limit_default = entry[:limit_default]
163
+ registrar.call(
164
+ server,
165
+ name:,
166
+ description: entry[:description],
167
+ input_schema: entry[:input_schema]
168
+ ) do |urls:, strategy: 'auto', **kwargs|
169
+ concurrency = kwargs.fetch(:concurrency, Batch::DEFAULT_CONCURRENCY)
170
+ batch_args = { urls:, strategy: Runtime.coerce_strategy(strategy), concurrency: }
171
+ batch_args[:limit] = kwargs.fetch(:limit, limit_default) unless limit_default.nil?
172
+ Outcome.public_send(name, Batch.public_send(batch_method, **batch_args))
173
+ end
174
+ end
175
+
176
+ def register_config_xor_tool(server, registrar, entry)
177
+ handler = entry[:handler] ? method(entry[:handler]) : entry[:call]
178
+ registrar.call(
179
+ server,
180
+ name: entry[:name],
181
+ description: entry[:description],
182
+ input_schema: entry[:input_schema],
183
+ annotations: entry.fetch(:annotations, Contract::ANNOTATIONS_OPEN_WORLD)
184
+ ) do |**kwargs|
185
+ handler.call(**tool_kwargs(kwargs))
186
+ end
187
+ end
188
+
189
+ def register_capture_tool(server, registrar, entry)
190
+ handler = method(entry[:handler])
191
+ registrar.call(
192
+ server,
193
+ name: entry[:name],
194
+ description: entry[:description],
195
+ input_schema: entry[:input_schema]
196
+ ) do |**kwargs|
197
+ handler.call(**tool_kwargs(kwargs))
198
+ end
199
+ end
200
+
201
+ def tool_kwargs(inputs)
202
+ args = inputs.dup
203
+ args.delete(:server_context)
204
+ args
205
+ end
206
+
207
+ def scrape_outcome(url:, strategy: 'auto', limit: 25, items_selector: nil)
208
+ wire = Batch.scrape_wire(url:, strategy: Runtime.coerce_strategy(strategy), limit:, items_selector:)
209
+ Outcome.scrape(
210
+ items: wire[:items],
211
+ requested_strategy: wire[:strategy],
212
+ channel_title: wire[:channel_title],
213
+ admission_drops: wire[:admission_drops],
214
+ botasaurus_configured: Runtime.botasaurus_configured?
215
+ )
216
+ end
217
+
218
+ def capture_outcome(url:, strategy: 'auto', items_selector: nil, force: false, topics: nil, title: nil, # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
219
+ summary: nil, enhance: nil, limit: nil, max_redirects: nil, max_requests: nil)
220
+ plan = Runtime.coerce_strategy(strategy)
221
+ result = Html2rss::Capture.build(
222
+ url,
223
+ strategy: plan,
224
+ items_selector:,
225
+ force:,
226
+ topics:,
227
+ title:,
228
+ summary:,
229
+ enhance:,
230
+ limit:,
231
+ max_redirects:,
232
+ max_requests:
233
+ )
234
+ Outcome.capture(
235
+ yaml: result.yaml,
236
+ articles_count: result.articles_count,
237
+ has_selectors: result.has_selectors,
238
+ channel_title: result.channel_title,
239
+ requested_strategy: plan,
240
+ segment_strategy: result.segment_strategy,
241
+ selected_strategy: result.selected_strategy,
242
+ admission_drops: result.admission_drops,
243
+ native_feed: result.native_feed,
244
+ suggested_channel_url: result.suggested_channel_url
245
+ )
246
+ end
247
+
248
+ def apply_outcome(url:, config: nil, yaml: nil)
249
+ feed_config = HashUtil.deep_dup(ConfigArgument.parse(config:, yaml:).config)
250
+ feed_config[:channel] ||= {}
251
+ feed_config[:channel][:url] ||= url
252
+ outcome, feed_result = FeedPipeline.new(feed_config).to_outcome_and_result
253
+ apply_feed_outcome(feed_config, feed_result, outcome)
254
+ end
255
+
256
+ def apply_feed_outcome(feed_config, feed_result, outcome) # rubocop:disable Metrics/MethodLength -- quality_report + RSS payload
257
+ rss = feed_result.to_rss
258
+ quality_report = Html2rss::Test.quality_report_for(
259
+ rss.items,
260
+ channel_url: feed_config.dig(:channel, :url).to_s,
261
+ raw_config: feed_config,
262
+ feed_result:,
263
+ pipeline_outcome: outcome,
264
+ probe_native_feed: false
265
+ )
266
+ Outcome.apply(
267
+ rss: rss.to_s,
268
+ item_count: rss.items.size,
269
+ empty: feed_result.empty?,
270
+ quality_report: quality_report.to_h
271
+ )
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end