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/server.rb
CHANGED
|
@@ -20,6 +20,68 @@ module Html2rss
|
|
|
20
20
|
# Loopback bind for HTTP transport (local use only).
|
|
21
21
|
HTTP_BIND_HOST = '127.0.0.1'
|
|
22
22
|
|
|
23
|
+
# Declarative MCP resource registrations consumed by {register_resources}.
|
|
24
|
+
RESOURCES = [
|
|
25
|
+
{
|
|
26
|
+
uri: 'html2rss://schema',
|
|
27
|
+
name: 'Configuration JSON Schema',
|
|
28
|
+
description: 'Full JSON Schema for html2rss feed configurations',
|
|
29
|
+
mime_type: 'application/json',
|
|
30
|
+
body: lambda {
|
|
31
|
+
[{ uri: 'html2rss://schema', mimeType: 'application/json',
|
|
32
|
+
text: Html2rss::Config.json_schema_json(pretty: true) }]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
uri: 'html2rss://extractors',
|
|
37
|
+
name: 'Available Extractors',
|
|
38
|
+
description: 'Registered extractor names for selector configs ' \
|
|
39
|
+
'(full option docs live in html2rss://schema $defs)',
|
|
40
|
+
mime_type: 'application/json',
|
|
41
|
+
body: lambda {
|
|
42
|
+
extractors = Html2rss::Selectors::Extractors::NAME_TO_CLASS.keys.map(&:to_s).sort
|
|
43
|
+
[{ uri: 'html2rss://extractors', mimeType: 'application/json',
|
|
44
|
+
text: JSON.pretty_generate(extractors) }]
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
uri: 'html2rss://strategies',
|
|
49
|
+
name: 'Available Strategies',
|
|
50
|
+
description: 'Published MCP request strategy names',
|
|
51
|
+
mime_type: 'application/json',
|
|
52
|
+
body: lambda {
|
|
53
|
+
[{ uri: 'html2rss://strategies', mimeType: 'application/json',
|
|
54
|
+
text: JSON.generate(Contract::STRATEGIES) }]
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
uri: 'html2rss://runtime',
|
|
59
|
+
name: 'Runtime capabilities',
|
|
60
|
+
description: 'Gem version, MCP contract version, catalog fingerprint, tool names, and Botasaurus config',
|
|
61
|
+
mime_type: 'application/json',
|
|
62
|
+
body: lambda {
|
|
63
|
+
[{ uri: 'html2rss://runtime', mimeType: 'application/json',
|
|
64
|
+
text: JSON.pretty_generate(Runtime.snapshot.to_h) }]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
].freeze
|
|
68
|
+
|
|
69
|
+
# Declarative MCP prompt registrations; SDK argument objects built at {register_prompts} time.
|
|
70
|
+
PROMPTS = [
|
|
71
|
+
{
|
|
72
|
+
name: 'scrape-webpage',
|
|
73
|
+
description: 'Guided one-shot scrape: one scrape call (auto already falls back)',
|
|
74
|
+
arguments: [{ name: 'url', description: 'URL to scrape', required: true }],
|
|
75
|
+
body: ->(args) { Outcome::Playbook.scrape_webpage_prompt(args.fetch(:url)) }
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'capture-feed-config',
|
|
79
|
+
description: 'Guided capture → test → apply; YAML draft plus catalog rewrite',
|
|
80
|
+
arguments: [{ name: 'url', description: 'URL to analyze', required: true }],
|
|
81
|
+
body: ->(args) { Outcome::Playbook.capture_feed_config_prompt(args.fetch(:url)) }
|
|
82
|
+
}
|
|
83
|
+
].freeze
|
|
84
|
+
|
|
23
85
|
class << self # rubocop:disable Metrics/ClassLength
|
|
24
86
|
##
|
|
25
87
|
# Starts the MCP server with the given transport.
|
|
@@ -140,28 +202,8 @@ module Html2rss
|
|
|
140
202
|
"(#{error.message}). Install them or use --transport stdio."
|
|
141
203
|
end
|
|
142
204
|
|
|
143
|
-
def instructions_text
|
|
144
|
-
|
|
145
|
-
html2rss MCP — decide which tool to call:
|
|
146
|
-
|
|
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).
|
|
160
|
-
TEXT
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def botasaurus_configured?
|
|
164
|
-
!ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
|
|
205
|
+
def instructions_text
|
|
206
|
+
Outcome::Playbook.instructions
|
|
165
207
|
end
|
|
166
208
|
|
|
167
209
|
def tool_error_response(error)
|
|
@@ -175,7 +217,7 @@ module Html2rss
|
|
|
175
217
|
tool_error_response(error)
|
|
176
218
|
end
|
|
177
219
|
|
|
178
|
-
# rubocop:disable Metrics/MethodLength -- listing fields stay together
|
|
220
|
+
# rubocop:disable-next Metrics/MethodLength -- listing fields stay together
|
|
179
221
|
def define_envelope_tool(server, name:, description:, input_schema:,
|
|
180
222
|
annotations: Contract::ANNOTATIONS_OPEN_WORLD)
|
|
181
223
|
run = method(:handle_tool_call)
|
|
@@ -190,201 +232,38 @@ module Html2rss
|
|
|
190
232
|
run.call { yield(**kwargs) }
|
|
191
233
|
end
|
|
192
234
|
end
|
|
193
|
-
# rubocop:enable Metrics/MethodLength
|
|
194
235
|
|
|
195
236
|
def register_tools(server)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
'strategy "auto" triggers fallback chain (faraday → botasaurus) for JS-rendered sites.',
|
|
210
|
-
input_schema: Contract::SCRAPE_INPUT_SCHEMA
|
|
211
|
-
) do |server_context:, url:, strategy: 'auto', limit: 25, items_selector: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
212
|
-
scrape_outcome(url:, strategy:, limit:, items_selector:)
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
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,
|
|
232
|
-
name: 'inspect_url',
|
|
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
|
|
237
|
-
) do |server_context:, url:, strategy: 'auto'| # rubocop:disable Lint/UnusedBlockArgument
|
|
238
|
-
Outcome.inspect(payload: Inspect.call(url:, strategy:))
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def register_capture_config(server) # rubocop:disable Metrics/MethodLength
|
|
243
|
-
define_envelope_tool(
|
|
244
|
-
server,
|
|
245
|
-
name: 'capture_config',
|
|
246
|
-
description: 'Derive a reusable html2rss feed config from a URL. ' \
|
|
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
|
|
252
|
-
) do |server_context:, url:, strategy: 'auto', items_selector: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
253
|
-
capture_outcome(url:, strategy:, items_selector:)
|
|
254
|
-
end
|
|
255
|
-
end
|
|
256
|
-
|
|
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,
|
|
275
|
-
name: 'validate_config',
|
|
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. ' \
|
|
278
|
-
'Full schema lives in resource html2rss://schema.',
|
|
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:)
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
|
|
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,
|
|
294
|
-
name: 'apply_config',
|
|
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. ' \
|
|
297
|
-
'Use after validate_config succeeds.',
|
|
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:)
|
|
237
|
+
Tools.register_all(server, registrar: method(:define_envelope_tool))
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def register_resources(server)
|
|
241
|
+
RESOURCES.each do |entry|
|
|
242
|
+
server.define_resource(
|
|
243
|
+
uri: entry[:uri],
|
|
244
|
+
name: entry[:name],
|
|
245
|
+
description: entry[:description],
|
|
246
|
+
mime_type: entry[:mime_type]
|
|
247
|
+
) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
248
|
+
entry[:body].call
|
|
249
|
+
end
|
|
301
250
|
end
|
|
302
251
|
end
|
|
303
252
|
|
|
304
|
-
def
|
|
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
|
-
|
|
313
|
-
def register_resources(server) # rubocop:disable Metrics/MethodLength
|
|
314
|
-
configured = method(:botasaurus_configured?)
|
|
315
|
-
server.define_resource(
|
|
316
|
-
uri: 'html2rss://schema',
|
|
317
|
-
name: 'Configuration JSON Schema',
|
|
318
|
-
description: 'Full JSON Schema for html2rss feed configurations',
|
|
319
|
-
mime_type: 'application/json'
|
|
320
|
-
) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
321
|
-
schema = Html2rss::Config.json_schema_json(pretty: true)
|
|
322
|
-
[{ uri: 'html2rss://schema', mimeType: 'application/json', text: schema }]
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
server.define_resource(
|
|
326
|
-
uri: 'html2rss://extractors',
|
|
327
|
-
name: 'Available Extractors',
|
|
328
|
-
description: 'Registered extractor names for selector configs ' \
|
|
329
|
-
'(full option docs live in html2rss://schema $defs)',
|
|
330
|
-
mime_type: 'application/json'
|
|
331
|
-
) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
332
|
-
extractors = Html2rss::Selectors::Extractors::NAME_TO_CLASS.keys.map(&:to_s).sort
|
|
333
|
-
[{ uri: 'html2rss://extractors', mimeType: 'application/json',
|
|
334
|
-
text: JSON.pretty_generate(extractors) }]
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
server.define_resource(
|
|
338
|
-
uri: 'html2rss://strategies',
|
|
339
|
-
name: 'Available Strategies',
|
|
340
|
-
description: 'Published MCP request strategy names',
|
|
341
|
-
mime_type: 'application/json'
|
|
342
|
-
) do |server_context: nil| # rubocop:disable Lint/UnusedBlockArgument
|
|
343
|
-
[{ uri: 'html2rss://strategies', mimeType: 'application/json',
|
|
344
|
-
text: JSON.generate(Contract::STRATEGIES) }]
|
|
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)
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
def register_scrape_webpage_prompt(server) # rubocop:disable Metrics/MethodLength -- SDK prompt types stay together
|
|
253
|
+
def register_prompts(server) # rubocop:disable Metrics/MethodLength -- prompt argument mapping
|
|
364
254
|
to_result = method(:prompt_result)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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)
|
|
380
|
-
server.define_prompt(
|
|
381
|
-
name: 'capture-feed-config',
|
|
382
|
-
description: 'Guided capture → validate → apply; YAML draft plus catalog rewrite',
|
|
383
|
-
arguments: [
|
|
384
|
-
::MCP::Prompt::Argument.new(name: 'url', description: 'URL to analyze', required: true)
|
|
385
|
-
]
|
|
386
|
-
) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
|
|
387
|
-
to_result.call(text_for.call(args.fetch(:url)))
|
|
255
|
+
PROMPTS.each do |entry|
|
|
256
|
+
arguments = entry[:arguments].map do |spec|
|
|
257
|
+
::MCP::Prompt::Argument.new(name: spec[:name], description: spec[:description],
|
|
258
|
+
required: spec.fetch(:required, false))
|
|
259
|
+
end
|
|
260
|
+
server.define_prompt(
|
|
261
|
+
name: entry[:name],
|
|
262
|
+
description: entry[:description],
|
|
263
|
+
arguments:
|
|
264
|
+
) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
|
|
265
|
+
to_result.call(entry[:body].call(args))
|
|
266
|
+
end
|
|
388
267
|
end
|
|
389
268
|
end
|
|
390
269
|
|
|
@@ -395,26 +274,6 @@ module Html2rss
|
|
|
395
274
|
]
|
|
396
275
|
)
|
|
397
276
|
end
|
|
398
|
-
|
|
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
|
|
406
|
-
end
|
|
407
|
-
|
|
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
|
|
417
|
-
end
|
|
418
277
|
end
|
|
419
278
|
end
|
|
420
279
|
end
|
data/lib/html2rss/mcp.rb
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# PageRecon — inspect diagnostics
|
|
2
|
+
|
|
3
|
+
`PageRecon::Diagnostics` powers the **inspect** verb (CLI, MCP, `Html2rss.inspect`). It fetches once via `PageRecon.probe`, classifies the surface, counts cheap AutoSource articles, and reports redirect facts. Full curation contract: `CONTEXT.md` § Frozen contract.
|
|
4
|
+
|
|
5
|
+
## What inspect reports
|
|
6
|
+
|
|
7
|
+
| Field | Meaning |
|
|
8
|
+
| ------------------ | --------------------------------------------------------------------------- |
|
|
9
|
+
| `requested_url` | URL you passed in |
|
|
10
|
+
| `final_url` | URL after redirects (may differ from requested) |
|
|
11
|
+
| `status` | HTTP status of the **final** response |
|
|
12
|
+
| `scheme_downgrade` | `true` when HTTPS entry landed on HTTP |
|
|
13
|
+
| `alternate_feeds` | `rel=alternate` RSS/Atom links found in HTML |
|
|
14
|
+
| `surface_category` | AutoSource surface class (`high_entropy_surface`, `unsupported_surface`, …) |
|
|
15
|
+
| `articles_count` | Cheap AutoSource extract (limit 10) — diagnostic only, not ship quality |
|
|
16
|
+
| `strategy` | Concrete transport used (`inspect` maps `auto` → Faraday) |
|
|
17
|
+
|
|
18
|
+
CLI text output omits `requested_url`; it prints the requested URL as the card title. The `Final:` line appears **only when** `final_url` differs from `requested_url`:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
https://apex.example/
|
|
22
|
+
Final: https://www.example/ (HTTP 200)
|
|
23
|
+
Surface: high_entropy_surface (10 articles)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That `Final:` line means the redirect **did** happen — not that inspect stopped at the apex host.
|
|
27
|
+
|
|
28
|
+
## Apex → www redirects
|
|
29
|
+
|
|
30
|
+
Some publishers redirect apex domains to `www` (301). html2rss follows redirects and records the landing URL in `final_url`.
|
|
31
|
+
|
|
32
|
+
Observed behavior:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bin/html2rss inspect https://apex.example
|
|
36
|
+
# Final: https://www.example/ (HTTP 200), high_entropy_surface, articles
|
|
37
|
+
|
|
38
|
+
bin/html2rss inspect https://www.example
|
|
39
|
+
# high_entropy_surface, articles (no Final: line — requested equals final)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Cross-host redirects (e.g. `apex.example` → `www.example`) no longer pin a stale `Host` header from the entry URL. `Config::RequestHeaders` omits `Host` by default; Faraday/Net::HTTP sets it per hop from the current request URL.
|
|
43
|
+
|
|
44
|
+
When the first streamed fetch loses the redirect payload (empty body at the final URL), `FaradayStrategy` retries once without streaming so inspect/recon see the same HTML as a direct canonical URL fetch.
|
|
45
|
+
|
|
46
|
+
### What to do
|
|
47
|
+
|
|
48
|
+
1. **Prefer the canonical URL** — if you know the site lives on `www`, pass that URL to inspect, recon, capture, and scrape.
|
|
49
|
+
2. **Read the `Final:` line** — when `final_url` differs from what you typed and status is 4xx, retry inspect on `final_url` before assuming the site is unreachable.
|
|
50
|
+
3. **Do not treat apex 403 as “redirect skipped”** — check JSON output (`--format json`) for `requested_url` vs `final_url` when text output is ambiguous.
|
|
51
|
+
4. **Blocked surfaces** — if the canonical URL still fails, try `strategy: botasaurus` on inspect (MCP) or escalate to recon/capture with browser strategy; Faraday-only inspect is intentionally cheap.
|
|
52
|
+
|
|
53
|
+
## inspect ≠ recon
|
|
54
|
+
|
|
55
|
+
| Verb | Adds beyond diagnostics |
|
|
56
|
+
| ------- | -------------------------------------------------------------------- |
|
|
57
|
+
| inspect | Scraper eligibility, XHR hints (Botasaurus), surface + article count |
|
|
58
|
+
| recon | Verdict (`:build` / `:defer` / `:drop`), native feed preference |
|
|
59
|
+
|
|
60
|
+
Follow golden-path `next_step` from MCP envelopes; do not call recon when inspect already answers the question.
|
|
61
|
+
|
|
62
|
+
## Ownership
|
|
63
|
+
|
|
64
|
+
| Concern | Owner |
|
|
65
|
+
| ----------------------------------- | -------------------------------------------------------------------- |
|
|
66
|
+
| Diagnostic fetch + assess | `PageRecon::Diagnostics` → `PageRecon.probe` |
|
|
67
|
+
| Surface class + cheap article count | `PageRecon::Assessment` |
|
|
68
|
+
| Redirect follow + terminal retry | `RequestService::FaradayStrategy` |
|
|
69
|
+
| Outbound header normalization | `Config::RequestHeaders` (no default `Host`; explicit override only) |
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class PageRecon
|
|
5
|
+
##
|
|
6
|
+
# Diagnostic inspect path (not Capture or Recon ownership). Fetches via {.probe},
|
|
7
|
+
# then adds scraper/XHR diagnostics for curation inspect surfaces.
|
|
8
|
+
module Diagnostics # rubocop:disable Metrics/ModuleLength -- diagnostic wire fields stay co-located
|
|
9
|
+
##
|
|
10
|
+
# Typed diagnostic report for inspect wire payloads and Outcome policy.
|
|
11
|
+
Report = Data.define(:data) do
|
|
12
|
+
##
|
|
13
|
+
# @return [Boolean]
|
|
14
|
+
def alternate_feeds?
|
|
15
|
+
Array(data[:alternate_feeds]).any?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# @return [Integer]
|
|
20
|
+
def articles_count
|
|
21
|
+
data[:articles_count].to_i
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# @return [Hash{Symbol => Object}]
|
|
26
|
+
def to_wire_h
|
|
27
|
+
data
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Minimum HTML body size to treat zero-article weak surfaces as likely JS shells.
|
|
32
|
+
JS_SHELL_MIN_BODY_BYTES = 8_192
|
|
33
|
+
private_constant :JS_SHELL_MIN_BODY_BYTES
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @param url [String]
|
|
39
|
+
# @param strategy [String, Symbol]
|
|
40
|
+
# @param deep [Boolean] when true and strategy is auto, one Botasaurus hop if configured
|
|
41
|
+
# @return [Report]
|
|
42
|
+
def call(url:, strategy: :auto, deep: false)
|
|
43
|
+
probe = PageRecon.probe(url, strategy: resolve_inspect_strategy(strategy, deep:))
|
|
44
|
+
recon = probe.result
|
|
45
|
+
response = probe.response
|
|
46
|
+
|
|
47
|
+
Report.new(data: build_data(probe, recon, response))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# Runs diagnostic inspect across URLs with per-URL error isolation.
|
|
52
|
+
#
|
|
53
|
+
# @param urls [Enumerable<String>]
|
|
54
|
+
# @param strategy [Symbol, String]
|
|
55
|
+
# @param concurrency [Integer]
|
|
56
|
+
# @return [Array<Report>]
|
|
57
|
+
def batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
|
|
58
|
+
Batch.map(Array(urls), concurrency:) do |url|
|
|
59
|
+
call(url:, strategy:)
|
|
60
|
+
rescue StandardError => error
|
|
61
|
+
error_report(url, error)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# @param response [Html2rss::RequestService::Response]
|
|
67
|
+
# @return [Hash] redacted XHR capture diagnostics (no query strings)
|
|
68
|
+
def xhr_capture_info(response)
|
|
69
|
+
captured = response.captured_responses
|
|
70
|
+
{
|
|
71
|
+
count: captured.size,
|
|
72
|
+
sample_endpoints: captured.first(5).filter_map { |entry| redacted_endpoint(entry) },
|
|
73
|
+
candidate_articles: captured.any? { |entry| xhr_candidate_articles?(entry) }
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# @param entry [Hash] captured response hash
|
|
79
|
+
# @return [String, nil] scheme+host+path only
|
|
80
|
+
def redacted_endpoint(entry)
|
|
81
|
+
raw = entry['url'] || entry[:url]
|
|
82
|
+
return unless raw
|
|
83
|
+
|
|
84
|
+
uri = URI.parse(raw.to_s)
|
|
85
|
+
return unless uri.scheme && uri.host
|
|
86
|
+
|
|
87
|
+
"#{uri.scheme}://#{uri.host}#{uri.path}"
|
|
88
|
+
rescue URI::InvalidURIError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
##
|
|
93
|
+
# @param entry [Hash] captured response hash
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def xhr_candidate_articles?(entry)
|
|
96
|
+
body = entry['body'] || entry[:body]
|
|
97
|
+
return false unless body.is_a?(String)
|
|
98
|
+
|
|
99
|
+
document = JSON.parse(body, symbolize_names: true)
|
|
100
|
+
AutoSource::Scraper::JsonState::CandidateDetector.candidate_array?(document)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
##
|
|
106
|
+
# @param parsed [Object] parsed response body
|
|
107
|
+
# @return [Array<String>, Hash]
|
|
108
|
+
def scraper_info(parsed)
|
|
109
|
+
return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
Html2rss::AutoSource::Scraper.from(parsed).map(&:name)
|
|
113
|
+
rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
|
|
114
|
+
{ none_found: error.category.to_s }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_data(probe, recon, response)
|
|
119
|
+
data = recon.to_h.merge(
|
|
120
|
+
strategy: probe.strategy,
|
|
121
|
+
scraper_eligibility: scraper_info(safe_parsed_body(response)),
|
|
122
|
+
html_present: html_present?(recon, response),
|
|
123
|
+
likely_js_shell: likely_js_shell?(recon, response),
|
|
124
|
+
redirect_summary: redirect_summary(recon)
|
|
125
|
+
)
|
|
126
|
+
data[:xhr_capture] = xhr_capture_info(response) if probe.strategy == :botasaurus
|
|
127
|
+
log_js_shell(data, response.body&.bytesize.to_i) if data[:likely_js_shell]
|
|
128
|
+
data
|
|
129
|
+
end
|
|
130
|
+
module_function :build_data
|
|
131
|
+
private_class_method :build_data
|
|
132
|
+
|
|
133
|
+
def resolve_inspect_strategy(strategy, deep:)
|
|
134
|
+
name = (strategy || :auto).to_sym
|
|
135
|
+
return :botasaurus if deep && name == :auto && MCP::Runtime.botasaurus_configured?
|
|
136
|
+
|
|
137
|
+
name
|
|
138
|
+
end
|
|
139
|
+
module_function :resolve_inspect_strategy
|
|
140
|
+
private_class_method :resolve_inspect_strategy
|
|
141
|
+
|
|
142
|
+
def html_present?(recon, response)
|
|
143
|
+
recon.html_response && !response.body.to_s.empty?
|
|
144
|
+
end
|
|
145
|
+
module_function :html_present?
|
|
146
|
+
private_class_method :html_present?
|
|
147
|
+
|
|
148
|
+
def likely_js_shell?(recon, response)
|
|
149
|
+
return false unless html_present?(recon, response)
|
|
150
|
+
return false if recon.articles_count.positive?
|
|
151
|
+
return false if recon.blocked_surface || recon.surface_category == :blocked_surface
|
|
152
|
+
|
|
153
|
+
return true if recon.surface_category == :app_shell
|
|
154
|
+
|
|
155
|
+
response.body.bytesize >= JS_SHELL_MIN_BODY_BYTES &&
|
|
156
|
+
SurfaceCategory.coerce(recon.surface_category).weak?
|
|
157
|
+
end
|
|
158
|
+
module_function :likely_js_shell?
|
|
159
|
+
private_class_method :likely_js_shell?
|
|
160
|
+
|
|
161
|
+
def redirect_summary(recon)
|
|
162
|
+
{
|
|
163
|
+
requested_url: recon.requested_url,
|
|
164
|
+
final_url: recon.final_url,
|
|
165
|
+
status: recon.status,
|
|
166
|
+
scheme_downgrade: recon.scheme_downgrade
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
module_function :redirect_summary
|
|
170
|
+
private_class_method :redirect_summary
|
|
171
|
+
|
|
172
|
+
def log_js_shell(data, body_bytesize)
|
|
173
|
+
Log.debug(
|
|
174
|
+
"Diagnostics js_shell: bytesize=#{body_bytesize} surface_category=#{data[:surface_category]}"
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
module_function :log_js_shell
|
|
178
|
+
private_class_method :log_js_shell
|
|
179
|
+
|
|
180
|
+
def safe_parsed_body(response)
|
|
181
|
+
return unless response.html_response?
|
|
182
|
+
|
|
183
|
+
response.parsed_body
|
|
184
|
+
rescue RequestService::UnsupportedResponseContentType
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
module_function :safe_parsed_body
|
|
188
|
+
private_class_method :safe_parsed_body
|
|
189
|
+
|
|
190
|
+
def error_report(url, error) # rubocop:disable Metrics/MethodLength -- error hash mirrors success report shape
|
|
191
|
+
Report.new(
|
|
192
|
+
data: {
|
|
193
|
+
requested_url: url.to_s,
|
|
194
|
+
final_url: url.to_s,
|
|
195
|
+
status: nil,
|
|
196
|
+
scheme_downgrade: false,
|
|
197
|
+
alternate_feeds: [],
|
|
198
|
+
surface_category: :unsupported_surface,
|
|
199
|
+
articles_count: 0,
|
|
200
|
+
html_response: false,
|
|
201
|
+
content_type: nil,
|
|
202
|
+
strategy: nil,
|
|
203
|
+
scraper_eligibility: { error: "#{error.class} - #{error.message}" }
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
module_function :error_report
|
|
208
|
+
private_class_method :error_report
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|