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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  6. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  7. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  8. data/lib/html2rss/auto_source/scraper.rb +34 -15
  9. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  10. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  11. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  12. data/lib/html2rss/auto_source.rb +7 -2
  13. data/lib/html2rss/capture.rb +315 -0
  14. data/lib/html2rss/cli.rb +64 -5
  15. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  16. data/lib/html2rss/config/schema.rb +67 -26
  17. data/lib/html2rss/config/selectors_validator.rb +60 -21
  18. data/lib/html2rss/config/validator.rb +18 -27
  19. data/lib/html2rss/error.rb +15 -3
  20. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  21. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  22. data/lib/html2rss/feed_pipeline.rb +10 -3
  23. data/lib/html2rss/hash_util.rb +17 -0
  24. data/lib/html2rss/html/sst_article_extractor.rb +36 -7
  25. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  26. data/lib/html2rss/link_destination/path_classifier.rb +3 -0
  27. data/lib/html2rss/mcp/server.rb +577 -0
  28. data/lib/html2rss/mcp.rb +21 -0
  29. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  30. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  31. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  32. data/lib/html2rss/request_service/budget.rb +7 -35
  33. data/lib/html2rss/request_service/context.rb +0 -6
  34. data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
  35. data/lib/html2rss/request_service/policy.rb +1 -1
  36. data/lib/html2rss/request_service/response.rb +15 -1
  37. data/lib/html2rss/request_service/strategy.rb +1 -2
  38. data/lib/html2rss/request_service.rb +4 -9
  39. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  40. data/lib/html2rss/scoring/engine.rb +49 -5
  41. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  42. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  43. data/lib/html2rss/scoring/score.rb +1 -20
  44. data/lib/html2rss/scoring.rb +0 -25
  45. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  46. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  47. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  48. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  49. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  50. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  51. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  52. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  53. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  54. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  55. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  56. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  57. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  58. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  59. data/lib/html2rss/url.rb +11 -7
  60. data/lib/html2rss/version.rb +1 -1
  61. data/lib/html2rss.rb +32 -1
  62. data/schema/html2rss-config.schema.json +408 -85
  63. metadata +54 -12
  64. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  65. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  66. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  67. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  68. 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
@@ -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
- 'Retry with --strategy browserless, try a more specific public listing URL, ' \
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