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.
- checksums.yaml +4 -4
- data/README.md +99 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +140 -49
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +47 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +41 -10
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +318 -0
- data/lib/html2rss/cli.rb +94 -17
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +79 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +46 -32
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +31 -1
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +40 -15
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
- data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
- data/lib/html2rss/html/article_extractor.rb +95 -17
- data/lib/html2rss/html/article_rules/category.rb +28 -11
- data/lib/html2rss/html/article_rules/date.rb +60 -6
- data/lib/html2rss/html/article_rules/description.rb +122 -0
- data/lib/html2rss/html/card_walk.rb +42 -0
- data/lib/html2rss/html/feed_link.rb +34 -0
- data/lib/html2rss/html/navigator.rb +18 -0
- data/lib/html2rss/html/sst_article_extractor.rb +147 -31
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +49 -32
- data/lib/html2rss/mcp/config_argument.rb +42 -0
- data/lib/html2rss/mcp/contract.rb +173 -0
- data/lib/html2rss/mcp/inspect.rb +241 -0
- data/lib/html2rss/mcp/outcome.rb +188 -0
- data/lib/html2rss/mcp/server.rb +421 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
- data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
- data/lib/html2rss/request_service/policy.rb +2 -2
- data/lib/html2rss/request_service/response.rb +72 -7
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +10 -10
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +31 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +62 -7
- data/schema/html2rss-config.schema.json +428 -94
- metadata +65 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- data/lib/html2rss/scoring/anchor_score.rb +0 -34
|
@@ -0,0 +1,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
|
data/lib/html2rss/mcp.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# MCP server for AI client consumption.
|
|
6
|
+
# Lazy-loads the mcp gem; no cost when the server is not started.
|
|
7
|
+
module MCP
|
|
8
|
+
class << self
|
|
9
|
+
##
|
|
10
|
+
# Starts the MCP server using the given transport.
|
|
11
|
+
#
|
|
12
|
+
# @param transport [Symbol] +:stdio+ or +:http+
|
|
13
|
+
# @param port [Integer] port for HTTP transport (bound to 127.0.0.1)
|
|
14
|
+
def start(transport: :stdio, port: 8080)
|
|
15
|
+
require 'mcp'
|
|
16
|
+
require_relative 'mcp/server'
|
|
17
|
+
Server.start(transport:, port:)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -21,8 +21,31 @@ module Html2rss
|
|
|
21
21
|
/cloudflare ray id/i
|
|
22
22
|
],
|
|
23
23
|
message: 'Blocked surface detected: Cloudflare anti-bot interstitial page. ' \
|
|
24
|
-
'
|
|
24
|
+
'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
|
|
25
25
|
'or run from an environment that can complete anti-bot checks.'
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: :datadome_interstitial,
|
|
29
|
+
min_matches: 2,
|
|
30
|
+
patterns: [
|
|
31
|
+
/captcha-delivery\.com/,
|
|
32
|
+
/DataDome/i,
|
|
33
|
+
/interstitial/i
|
|
34
|
+
],
|
|
35
|
+
message: 'Blocked surface detected: DataDome anti-bot challenge page. ' \
|
|
36
|
+
'Target a direct listing URL, or scrape via a session with resolved DataDome cookies.'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: :vercel_security_checkpoint,
|
|
40
|
+
min_matches: 1,
|
|
41
|
+
patterns: [
|
|
42
|
+
/Vercel Security Checkpoint/i,
|
|
43
|
+
%r{vercel\.com/security}i,
|
|
44
|
+
/checking the security/i
|
|
45
|
+
],
|
|
46
|
+
message: 'Blocked surface detected: Vercel Security Checkpoint. ' \
|
|
47
|
+
'This site is a JS-rendered SPA behind Vercel edge protection. ' \
|
|
48
|
+
'Configure BOTASAURUS_SCRAPER_URL or target a direct listing URL.'
|
|
26
49
|
}
|
|
27
50
|
].freeze
|
|
28
51
|
|