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.
- checksums.yaml +4 -4
- data/README.md +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +32 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +268 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -40
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +48 -24
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
data/lib/html2rss/mcp/outcome.rb
CHANGED
|
@@ -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
|
|
9
|
-
#
|
|
10
|
-
class Outcome
|
|
11
|
-
# Matches {ConfigArgument} XOR
|
|
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
|
|
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
|
|
69
|
+
# @param report [PageRecon::Diagnostics::Report]
|
|
82
70
|
# @return [Outcome]
|
|
83
|
-
def inspect(
|
|
84
|
-
next_step = inspect_next_step(
|
|
85
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
124
|
-
|
|
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.
|
|
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(
|
|
153
|
-
return NextStep.
|
|
154
|
-
return NextStep.
|
|
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.
|
|
194
|
+
NextStep.scrape
|
|
157
195
|
end
|
|
158
196
|
|
|
159
|
-
def
|
|
160
|
-
|
|
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.
|
|
248
|
+
when Contract::UnpublishedRequestError then NextStep.validate
|
|
177
249
|
when ArgumentError then argument_error_next_step(error)
|
|
178
|
-
else NextStep.
|
|
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.
|
|
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
|