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.
- checksums.yaml +4 -4
- data/README.md +41 -18
- data/html2rss.gemspec +1 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +121 -56
- data/lib/html2rss/auto_source/scraper.rb +13 -0
- data/lib/html2rss/auto_source.rb +34 -8
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +120 -117
- data/lib/html2rss/cli.rb +35 -17
- data/lib/html2rss/config/schema.rb +12 -0
- data/lib/html2rss/config/validator.rb +31 -8
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +24 -6
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +30 -12
- data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
- data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
- data/lib/html2rss/html/article_extractor.rb +95 -17
- data/lib/html2rss/html/article_rules/category.rb +28 -11
- data/lib/html2rss/html/article_rules/date.rb +60 -6
- data/lib/html2rss/html/article_rules/description.rb +122 -0
- data/lib/html2rss/html/card_walk.rb +42 -0
- data/lib/html2rss/html/feed_link.rb +34 -0
- data/lib/html2rss/html/navigator.rb +18 -0
- data/lib/html2rss/html/sst_article_extractor.rb +119 -32
- data/lib/html2rss/link_destination/path_classifier.rb +49 -35
- data/lib/html2rss/mcp/config_argument.rb +42 -0
- data/lib/html2rss/mcp/contract.rb +173 -0
- data/lib/html2rss/mcp/inspect.rb +241 -0
- data/lib/html2rss/mcp/outcome.rb +188 -0
- data/lib/html2rss/mcp/server.rb +253 -409
- data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
- data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +57 -6
- data/lib/html2rss/request_service.rb +6 -1
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +20 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +30 -6
- data/schema/html2rss-config.schema.json +26 -15
- metadata +15 -4
data/lib/html2rss/mcp/server.rb
CHANGED
|
@@ -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.
|
|
10
|
-
# This module
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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"
|
|
111
|
-
-
|
|
112
|
-
2. Need a reusable feed YAML
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
147
|
+
1. Need articles now (no saved config)? → scrape_url (1 call)
|
|
148
|
+
- strategy "auto" runs Faraday → Botasaurus 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)
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
|
|
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
|
-
'
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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/
|
|
198
|
-
|
|
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
|
|
202
|
-
'Returns
|
|
203
|
-
'
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
|
|
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
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
270
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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: '
|
|
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.
|
|
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
|
|
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
|
|
368
|
+
description: 'Guided one-shot scrape: one scrape_url call (auto already falls back)',
|
|
334
369
|
arguments: [
|
|
335
|
-
|
|
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
|
-
|
|
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 →
|
|
382
|
+
description: 'Guided capture → validate → apply; YAML draft plus catalog rewrite',
|
|
359
383
|
arguments: [
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|