html2rss 0.25.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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. data/lib/html2rss/scoring/anchor_score.rb +0 -34
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module MCP
5
+ Outcome = Data.define(:ok, :next_step, :guidance, :payload)
6
+
7
+ ##
8
+ # Typed MCP tool result. Owns next-step policy and guidance copy so the
9
+ # protocol adapter does not branch on quality heuristics.
10
+ class Outcome
11
+ # Matches {ConfigArgument} XOR {ArgumentError} messages.
12
+ XOR_ERROR = /exactly one of config or yaml/
13
+ NextStep = Data.define(:name, :guidance)
14
+
15
+ ##
16
+ # Closed set of agent next actions. Invalid names cannot be constructed.
17
+ class NextStep
18
+ # Wire names for +next_step+.
19
+ NAMES = %i[done inspect_url validate_config apply_config scrape_url capture_config read_runtime].freeze
20
+ # Default guidance copy keyed by {NAMES}.
21
+ GUIDANCE = {
22
+ done: 'Done. Read payload for the result.',
23
+ inspect_url: 'Call inspect_url next. Read payload for recon (final_url, status, ' \
24
+ 'scheme_downgrade, alternate_feeds).',
25
+ validate_config: 'Call validate_config with payload.yaml or a config hash (XOR, not both).',
26
+ apply_config: 'Call apply_config next. Confirm payload.item_count before shipping.',
27
+ scrape_url: 'Call scrape_url for articles now. strategy auto already runs Faraday then Botasaurus.',
28
+ capture_config: 'Call capture_config for a reusable YAML draft, then follow next_step.',
29
+ read_runtime: 'Read html2rss://runtime. Set BOTASAURUS_SCRAPER_URL on the MCP process ' \
30
+ 'if botasaurus_configured is false.'
31
+ }.freeze
32
+
33
+ ##
34
+ # @param name [Symbol, String]
35
+ # @param guidance [String, nil]
36
+ def initialize(name:, guidance: nil)
37
+ step = name.to_sym
38
+ raise ArgumentError, "unknown next_step: #{name.inspect}" unless NAMES.include?(step)
39
+
40
+ super(name: step, guidance: (guidance || GUIDANCE.fetch(step)).freeze)
41
+ end
42
+
43
+ class << self
44
+ NAMES.each { |step| define_method(step) { new(name: step) } }
45
+ end
46
+ end
47
+
48
+ ##
49
+ # @param ok [Boolean]
50
+ # @param next_step [NextStep]
51
+ # @param guidance [String]
52
+ # @param payload [Hash]
53
+ def initialize(ok:, next_step:, guidance:, payload:) # rubocop:disable Naming/MethodParameterName -- +ok+ is the envelope field
54
+ raise ArgumentError, 'next_step must be a NextStep' unless next_step.is_a?(NextStep)
55
+ raise ArgumentError, 'payload must be a Hash' unless payload.is_a?(Hash)
56
+
57
+ super(ok: !!ok, next_step:, guidance: guidance.to_s.freeze, payload: payload.dup.freeze)
58
+ end
59
+
60
+ ##
61
+ # @return [Hash{Symbol => Object}] envelope for {Contract.response}
62
+ def to_h
63
+ { ok:, next_step: next_step.name.to_s, guidance:, payload: }
64
+ end
65
+
66
+ class << self
67
+ ##
68
+ # @param items [Array]
69
+ # @param requested_strategy [String, Symbol]
70
+ # @param channel_title [String, nil]
71
+ # @param admission_drops [Hash]
72
+ # @param botasaurus_configured [Boolean]
73
+ # @return [Outcome]
74
+ def scrape(items:, requested_strategy:, channel_title:, botasaurus_configured:, admission_drops: {})
75
+ next_step = scrape_next_step(items.empty?, botasaurus_configured:)
76
+ new(ok: true, next_step:, guidance: next_step.guidance,
77
+ payload: scrape_payload(items:, requested_strategy:, channel_title:, admission_drops:))
78
+ end
79
+
80
+ ##
81
+ # @param payload [Hash] inspect recon Hash
82
+ # @return [Outcome]
83
+ def inspect(payload:)
84
+ next_step = inspect_next_step(payload)
85
+ new(ok: true, next_step:, guidance: next_step.guidance, payload:)
86
+ end
87
+
88
+ ##
89
+ # @param yaml [String]
90
+ # @param articles_count [Integer]
91
+ # @param has_selectors [Boolean]
92
+ # @param channel_title [String, nil]
93
+ # @param requested_strategy [String, Symbol]
94
+ # @param segment_strategy [Symbol, String, nil]
95
+ # @param selected_strategy [Symbol, String, nil]
96
+ # @param admission_drops [Hash]
97
+ # @return [Outcome]
98
+ def capture(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
99
+ segment_strategy: nil, selected_strategy: nil, admission_drops: {})
100
+ next_step = capture_next_step(articles_count:, has_selectors:)
101
+ new(ok: true, next_step:, guidance: next_step.guidance, payload: capture_payload(
102
+ yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:,
103
+ segment_strategy:, selected_strategy:, admission_drops:
104
+ ))
105
+ end
106
+
107
+ ##
108
+ # @param errors [Hash, nil] schema errors; +nil+ means success
109
+ # @return [Outcome]
110
+ def validate(errors:)
111
+ ok = errors.nil?
112
+ next_step = ok ? NextStep.apply_config : NextStep.validate_config
113
+ new(ok:, next_step:, guidance: next_step.guidance, payload: ok ? {} : { errors: })
114
+ end
115
+
116
+ ##
117
+ # @param rss [String]
118
+ # @param item_count [Integer]
119
+ # @param empty [Boolean] {FeedResult#empty?} (ship gate); defaults to zero items
120
+ # @return [Outcome]
121
+ def apply(rss:, item_count:, empty: item_count.zero?)
122
+ ok = !empty
123
+ next_step = ok ? NextStep.done : NextStep.inspect_url
124
+ new(ok:, next_step:, guidance: next_step.guidance, payload: { rss:, item_count: })
125
+ end
126
+
127
+ ##
128
+ # @param error [Exception]
129
+ # @return [Outcome]
130
+ def from_error(error)
131
+ next_step = next_step_for_error(error)
132
+ new(ok: false, next_step:, guidance: next_step.guidance,
133
+ payload: { class: error.class.name, message: error.message })
134
+ end
135
+
136
+ private
137
+
138
+ def scrape_next_step(empty, botasaurus_configured:)
139
+ return NextStep.done unless empty
140
+ return NextStep.read_runtime unless botasaurus_configured
141
+
142
+ NextStep.inspect_url
143
+ end
144
+
145
+ def scrape_payload(items:, requested_strategy:, channel_title:, admission_drops:)
146
+ {
147
+ items:, total: items.size, requested_strategy: requested_strategy.to_s, channel_title:,
148
+ **(admission_drops.any? ? { admission_drops: } : {})
149
+ }
150
+ end
151
+
152
+ def inspect_next_step(payload)
153
+ return NextStep.done if Array(payload[:alternate_feeds]).any?
154
+ return NextStep.capture_config if payload[:articles_count].to_i.positive?
155
+
156
+ NextStep.scrape_url
157
+ end
158
+
159
+ def capture_next_step(articles_count:, has_selectors:)
160
+ articles_count.positive? && has_selectors ? NextStep.validate_config : NextStep.inspect_url
161
+ end
162
+
163
+ def capture_payload(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
164
+ segment_strategy:, selected_strategy:, admission_drops:)
165
+ {
166
+ yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy: requested_strategy.to_s,
167
+ **(segment_strategy ? { segment_strategy: segment_strategy.to_s } : {}),
168
+ **(selected_strategy ? { selected_strategy: selected_strategy.to_s } : {}),
169
+ **(admission_drops.any? ? { admission_drops: } : {})
170
+ }
171
+ end
172
+
173
+ def next_step_for_error(error)
174
+ case error
175
+ when RequestService::BotasaurusConfigurationError then NextStep.read_runtime
176
+ when Contract::UnpublishedRequestError then NextStep.validate_config
177
+ when ArgumentError then argument_error_next_step(error)
178
+ else NextStep.inspect_url
179
+ end
180
+ end
181
+
182
+ def argument_error_next_step(error)
183
+ XOR_ERROR.match?(error.message) ? NextStep.validate_config : NextStep.inspect_url
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,421 @@
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_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
+ #
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
+ # 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
+ #
31
+ # @param transport [Symbol] +:stdio+ or +:http+
32
+ # @param port [Integer] port for HTTP transport
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!
37
+ app = build
38
+ Log.info(start_banner(transport:, port:))
39
+ return start_http(app, port:) if transport == :http
40
+
41
+ ::MCP::Server::Transports::StdioTransport.new(app).open
42
+ end
43
+
44
+ ##
45
+ # Builds the configured MCP protocol server (tools/resources/prompts).
46
+ #
47
+ # @return [::MCP::Server]
48
+ def build # rubocop:disable Metrics/MethodLength -- protocol server construction
49
+ ::MCP::Server.new(
50
+ name: SERVER_NAME,
51
+ title: SERVER_NAME,
52
+ version: SERVER_VERSION,
53
+ instructions: instructions_text,
54
+ configuration: protocol_configuration
55
+ ).tap do |server|
56
+ register_tools(server)
57
+ register_resources(server)
58
+ register_prompts(server)
59
+ end
60
+ end
61
+
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
69
+ end
70
+
71
+ def start_banner(transport:, port:)
72
+ bind = transport == :http ? " bind=#{HTTP_BIND_HOST}:#{port}" : ''
73
+ "html2rss MCP #{SERVER_VERSION} starting transport=#{transport}#{bind}"
74
+ end
75
+
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
82
+ end
83
+
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
124
+
125
+ def start_http(app, port:) # rubocop:disable Metrics/MethodLength -- require + bind + LoadError message
126
+ require 'rackup'
127
+ require 'rackup/handler/webrick'
128
+ require 'webrick'
129
+
130
+ handler = ::MCP::Server::Transports::StreamableHTTPTransport.new(app, stateless: true)
131
+ Rackup::Handler::WEBrick.run(
132
+ handler,
133
+ Host: HTTP_BIND_HOST,
134
+ Port: port,
135
+ Logger: Html2rss.logger
136
+ )
137
+ rescue LoadError => error
138
+ raise LoadError,
139
+ 'HTTP transport requires the rackup and webrick gems ' \
140
+ "(#{error.message}). Install them or use --transport stdio."
141
+ end
142
+
143
+ def instructions_text # rubocop:disable Metrics/MethodLength -- agent decision tree is the published contract
144
+ <<~TEXT.strip
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?
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
+
195
+ def register_tools(server)
196
+ register_scrape_url(server)
197
+ register_inspect_url(server)
198
+ register_capture_config(server)
199
+ register_validate_config(server)
200
+ register_apply_config(server)
201
+ end
202
+
203
+ def register_scrape_url(server)
204
+ define_envelope_tool(
205
+ server,
206
+ name: 'scrape_url',
207
+ description: 'One-shot article extraction as JSON Feed items. ' \
208
+ 'Use when you need articles now without a saved config. ' \
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:)
301
+ end
302
+ end
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
+
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
364
+ to_result = method(:prompt_result)
365
+ text_for = method(:scrape_webpage_text)
366
+ server.define_prompt(
367
+ name: 'scrape-webpage',
368
+ description: 'Guided one-shot scrape: one scrape_url call (auto already falls back)',
369
+ arguments: [
370
+ ::MCP::Prompt::Argument.new(name: 'url', description: 'URL to scrape', required: true)
371
+ ]
372
+ ) do |args, server_context:| # rubocop:disable Lint/UnusedBlockArgument
373
+ to_result.call(text_for.call(args.fetch(:url)))
374
+ end
375
+ end
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)))
388
+ end
389
+ end
390
+
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
+ ]
396
+ )
397
+ 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
+ end
419
+ end
420
+ end
421
+ 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