html2rss 0.25.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +34 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +7 -2
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +64 -5
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +18 -27
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +10 -3
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/sst_article_extractor.rb +36 -7
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +3 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -1
- data/schema/html2rss-config.schema.json +408 -85
- metadata +54 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- data/lib/html2rss/scoring/anchor_score.rb +0 -34
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module MCP
|
|
5
|
+
##
|
|
6
|
+
# Thin MCP wire adapter over public html2rss APIs.
|
|
7
|
+
#
|
|
8
|
+
# Ownership: scraping/capture/validate/feed stay on gem entrypoints
|
|
9
|
+
# ({Html2rss.auto_json_feed}, {Capture.build}, {Config.validate}, {Html2rss.feed}).
|
|
10
|
+
# This module only maps MCP kwargs ↔ those APIs and shapes Tool::Response.
|
|
11
|
+
#
|
|
12
|
+
# Strategy note: MCP +auto+ passes through to FeedPipeline AutoFallback
|
|
13
|
+
# (faraday → botasaurus). Concrete strategies are used as-is.
|
|
14
|
+
# Botasaurus requires +BOTASAURUS_SCRAPER_URL+.
|
|
15
|
+
module Server # rubocop:disable Metrics/ModuleLength
|
|
16
|
+
# MCP server display name.
|
|
17
|
+
SERVER_NAME = 'html2rss'
|
|
18
|
+
# MCP server version (mirrors the gem version).
|
|
19
|
+
SERVER_VERSION = Html2rss::VERSION
|
|
20
|
+
# Loopback bind for HTTP transport (local use only).
|
|
21
|
+
HTTP_BIND_HOST = '127.0.0.1'
|
|
22
|
+
|
|
23
|
+
class << self # rubocop:disable Metrics/ClassLength
|
|
24
|
+
##
|
|
25
|
+
# Starts the MCP server with the given transport.
|
|
26
|
+
#
|
|
27
|
+
# @param transport [Symbol] +:stdio+ or +:http+
|
|
28
|
+
# @param port [Integer] port for HTTP transport
|
|
29
|
+
def start(transport: :stdio, port: 8080)
|
|
30
|
+
app = build
|
|
31
|
+
|
|
32
|
+
case transport
|
|
33
|
+
when :stdio
|
|
34
|
+
::MCP::Server::Transports::StdioTransport.new(app).open
|
|
35
|
+
when :http
|
|
36
|
+
start_http(app, port:)
|
|
37
|
+
else
|
|
38
|
+
raise ArgumentError, "Unknown transport: #{transport.inspect}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# Builds the configured MCP protocol server (tools/resources/prompts).
|
|
44
|
+
#
|
|
45
|
+
# @return [::MCP::Server]
|
|
46
|
+
def build
|
|
47
|
+
::MCP::Server.new(
|
|
48
|
+
name: SERVER_NAME,
|
|
49
|
+
version: SERVER_VERSION,
|
|
50
|
+
instructions: instructions_text
|
|
51
|
+
).tap do |server|
|
|
52
|
+
register_tools(server)
|
|
53
|
+
register_resources(server)
|
|
54
|
+
register_prompts(server)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Maps MCP strategy shortcut to a feed-level strategy plan.
|
|
60
|
+
# +:auto+ passes through as +:auto+ so the FeedPipeline's AutoFallback
|
|
61
|
+
# chain (faraday → botasaurus) is triggered for JS-rendered sites.
|
|
62
|
+
#
|
|
63
|
+
# @param strategy [String, Symbol, nil]
|
|
64
|
+
# @return [Symbol]
|
|
65
|
+
def resolve_mcp_strategy(strategy)
|
|
66
|
+
(strategy || :auto).to_sym
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# @param text [String]
|
|
71
|
+
# @param error [Boolean]
|
|
72
|
+
# @param meta [Hash, nil]
|
|
73
|
+
# @return [::MCP::Tool::Response]
|
|
74
|
+
def text_response(text, error: false, meta: nil)
|
|
75
|
+
::MCP::Tool::Response.new([{ type: 'text', text: }], error:, meta:)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
##
|
|
79
|
+
# @param error [Exception]
|
|
80
|
+
# @return [::MCP::Tool::Response]
|
|
81
|
+
def error_response(error)
|
|
82
|
+
text_response("Error: #{error.message}", error: true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def start_http(app, port:) # rubocop:disable Metrics/MethodLength -- require + bind + LoadError message
|
|
88
|
+
require 'rackup'
|
|
89
|
+
require 'rackup/handler/webrick'
|
|
90
|
+
require 'webrick'
|
|
91
|
+
|
|
92
|
+
handler = ::MCP::Server::Transports::StreamableHTTPTransport.new(app, stateless: true)
|
|
93
|
+
Rackup::Handler::WEBrick.run(
|
|
94
|
+
handler,
|
|
95
|
+
Host: HTTP_BIND_HOST,
|
|
96
|
+
Port: port,
|
|
97
|
+
Logger: Html2rss.logger
|
|
98
|
+
)
|
|
99
|
+
rescue LoadError => error
|
|
100
|
+
raise LoadError,
|
|
101
|
+
'HTTP transport requires the rackup and webrick gems ' \
|
|
102
|
+
"(#{error.message}). Install them or use --transport stdio."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def instructions_text # rubocop:disable Metrics/MethodLength -- agent decision tree is the published contract
|
|
106
|
+
<<~TEXT.strip
|
|
107
|
+
html2rss MCP — decide which tool to call:
|
|
108
|
+
|
|
109
|
+
1. Need articles now (no saved config)? → scrape_url
|
|
110
|
+
- strategy "auto" triggers faraday → botasaurus fallback chain for JS-rendered sites.
|
|
111
|
+
- If botasaurus is unconfigured and auto fails, try explicit "faraday" or set up Botasaurus.
|
|
112
|
+
2. Need a reusable feed YAML/config? → capture_config, then validate_config, then apply_config
|
|
113
|
+
3. Debugging why scrape/capture is weak? → inspect_url (scrapers/SST/segments/blocked_surface), then retry scrape/capture
|
|
114
|
+
4. Have a config already? → validate_config (must succeed) → apply_config for RSS XML
|
|
115
|
+
5. Schema / extractor / strategy lists → resources html2rss://schema|extractors|strategies
|
|
116
|
+
|
|
117
|
+
Prefer capture_config when the goal is a durable config; prefer scrape_url for one-shot extraction.
|
|
118
|
+
Botasaurus needs BOTASAURUS_SCRAPER_URL (see docker-compose.botasaurus.yml).
|
|
119
|
+
TEXT
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def register_tools(server)
|
|
123
|
+
register_scrape_url(server)
|
|
124
|
+
register_inspect_url(server)
|
|
125
|
+
register_capture_config(server)
|
|
126
|
+
register_validate_config(server)
|
|
127
|
+
register_apply_config(server)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def register_scrape_url(server) # rubocop:disable Metrics/MethodLength
|
|
131
|
+
server.define_tool(
|
|
132
|
+
name: 'scrape_url',
|
|
133
|
+
description: 'One-shot article extraction as JSON Feed items. ' \
|
|
134
|
+
'Use when you need articles now without a saved config. ' \
|
|
135
|
+
'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
|
+
}
|
|
158
|
+
) do |server_context:, url:, strategy: 'auto', limit: 25, items_selector: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
159
|
+
resolved = Server.resolve_mcp_strategy(strategy)
|
|
160
|
+
feed = Html2rss.auto_json_feed(url, strategy: resolved, limit:, items_selector:)
|
|
161
|
+
items = feed[:items] || []
|
|
162
|
+
Server.text_response(JSON.generate(items), meta: {
|
|
163
|
+
total: items.size,
|
|
164
|
+
strategy: resolved.to_s,
|
|
165
|
+
channel_title: feed[:title]
|
|
166
|
+
})
|
|
167
|
+
rescue StandardError => error
|
|
168
|
+
Server.error_response(error)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def register_inspect_url(server) # rubocop:disable Metrics/MethodLength
|
|
173
|
+
server.define_tool(
|
|
174
|
+
name: 'inspect_url',
|
|
175
|
+
description: 'Diagnostic page analysis (scrapers, SST, segments). ' \
|
|
176
|
+
'Use when scrape_url/capture_config returns little and you need to see why.',
|
|
177
|
+
input_schema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
url: { type: 'string', description: 'Source page URL' },
|
|
181
|
+
strategy: {
|
|
182
|
+
type: 'string',
|
|
183
|
+
enum: %w[auto faraday botasaurus],
|
|
184
|
+
default: 'auto',
|
|
185
|
+
description: 'Request strategy (auto collapses to faraday in MCP)'
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
required: ['url']
|
|
189
|
+
}
|
|
190
|
+
) do |server_context:, url:, strategy: 'auto'| # rubocop:disable Lint/UnusedBlockArgument
|
|
191
|
+
Server.text_response(JSON.pretty_generate(Inspect.call(url:, strategy:)))
|
|
192
|
+
rescue StandardError => error
|
|
193
|
+
Server.error_response(error)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def register_capture_config(server) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
198
|
+
server.define_tool(
|
|
199
|
+
name: 'capture_config',
|
|
200
|
+
description: 'Derive a reusable html2rss feed config from a URL. ' \
|
|
201
|
+
'Use when the goal is a durable YAML/config (then validate_config). ' \
|
|
202
|
+
'Returns config plus quality meta (articles_count, selectors presence). ' \
|
|
203
|
+
'Full schema options live in resource html2rss://schema.',
|
|
204
|
+
input_schema: {
|
|
205
|
+
type: 'object',
|
|
206
|
+
properties: {
|
|
207
|
+
url: { type: 'string', description: 'Source page URL' },
|
|
208
|
+
strategy: {
|
|
209
|
+
type: 'string',
|
|
210
|
+
enum: %w[auto faraday botasaurus],
|
|
211
|
+
default: 'auto',
|
|
212
|
+
description: 'Request strategy (auto collapses to faraday in MCP)'
|
|
213
|
+
},
|
|
214
|
+
items_selector: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
description: 'Optional CSS selector hint for items'
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
required: ['url']
|
|
220
|
+
}
|
|
221
|
+
) do |server_context:, url:, strategy: 'auto', items_selector: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
222
|
+
resolved = Server.resolve_mcp_strategy(strategy)
|
|
223
|
+
result = Html2rss::Capture.build(url, strategy: resolved, items_selector:)
|
|
224
|
+
selectors = result.config[:selectors]
|
|
225
|
+
Server.text_response(
|
|
226
|
+
JSON.pretty_generate(result.config),
|
|
227
|
+
meta: {
|
|
228
|
+
articles_count: result.articles_count,
|
|
229
|
+
channel_title: result.channel_title,
|
|
230
|
+
has_selectors: !selectors.nil? && !selectors.empty?,
|
|
231
|
+
strategy: resolved.to_s
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
rescue StandardError => error
|
|
235
|
+
Server.error_response(error)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def register_validate_config(server) # rubocop:disable Metrics/MethodLength
|
|
240
|
+
server.define_tool(
|
|
241
|
+
name: 'validate_config',
|
|
242
|
+
description: 'Validate a feed config hash against the html2rss JSON schema. ' \
|
|
243
|
+
'Call before apply_config. Failures return isError with structured error details. ' \
|
|
244
|
+
'Full schema lives in resource html2rss://schema.',
|
|
245
|
+
input_schema: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: {
|
|
248
|
+
config: {
|
|
249
|
+
type: 'object',
|
|
250
|
+
description: 'Feed configuration hash with channel and selectors'
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
required: ['config']
|
|
254
|
+
}
|
|
255
|
+
) do |server_context:, config:| # rubocop:disable Lint/UnusedBlockArgument
|
|
256
|
+
config_hash = HashUtil.deep_symbolize_keys(config, context: 'config')
|
|
257
|
+
validation = Html2rss::Config.validate(config_hash)
|
|
258
|
+
|
|
259
|
+
if validation.success?
|
|
260
|
+
Server.text_response('Config is valid.')
|
|
261
|
+
else
|
|
262
|
+
Server.text_response(JSON.generate(validation.errors.to_h), error: true)
|
|
263
|
+
end
|
|
264
|
+
rescue StandardError => error
|
|
265
|
+
Server.error_response(error)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def register_apply_config(server) # rubocop:disable Metrics/MethodLength
|
|
270
|
+
server.define_tool(
|
|
271
|
+
name: 'apply_config',
|
|
272
|
+
description: 'Apply a validated feed config and return RSS XML. ' \
|
|
273
|
+
'Use after validate_config succeeds.',
|
|
274
|
+
input_schema: {
|
|
275
|
+
type: 'object',
|
|
276
|
+
properties: {
|
|
277
|
+
url: { type: 'string', description: 'Source page URL (fills channel.url if missing)' },
|
|
278
|
+
config: {
|
|
279
|
+
type: 'object',
|
|
280
|
+
description: 'Feed configuration hash with selectors'
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
required: %w[url config]
|
|
284
|
+
}
|
|
285
|
+
) do |server_context:, url:, config:| # rubocop:disable Lint/UnusedBlockArgument
|
|
286
|
+
feed_config = HashUtil.deep_symbolize_keys(config, context: 'config')
|
|
287
|
+
feed_config[:channel] ||= {}
|
|
288
|
+
feed_config[:channel][:url] ||= url
|
|
289
|
+
|
|
290
|
+
rss = Html2rss.feed(feed_config)
|
|
291
|
+
Server.text_response(rss.to_s)
|
|
292
|
+
rescue StandardError => error
|
|
293
|
+
Server.error_response(error)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def register_resources(server) # rubocop:disable Metrics/MethodLength
|
|
298
|
+
server.define_resource(
|
|
299
|
+
uri: 'html2rss://schema',
|
|
300
|
+
name: 'Configuration JSON Schema',
|
|
301
|
+
description: 'Full JSON Schema for html2rss feed configurations',
|
|
302
|
+
mime_type: 'application/json'
|
|
303
|
+
) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
304
|
+
schema = Html2rss::Config.json_schema_json(pretty: true)
|
|
305
|
+
[{ uri: 'html2rss://schema', mimeType: 'application/json', text: schema }]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
server.define_resource(
|
|
309
|
+
uri: 'html2rss://extractors',
|
|
310
|
+
name: 'Available Extractors',
|
|
311
|
+
description: 'Registered extractor names for selector configs ' \
|
|
312
|
+
'(full option docs live in html2rss://schema $defs)'
|
|
313
|
+
) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
314
|
+
extractors = Html2rss::Selectors::Extractors::NAME_TO_CLASS.keys.map(&:to_s).sort
|
|
315
|
+
[{ uri: 'html2rss://extractors', mimeType: 'application/json',
|
|
316
|
+
text: JSON.pretty_generate(extractors) }]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
server.define_resource(
|
|
320
|
+
uri: 'html2rss://strategies',
|
|
321
|
+
name: 'Available Strategies',
|
|
322
|
+
description: 'Registered request strategy names'
|
|
323
|
+
) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
324
|
+
strategies = Html2rss::RequestService.instance.strategy_names
|
|
325
|
+
[{ uri: 'html2rss://strategies', mimeType: 'application/json',
|
|
326
|
+
text: JSON.pretty_generate(strategies) }]
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def register_prompts(server) # rubocop:disable Metrics/MethodLength
|
|
331
|
+
server.define_prompt(
|
|
332
|
+
name: 'scrape-webpage',
|
|
333
|
+
description: 'Guided one-shot scrape: scrape_url then inspect/retry with botasaurus if needed',
|
|
334
|
+
arguments: [
|
|
335
|
+
{ name: 'url', description: 'URL to scrape', required: true }
|
|
336
|
+
]
|
|
337
|
+
) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
|
|
338
|
+
url = args.fetch(:url)
|
|
339
|
+
{
|
|
340
|
+
messages: [
|
|
341
|
+
{
|
|
342
|
+
role: 'user',
|
|
343
|
+
content: {
|
|
344
|
+
type: 'text',
|
|
345
|
+
text: <<~MSG.strip
|
|
346
|
+
Scrape #{url} with the scrape_url tool (strategy auto first).
|
|
347
|
+
If articles are empty or look JS-gated, call inspect_url, then scrape_url again with strategy botasaurus.
|
|
348
|
+
Return the structured articles JSON.
|
|
349
|
+
MSG
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
server.define_prompt(
|
|
357
|
+
name: 'capture-feed-config',
|
|
358
|
+
description: 'Guided capture → validate → optional apply for a reusable feed config',
|
|
359
|
+
arguments: [
|
|
360
|
+
{ name: 'url', description: 'URL to analyze', required: true }
|
|
361
|
+
]
|
|
362
|
+
) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
|
|
363
|
+
url = args.fetch(:url)
|
|
364
|
+
{
|
|
365
|
+
messages: [
|
|
366
|
+
{
|
|
367
|
+
role: 'user',
|
|
368
|
+
content: {
|
|
369
|
+
type: 'text',
|
|
370
|
+
text: <<~MSG.strip
|
|
371
|
+
Build a reusable html2rss feed config for #{url}:
|
|
372
|
+
1) capture_config — check _meta.articles_count and has_selectors
|
|
373
|
+
2) If weak, inspect_url and/or retry capture_config with strategy botasaurus
|
|
374
|
+
3) validate_config on the config (must not be isError)
|
|
375
|
+
4) Optionally apply_config to confirm RSS XML
|
|
376
|
+
Return the validated config hash suitable for YAML.
|
|
377
|
+
MSG
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
]
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
##
|
|
387
|
+
# Diagnostic inspect path (not Capture ownership). Fetches once for SST/scraper stats.
|
|
388
|
+
module Inspect # rubocop:disable Metrics/ModuleLength -- diagnostic helpers stay co-located
|
|
389
|
+
module_function
|
|
390
|
+
|
|
391
|
+
##
|
|
392
|
+
# Resolves feed-level strategy plans to concrete strategies for diagnostic fetch.
|
|
393
|
+
# +:auto+ collapses to +:faraday+ (inspect is a single-request diagnostic, not a fallback run).
|
|
394
|
+
#
|
|
395
|
+
# @param strategy [String, Symbol]
|
|
396
|
+
# @return [Symbol]
|
|
397
|
+
def concrete_strategy(strategy)
|
|
398
|
+
plan = FeedPipeline::StrategyPlan.resolve(Server.resolve_mcp_strategy(strategy))
|
|
399
|
+
plan.is_a?(FeedPipeline::StrategyPlan::Auto) ? :faraday : plan.strategy
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
##
|
|
403
|
+
# @param url [String]
|
|
404
|
+
# @param strategy [String, Symbol]
|
|
405
|
+
# @return [Hash]
|
|
406
|
+
def call(url:, strategy: :auto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
407
|
+
resolved = concrete_strategy(strategy)
|
|
408
|
+
response = fetch_response(url, resolved)
|
|
409
|
+
parsed = response.parsed_body
|
|
410
|
+
|
|
411
|
+
result = {
|
|
412
|
+
url:,
|
|
413
|
+
strategy: resolved,
|
|
414
|
+
content_type: response.content_type,
|
|
415
|
+
html_response: response.html_response?,
|
|
416
|
+
scraper_eligibility: scraper_info(parsed),
|
|
417
|
+
sst_stats: sst_stats_from(response)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if response.html_response?
|
|
421
|
+
sst = sst_document(response)
|
|
422
|
+
if sst
|
|
423
|
+
result[:sst] = {
|
|
424
|
+
node_count: sst.node_count,
|
|
425
|
+
degraded: sst.degraded,
|
|
426
|
+
segment_stats: segment_stats(sst, url)
|
|
427
|
+
}
|
|
428
|
+
end
|
|
429
|
+
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
|
+
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
|
+
|
|
474
|
+
document = JSON.parse(body, symbolize_names: true)
|
|
475
|
+
AutoSource::Scraper::JsonState::CandidateDetector.candidate_array?(document)
|
|
476
|
+
rescue JSON::ParserError
|
|
477
|
+
false
|
|
478
|
+
end
|
|
479
|
+
module_function :xhr_candidate_articles?
|
|
480
|
+
|
|
481
|
+
##
|
|
482
|
+
# @param url [String]
|
|
483
|
+
# @param strategy [Symbol]
|
|
484
|
+
# @return [Html2rss::RequestService::Response]
|
|
485
|
+
def fetch_response(url, strategy) # rubocop:disable Metrics/MethodLength -- session construction
|
|
486
|
+
raw_config = Config.auto_source_config(
|
|
487
|
+
url:,
|
|
488
|
+
request_controls: Config::RequestControls.from_shortcut(strategy:)
|
|
489
|
+
)
|
|
490
|
+
raw_config[:strategy] = strategy
|
|
491
|
+
config = Config.from_hash(raw_config)
|
|
492
|
+
resources = FeedPipeline::RuntimePolicy.resources_for(config)
|
|
493
|
+
session = RequestSession.build(
|
|
494
|
+
config:,
|
|
495
|
+
strategy: config.strategy,
|
|
496
|
+
budget: resources.budget,
|
|
497
|
+
policy: resources.policy
|
|
498
|
+
)
|
|
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
|
+
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
|
+
|
|
526
|
+
{ node_count: doc.node_count, degraded: doc.degraded }
|
|
527
|
+
rescue StandardError
|
|
528
|
+
nil
|
|
529
|
+
end
|
|
530
|
+
module_function :sst_stats_from
|
|
531
|
+
|
|
532
|
+
##
|
|
533
|
+
# @param response [Html2rss::RequestService::Response]
|
|
534
|
+
# @return [Html2rss::SST::Document, nil]
|
|
535
|
+
def sst_document(response)
|
|
536
|
+
Html2rss::SST::Normalizer.call(response.body)
|
|
537
|
+
rescue ArgumentError
|
|
538
|
+
nil
|
|
539
|
+
end
|
|
540
|
+
module_function :sst_document
|
|
541
|
+
|
|
542
|
+
##
|
|
543
|
+
# @param sst [Html2rss::SST::Document]
|
|
544
|
+
# @param url [String]
|
|
545
|
+
# @return [Hash]
|
|
546
|
+
def segment_stats(sst, url)
|
|
547
|
+
segments = discover_segments(sst, url)
|
|
548
|
+
return { found: 0 } if segments.empty?
|
|
549
|
+
|
|
550
|
+
{
|
|
551
|
+
found: segments.size,
|
|
552
|
+
strategies: segments.map(&:strategy).uniq,
|
|
553
|
+
sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
|
|
554
|
+
}
|
|
555
|
+
end
|
|
556
|
+
module_function :segment_stats
|
|
557
|
+
|
|
558
|
+
##
|
|
559
|
+
# @param sst [Html2rss::SST::Document]
|
|
560
|
+
# @param url [String]
|
|
561
|
+
# @return [Array]
|
|
562
|
+
def discover_segments(sst, url)
|
|
563
|
+
link_resolver = Scoring::LinkResolver.new(url)
|
|
564
|
+
AutoSource::Segmenter.call(
|
|
565
|
+
sst,
|
|
566
|
+
base_url: url,
|
|
567
|
+
strategy: :list,
|
|
568
|
+
link_resolver:
|
|
569
|
+
)
|
|
570
|
+
rescue StandardError
|
|
571
|
+
[]
|
|
572
|
+
end
|
|
573
|
+
module_function :discover_segments
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
end
|
data/lib/html2rss/mcp.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# MCP server for AI client consumption.
|
|
6
|
+
# Lazy-loads the mcp gem; no cost when the server is not started.
|
|
7
|
+
module MCP
|
|
8
|
+
class << self
|
|
9
|
+
##
|
|
10
|
+
# Starts the MCP server using the given transport.
|
|
11
|
+
#
|
|
12
|
+
# @param transport [Symbol] +:stdio+ or +:http+
|
|
13
|
+
# @param port [Integer] port for HTTP transport (bound to 127.0.0.1)
|
|
14
|
+
def start(transport: :stdio, port: 8080)
|
|
15
|
+
require 'mcp'
|
|
16
|
+
require_relative 'mcp/server'
|
|
17
|
+
Server.start(transport:, port:)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -21,8 +21,31 @@ module Html2rss
|
|
|
21
21
|
/cloudflare ray id/i
|
|
22
22
|
],
|
|
23
23
|
message: 'Blocked surface detected: Cloudflare anti-bot interstitial page. ' \
|
|
24
|
-
'
|
|
24
|
+
'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
|
|
25
25
|
'or run from an environment that can complete anti-bot checks.'
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: :datadome_interstitial,
|
|
29
|
+
min_matches: 2,
|
|
30
|
+
patterns: [
|
|
31
|
+
/captcha-delivery\.com/,
|
|
32
|
+
/DataDome/i,
|
|
33
|
+
/interstitial/i
|
|
34
|
+
],
|
|
35
|
+
message: 'Blocked surface detected: DataDome anti-bot challenge page. ' \
|
|
36
|
+
'Target a direct listing URL, or scrape via a session with resolved DataDome cookies.'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: :vercel_security_checkpoint,
|
|
40
|
+
min_matches: 1,
|
|
41
|
+
patterns: [
|
|
42
|
+
/Vercel Security Checkpoint/i,
|
|
43
|
+
%r{vercel\.com/security}i,
|
|
44
|
+
/checking the security/i
|
|
45
|
+
],
|
|
46
|
+
message: 'Blocked surface detected: Vercel Security Checkpoint. ' \
|
|
47
|
+
'This site is a JS-rendered SPA behind Vercel edge protection. ' \
|
|
48
|
+
'Configure BOTASAURUS_SCRAPER_URL or target a direct listing URL.'
|
|
26
49
|
}
|
|
27
50
|
].freeze
|
|
28
51
|
|