html2rss 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +8 -8
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +5 -2
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +25 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +59 -22
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +5 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
- data/lib/html2rss/feed_pipeline.rb +27 -16
- data/lib/html2rss/feed_resolution/README.md +11 -11
- data/lib/html2rss/feed_resolution.rb +6 -12
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -42
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +56 -2
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +3 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +2 -0
- data/lib/html2rss/request_session.rb +1 -2
- data/lib/html2rss/scrape_target.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +3 -6
- data/lib/html2rss/syndication/README.md +6 -6
- data/lib/html2rss/syndication/discovery.rb +2 -4
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +1 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +9 -29
- metadata +17 -18
- data/lib/html2rss/mcp/inspect.rb +0 -138
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
data/lib/html2rss/cli.rb
CHANGED
|
@@ -9,202 +9,345 @@ module Html2rss
|
|
|
9
9
|
# The Html2rss command line interface.
|
|
10
10
|
class CLI < Thor # rubocop:disable Metrics/ClassLength
|
|
11
11
|
check_unknown_options!
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
|
|
13
|
+
map 'feed' => 'apply'
|
|
14
|
+
map 'auto' => 'scrape'
|
|
15
|
+
|
|
16
|
+
# Supported CLI strategy names.
|
|
15
17
|
STRATEGY_OPTION_ENUM = Html2rss::FeedPipeline::StrategyPlan.accepted_names.map(&:to_s).freeze
|
|
18
|
+
# CLI strategy option description text.
|
|
19
|
+
STRATEGY_OPTION_DESC =
|
|
20
|
+
'Optional request strategy (defaults to auto; ' \
|
|
21
|
+
"auto tries #{Html2rss::FeedPipeline::AutoFallback::CHAIN.join(' -> ')})".freeze
|
|
16
22
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
# @return [String]
|
|
20
|
-
STRATEGY_OPTION_DESC = [
|
|
21
|
-
'Optional request strategy (defaults to auto; auto tries',
|
|
22
|
-
"#{Html2rss::FeedPipeline::AutoFallback::CHAIN.join(' -> ')})"
|
|
23
|
-
].join(' ').freeze
|
|
23
|
+
# CLI `--file` description for inspect/recon candidate lists.
|
|
24
|
+
RECON_FILE_DESC = 'Candidate list file (one URL or slug\turl per line)'
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
##
|
|
27
|
+
# @return [Boolean] whether Thor should exit on command failure
|
|
26
28
|
def self.exit_on_failure?
|
|
27
29
|
true
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
32
|
+
# Shared Thor options for inspect/recon URL targets.
|
|
33
|
+
def self.probe_target_options
|
|
34
|
+
method_option :strategy, type: :string, desc: STRATEGY_OPTION_DESC, enum: STRATEGY_OPTION_ENUM
|
|
35
|
+
method_option :file, type: :string, desc: RECON_FILE_DESC
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Shared Thor request/transport options for feed-producing commands.
|
|
39
|
+
def self.feed_transport_options
|
|
40
|
+
method_option :strategy, type: :string, desc: STRATEGY_OPTION_DESC, enum: STRATEGY_OPTION_ENUM
|
|
41
|
+
method_option :max_redirects, type: :numeric, desc: 'Max redirects to follow'
|
|
42
|
+
method_option :max_requests, type: :numeric, desc: 'Max request budget'
|
|
43
|
+
method_option :input, type: :string, desc: 'Local HTML file path'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Shared Thor output options for apply/scrape.
|
|
47
|
+
def self.feed_emit_options
|
|
48
|
+
method_option :format, type: :string, enum: %w[rss jsonfeed], default: 'rss'
|
|
49
|
+
method_option :explain, type: :boolean, desc: 'Print status JSON to stderr', default: false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
probe_target_options
|
|
53
|
+
desc 'inspect [TARGET]', 'Fetch diagnostics for a URL (final URL, status, alternates, surface)'
|
|
54
|
+
method_option :format, type: :string, enum: %w[text json], default: 'text'
|
|
55
|
+
method_option :deep, type: :boolean, default: false,
|
|
56
|
+
desc: 'Single Botasaurus diagnostic hop when BOTASAURUS_SCRAPER_URL is set'
|
|
57
|
+
# @param target [String, nil] URL, file, or '-' for stdin
|
|
51
58
|
# @return [void]
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
def inspect(target = nil)
|
|
60
|
+
with_recon_targets(target) do |urls, batch_mode|
|
|
61
|
+
results = if batch_mode
|
|
62
|
+
Html2rss.batch_inspect(urls, strategy: current_strategy).results
|
|
63
|
+
else
|
|
64
|
+
[Html2rss.inspect(urls.first, strategy: current_strategy, deep: options[:deep]).to_wire_h]
|
|
65
|
+
end
|
|
66
|
+
Render.inspect_output(results, format: options[:format], batch_mode:)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
method_option :
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
enum: STRATEGY_OPTION_ENUM
|
|
66
|
-
method_option :format,
|
|
67
|
-
type: :string,
|
|
68
|
-
desc: 'Output format for the auto-sourced feed',
|
|
69
|
-
enum: %w[rss jsonfeed],
|
|
70
|
-
default: 'rss'
|
|
71
|
-
method_option :items_selector, type: :string, desc: 'CSS selector for items (will be enhanced) (optional)'
|
|
72
|
-
method_option :max_redirects,
|
|
73
|
-
type: :numeric,
|
|
74
|
-
desc: 'Maximum redirects to follow per request'
|
|
75
|
-
method_option :max_requests,
|
|
76
|
-
type: :numeric,
|
|
77
|
-
desc: 'Maximum requests to allow for this feed build'
|
|
78
|
-
method_option :limit,
|
|
79
|
-
type: :numeric,
|
|
80
|
-
desc: 'Maximum number of articles to keep (default: 25)'
|
|
81
|
-
method_option :input,
|
|
82
|
-
type: :string,
|
|
83
|
-
desc: 'Local HTML file path to read input from'
|
|
84
|
-
method_option :explain,
|
|
85
|
-
type: :boolean,
|
|
86
|
-
desc: 'Print Status JSON to stderr (stdout stays the feed)',
|
|
87
|
-
default: false
|
|
88
|
-
# @param url [String, nil] source page URL for auto discovery
|
|
70
|
+
probe_target_options
|
|
71
|
+
desc 'recon [TARGET]', 'Probe a URL or candidate list for redirect chains, native feeds, and surface readiness'
|
|
72
|
+
method_option :cache_dir, type: :string, desc: 'Directory to cache raw HTML snapshots'
|
|
73
|
+
method_option :verdict, type: :string, desc: 'Filter batch output by verdict (BUILD, DEFER, DROP)'
|
|
74
|
+
method_option :url_only, type: :boolean, desc: 'Emit only URLs matching verdict (for pipe chaining)', default: false
|
|
75
|
+
method_option :format, type: :string, enum: %w[text json tsv], default: 'text'
|
|
76
|
+
# @param target [String, nil] URL, file, or '-' for stdin
|
|
89
77
|
# @return [void]
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
method_option :
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
78
|
+
def recon(target = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
79
|
+
with_recon_targets(target) do |urls, batch_mode|
|
|
80
|
+
results = Html2rss::Recon.batch(
|
|
81
|
+
urls,
|
|
82
|
+
strategy: current_strategy,
|
|
83
|
+
cache_dir: options[:cache_dir]
|
|
84
|
+
)
|
|
85
|
+
filtered = filter_recon_results(results, options[:verdict])
|
|
86
|
+
assert_recon_verdict_match!(filtered, results, batch_mode:)
|
|
87
|
+
|
|
88
|
+
Render.recon_output(
|
|
89
|
+
filtered,
|
|
90
|
+
format: options[:format],
|
|
91
|
+
batch_mode:,
|
|
92
|
+
url_only: options[:url_only]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return if batch_mode || filtered.empty?
|
|
96
|
+
|
|
97
|
+
exit(3) if filtered.first.defer?
|
|
98
|
+
exit(1) if filtered.first.drop?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
feed_transport_options
|
|
103
|
+
desc 'capture [TARGET]', 'Analyze a URL or HTML and output a curated YAML feed config'
|
|
104
|
+
method_option :items_selector, type: :string, desc: 'CSS selector hint for items'
|
|
105
|
+
method_option :output_dir, aliases: '-o', type: :string,
|
|
106
|
+
desc: 'Base directory to write <domain>/index.yml'
|
|
107
|
+
method_option :write, aliases: '-w', type: :string, desc: 'Specific file path to write YAML to'
|
|
108
|
+
method_option :topics, aliases: '-t', type: :string, desc: 'Comma-separated directory topics'
|
|
109
|
+
method_option :title, type: :string, desc: 'Directory and channel title override'
|
|
110
|
+
method_option :summary, type: :string, desc: 'Directory summary override'
|
|
111
|
+
method_option :enhance, type: :boolean, desc: 'Force enhance: true on items selector'
|
|
112
|
+
method_option :force, type: :boolean, desc: 'Bypass native feed check', default: false
|
|
113
|
+
method_option :limit, type: :numeric, desc: 'Article limit (default: 25)'
|
|
114
|
+
method_option :explain, type: :boolean, desc: 'Print capture diagnostics to stderr', default: false
|
|
115
|
+
# @param target [String, nil] URL or local HTML file
|
|
125
116
|
# @return [void]
|
|
126
|
-
def capture(
|
|
127
|
-
strategy, local_file_path, url = prepare_auto_inputs(
|
|
117
|
+
def capture(target = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
118
|
+
strategy, local_file_path, url = prepare_auto_inputs(target, options[:input])
|
|
119
|
+
topics = options[:topics]&.split(',')&.map(&:strip)
|
|
120
|
+
|
|
128
121
|
result = Html2rss::Capture.build(
|
|
129
122
|
url,
|
|
130
123
|
strategy:,
|
|
131
124
|
items_selector: options[:items_selector],
|
|
125
|
+
topics:,
|
|
126
|
+
title: options[:title],
|
|
127
|
+
summary: options[:summary],
|
|
128
|
+
force: options[:force],
|
|
129
|
+
enhance: options[:enhance],
|
|
132
130
|
limit: options[:limit]&.to_i,
|
|
133
131
|
max_redirects: options[:max_redirects],
|
|
134
132
|
max_requests: options[:max_requests],
|
|
135
133
|
local_file_path:
|
|
136
134
|
)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
135
|
+
|
|
136
|
+
if result.native_feed? && !options[:force]
|
|
137
|
+
$stderr.puts "First-party RSS/Atom feed detected at #{result.native_feed}. Use --force to capture anyway." # rubocop:disable Style/StderrPuts
|
|
138
|
+
exit(3)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
explain_json!(capture_explain_payload(result)) if options[:explain]
|
|
142
|
+
handle_capture_output(result, url)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
desc 'test [CONFIG_INPUT] [feed_name]', 'Validate schema AND execute live extraction (fails on 0 items)'
|
|
146
|
+
method_option :params, type: :hash, default: {}
|
|
147
|
+
method_option :min_items, type: :numeric, default: 1, desc: 'Minimum required articles to pass'
|
|
148
|
+
method_option :strict_quality, type: :boolean, default: false,
|
|
149
|
+
desc: 'Fail when ship-quality audit thresholds are exceeded'
|
|
150
|
+
method_option :compare_enhance, type: :boolean, default: false,
|
|
151
|
+
desc: 'Compare extraction with enhance on vs off (diagnostic only)'
|
|
152
|
+
method_option :strategy, type: :string, desc: STRATEGY_OPTION_DESC, enum: STRATEGY_OPTION_ENUM
|
|
153
|
+
method_option :json, type: :boolean, desc: 'Output test outcome as JSON', default: false
|
|
154
|
+
method_option :xml, type: :boolean, desc: 'Dump RSS XML alongside summary', default: false
|
|
155
|
+
method_option :quiet, aliases: '-q', type: :boolean, default: false
|
|
156
|
+
# @param config_input [String, nil] YAML file path, content, or '-' for stdin
|
|
157
|
+
# @param feed_name [String, nil] optional feed name
|
|
152
158
|
# @return [void]
|
|
153
|
-
def
|
|
154
|
-
|
|
159
|
+
def test(config_input = nil, feed_name = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
160
|
+
raw_content, input_source = read_config_input(config_input)
|
|
161
|
+
result = Html2rss.test(
|
|
162
|
+
raw_content,
|
|
163
|
+
feed_name,
|
|
164
|
+
min_items: options.fetch(:min_items, 1).to_i,
|
|
165
|
+
params: options[:params] || {},
|
|
166
|
+
strategy: options[:strategy],
|
|
167
|
+
strict_quality: options.fetch(:strict_quality, false),
|
|
168
|
+
compare_enhance: options.fetch(:compare_enhance, false)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if options[:json]
|
|
172
|
+
puts JSON.pretty_generate(result.to_h)
|
|
173
|
+
elsif options[:quiet]
|
|
174
|
+
$stderr.puts(result.error_message) unless result.success # rubocop:disable Style/StderrPuts
|
|
175
|
+
elsif input_source == 'stdin' && !$stdout.tty? && !options[:xml]
|
|
176
|
+
# Piped filter: echo config to stdout on success, write failure to stderr
|
|
177
|
+
if result.success
|
|
178
|
+
puts raw_content
|
|
179
|
+
else
|
|
180
|
+
$stderr.puts "Test failed: #{result.error_message}" # rubocop:disable Style/StderrPuts
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
Render.test_card(result, input_source)
|
|
184
|
+
puts result.rss if options[:xml] && result.success
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
raise Thor::Error, (result.error_message || 'Test failed') unless result.success
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
feed_transport_options
|
|
191
|
+
feed_emit_options
|
|
192
|
+
desc 'apply [CONFIG_INPUT] [feed_name]', 'Print RSS built from YAML config to stdout'
|
|
193
|
+
method_option :params, type: :hash, default: {}
|
|
194
|
+
# @param source [String, nil] YAML file path or '-'
|
|
195
|
+
# @param feed_name [String, nil] optional named feed
|
|
196
|
+
# @return [void]
|
|
197
|
+
def apply(source = nil, feed_name = nil) # rubocop:disable Metrics/AbcSize
|
|
198
|
+
config = if File.file?(source.to_s)
|
|
199
|
+
Html2rss.config_from_yaml_file(source.to_s, feed_name)
|
|
200
|
+
else
|
|
201
|
+
raw_content, = read_config_input(source)
|
|
202
|
+
Config.from_yaml(raw_content)
|
|
203
|
+
end
|
|
204
|
+
config[:params] = options[:params] || {}
|
|
205
|
+
apply_runtime_request_overrides!(config)
|
|
206
|
+
apply_local_file_input!(config, options[:input]) if options[:input]
|
|
207
|
+
|
|
208
|
+
run_feed_command { Html2rss.apply(config) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
feed_transport_options
|
|
212
|
+
feed_emit_options
|
|
213
|
+
desc 'scrape [URL]', 'Automatically source an RSS feed from a URL (one-shot auto-source)'
|
|
214
|
+
method_option :items_selector, type: :string, desc: 'CSS selector hint for items'
|
|
215
|
+
method_option :limit, type: :numeric, desc: 'Max articles (auto-source)'
|
|
216
|
+
# @param url [String, nil]
|
|
217
|
+
# @return [void]
|
|
218
|
+
def scrape(url = nil)
|
|
219
|
+
strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
|
|
220
|
+
run_feed_command { scrape_feed_result_for(url, strategy, local_file_path) }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
desc 'validate [CONFIG_FILES...]', 'Validate one or more YAML configs against the JSON Schema'
|
|
224
|
+
method_option :params, type: :hash, default: {}
|
|
225
|
+
method_option :quiet, aliases: '-q', type: :boolean, default: false
|
|
226
|
+
# @param files [Array<String>] file paths or globs
|
|
227
|
+
# @return [void]
|
|
228
|
+
def validate(*files)
|
|
229
|
+
Validate.run(files, params: options[:params] || {}, quiet: options[:quiet])
|
|
230
|
+
end
|
|
155
231
|
|
|
232
|
+
desc 'schema', 'Print or export the configuration JSON Schema'
|
|
233
|
+
method_option :pretty, type: :boolean, default: true, desc: 'Pretty-print JSON'
|
|
234
|
+
method_option :write, aliases: '-w', type: :string, desc: 'Write schema to file path'
|
|
235
|
+
# @return [void]
|
|
236
|
+
def schema
|
|
237
|
+
schema_json = Html2rss.schema_json(pretty: options.fetch(:pretty, true))
|
|
156
238
|
if options[:write]
|
|
157
239
|
FileUtils.mkdir_p(File.dirname(options[:write]))
|
|
158
240
|
File.write(options[:write], "#{schema_json}\n")
|
|
159
241
|
puts options[:write]
|
|
160
|
-
|
|
242
|
+
else
|
|
243
|
+
puts schema_json
|
|
161
244
|
end
|
|
162
|
-
|
|
163
|
-
puts schema_json
|
|
164
245
|
end
|
|
165
246
|
|
|
166
|
-
desc 'mcp', 'Start the MCP server for AI
|
|
167
|
-
method_option :transport,
|
|
168
|
-
|
|
169
|
-
desc: 'MCP transport protocol',
|
|
170
|
-
enum: %w[stdio http],
|
|
171
|
-
default: 'stdio'
|
|
172
|
-
method_option :port,
|
|
173
|
-
type: :numeric,
|
|
174
|
-
desc: 'Port for HTTP transport (binds 127.0.0.1)',
|
|
175
|
-
default: 8080
|
|
176
|
-
##
|
|
177
|
-
# Starts the MCP server for AI client consumption.
|
|
178
|
-
#
|
|
247
|
+
desc 'mcp', 'Start the MCP server for AI agent and IDE consumption'
|
|
248
|
+
method_option :transport, type: :string, enum: %w[stdio http], default: 'stdio'
|
|
249
|
+
method_option :port, type: :numeric, default: 8080
|
|
179
250
|
# @return [void]
|
|
180
251
|
def mcp
|
|
181
|
-
Html2rss::MCP.start(
|
|
182
|
-
transport: options[:transport].to_sym,
|
|
183
|
-
port: options[:port]
|
|
184
|
-
)
|
|
252
|
+
Html2rss::MCP.start(transport: options[:transport].to_sym, port: options[:port])
|
|
185
253
|
end
|
|
186
254
|
|
|
187
|
-
desc '
|
|
188
|
-
method_option :
|
|
189
|
-
|
|
190
|
-
optional: true,
|
|
191
|
-
required: false,
|
|
192
|
-
default: {}
|
|
193
|
-
##
|
|
194
|
-
# Validates a YAML config and prints the result.
|
|
195
|
-
#
|
|
196
|
-
# @param yaml_file [String] the YAML file to validate
|
|
197
|
-
# @param feed_name [String, nil] optional feed name for multi-feed files
|
|
255
|
+
desc 'doctor [TYPE]', 'Runtime preflight checks (botasaurus)'
|
|
256
|
+
method_option :sample, type: :string, desc: 'Optional URL for a Botasaurus sample scrape'
|
|
257
|
+
# @param type [String, nil] check type (default: botasaurus)
|
|
198
258
|
# @return [void]
|
|
199
|
-
def
|
|
200
|
-
|
|
259
|
+
def doctor(type = 'botasaurus')
|
|
260
|
+
case type.to_s
|
|
261
|
+
when 'botasaurus'
|
|
262
|
+
result = Html2rss::Doctor::Botasaurus.call(sample_url: options[:sample])
|
|
263
|
+
explain_json!(result.to_h)
|
|
264
|
+
raise Thor::Error, result.message unless result.ok
|
|
265
|
+
else
|
|
266
|
+
raise Thor::Error, "Unknown doctor type: #{type}. Supported: botasaurus"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
201
271
|
|
|
202
|
-
|
|
272
|
+
def with_recon_targets(target)
|
|
273
|
+
urls, batch_mode = resolve_recon_targets(target, options[:file])
|
|
274
|
+
raise Thor::Error, 'A target URL, --file, or stdin (-) is required' if urls.empty?
|
|
203
275
|
|
|
204
|
-
|
|
276
|
+
yield urls, batch_mode
|
|
205
277
|
end
|
|
206
278
|
|
|
207
|
-
|
|
279
|
+
def resolve_recon_targets(target, file_opt)
|
|
280
|
+
if file_opt
|
|
281
|
+
[parse_recon_lines(File.readlines(file_opt, chomp: true)), true]
|
|
282
|
+
elsif target == '-'
|
|
283
|
+
[parse_recon_lines($stdin.readlines.map(&:chomp)), true]
|
|
284
|
+
elsif target
|
|
285
|
+
[Array(target), false]
|
|
286
|
+
else
|
|
287
|
+
[[], false]
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def parse_recon_lines(lines)
|
|
292
|
+
lines.filter_map do |line|
|
|
293
|
+
stripped = line.strip
|
|
294
|
+
next if stripped.empty? || stripped.start_with?('#')
|
|
295
|
+
|
|
296
|
+
stripped.include?("\t") ? stripped.split("\t", 2).last.strip : stripped
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def filter_recon_results(results, verdict_filter)
|
|
301
|
+
return results unless verdict_filter
|
|
302
|
+
|
|
303
|
+
expected = Recon::Verdict.coerce(verdict_filter.downcase)
|
|
304
|
+
results.select { |r| r.verdict == expected }
|
|
305
|
+
rescue ArgumentError => error
|
|
306
|
+
raise Thor::Error, error.message
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def assert_recon_verdict_match!(filtered, results, batch_mode:)
|
|
310
|
+
return if batch_mode || !options[:verdict] || !filtered.empty? || results.empty?
|
|
311
|
+
|
|
312
|
+
raise Thor::Error, "No results matched verdict #{options[:verdict].upcase}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def run_feed_command(&)
|
|
316
|
+
feed_res = execute_feed(&)
|
|
317
|
+
explain_json!(feed_res.status.to_h) if options[:explain]
|
|
318
|
+
emit_feed_result(feed_res, options.fetch(:format, 'rss'))
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def emit_feed_result(feed_res, format)
|
|
322
|
+
payload = format == 'jsonfeed' ? JSON.pretty_generate(feed_res.to_json_feed) : feed_res.to_rss
|
|
323
|
+
puts payload
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def handle_capture_output(result, url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
327
|
+
if options[:output_dir]
|
|
328
|
+
dir = File.join(options[:output_dir], URI.parse(url).host.to_s.delete_prefix('www.'))
|
|
329
|
+
FileUtils.mkdir_p(dir)
|
|
330
|
+
file_path = File.join(dir, 'index.yml')
|
|
331
|
+
File.write(file_path, result.yaml)
|
|
332
|
+
puts "Wrote captured config to #{file_path}"
|
|
333
|
+
elsif options[:write]
|
|
334
|
+
FileUtils.mkdir_p(File.dirname(options[:write]))
|
|
335
|
+
File.write(options[:write], result.yaml)
|
|
336
|
+
puts "Wrote captured config to #{options[:write]}"
|
|
337
|
+
else
|
|
338
|
+
puts result.yaml
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def read_config_input(input)
|
|
343
|
+
if input.nil? || input == '-'
|
|
344
|
+
[$stdin.read, 'stdin']
|
|
345
|
+
elsif File.file?(input)
|
|
346
|
+
[File.read(input), input]
|
|
347
|
+
else
|
|
348
|
+
[input, 'raw_string']
|
|
349
|
+
end
|
|
350
|
+
end
|
|
208
351
|
|
|
209
352
|
def apply_runtime_request_overrides!(config)
|
|
210
353
|
clear_blank_request_overrides!(config)
|
|
@@ -262,32 +405,15 @@ module Html2rss
|
|
|
262
405
|
options[:strategy]&.to_sym || :auto
|
|
263
406
|
end
|
|
264
407
|
|
|
265
|
-
def current_max_redirects
|
|
266
|
-
options.fetch(:max_redirects, Html2rss::RequestService::Policy::DEFAULTS[:max_redirects])
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
def current_max_requests
|
|
270
|
-
options.fetch(:max_requests, Html2rss::RequestService::Policy::DEFAULTS[:max_requests])
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
def suggested_max_redirects
|
|
274
|
-
current_max_redirects + 1
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def suggested_max_requests
|
|
278
|
-
current_max_requests + 1
|
|
279
|
-
end
|
|
280
|
-
|
|
281
408
|
def execute_feed # rubocop:disable Metrics/MethodLength
|
|
282
409
|
yield
|
|
283
|
-
rescue
|
|
410
|
+
rescue Html2rss::RequestService::RedirectLimitReached => error
|
|
284
411
|
raise Thor::Error,
|
|
285
412
|
"#{error.message}. already retried the last redirect hop once; " \
|
|
286
|
-
|
|
413
|
+
'retry with higher --max-redirects or use the final URL directly.'
|
|
287
414
|
rescue Html2rss::RequestService::RequestBudgetExceeded => error
|
|
288
415
|
raise Thor::Error,
|
|
289
|
-
"#{error.message}. retry with --max-requests
|
|
290
|
-
'or increase request.max_requests in the config.'
|
|
416
|
+
"#{error.message}. retry with higher --max-requests or increase request.max_requests in config."
|
|
291
417
|
rescue Html2rss::RequestService::BotasaurusConfigurationError,
|
|
292
418
|
Html2rss::RequestService::BotasaurusConnectionFailed,
|
|
293
419
|
Html2rss::RequestService::BotasaurusServiceError,
|
|
@@ -296,8 +422,8 @@ module Html2rss
|
|
|
296
422
|
raise Thor::Error, error.message
|
|
297
423
|
end
|
|
298
424
|
|
|
299
|
-
def
|
|
300
|
-
Html2rss.
|
|
425
|
+
def scrape_feed_result_for(url, strategy, local_file_path)
|
|
426
|
+
Html2rss.scrape(
|
|
301
427
|
url,
|
|
302
428
|
strategy:,
|
|
303
429
|
items_selector: options[:items_selector],
|
|
@@ -308,21 +434,20 @@ module Html2rss
|
|
|
308
434
|
)
|
|
309
435
|
end
|
|
310
436
|
|
|
311
|
-
def
|
|
312
|
-
$stderr.puts JSON.pretty_generate(
|
|
437
|
+
def explain_json!(payload)
|
|
438
|
+
$stderr.puts JSON.pretty_generate(payload) # rubocop:disable Style/StderrPuts
|
|
313
439
|
end
|
|
314
440
|
|
|
315
|
-
def
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
)
|
|
441
|
+
def capture_explain_payload(result)
|
|
442
|
+
{
|
|
443
|
+
articles_count: result.articles_count,
|
|
444
|
+
channel_title: result.channel_title,
|
|
445
|
+
has_selectors: result.has_selectors,
|
|
446
|
+
segment_strategy: result.segment_strategy,
|
|
447
|
+
selected_strategy: result.selected_strategy,
|
|
448
|
+
inferred_topics: result.inferred_topics,
|
|
449
|
+
admission_drops: result.admission_drops
|
|
450
|
+
}.compact
|
|
326
451
|
end
|
|
327
452
|
|
|
328
453
|
def check_file_exists!(path)
|
|
@@ -59,20 +59,17 @@ module Html2rss
|
|
|
59
59
|
#
|
|
60
60
|
# @param headers [Hash, nil] caller provided headers
|
|
61
61
|
# @param channel_language [String, nil] language defined on the channel
|
|
62
|
-
# @param url [String] request URL used to infer the Host header
|
|
63
62
|
# @return [Hash{String => String}] normalized HTTP headers
|
|
64
|
-
def normalize(headers, channel_language
|
|
65
|
-
new(headers || {}, channel_language
|
|
63
|
+
def normalize(headers, channel_language:)
|
|
64
|
+
new(headers || {}, channel_language:).to_h
|
|
66
65
|
end
|
|
67
66
|
end
|
|
68
67
|
|
|
69
68
|
# @param headers [Hash{String, Symbol => String}] caller-provided headers
|
|
70
69
|
# @param channel_language [String, nil] channel language hint for Accept-Language
|
|
71
|
-
|
|
72
|
-
def initialize(headers, channel_language:, url:)
|
|
70
|
+
def initialize(headers, channel_language:)
|
|
73
71
|
@headers = headers
|
|
74
72
|
@channel_language = channel_language
|
|
75
|
-
@url = url
|
|
76
73
|
end
|
|
77
74
|
|
|
78
75
|
##
|
|
@@ -86,14 +83,13 @@ module Html2rss
|
|
|
86
83
|
|
|
87
84
|
defaults['Accept'] = normalize_accept(accept_override)
|
|
88
85
|
defaults['Accept-Language'] = build_accept_language
|
|
89
|
-
defaults['Host'] ||= request_host
|
|
90
86
|
|
|
91
87
|
defaults.compact
|
|
92
88
|
end
|
|
93
89
|
|
|
94
90
|
private
|
|
95
91
|
|
|
96
|
-
attr_reader :headers, :channel_language
|
|
92
|
+
attr_reader :headers, :channel_language
|
|
97
93
|
|
|
98
94
|
def normalize_custom_headers(custom)
|
|
99
95
|
custom.transform_keys { canonicalize(_1) }
|
|
@@ -134,12 +130,6 @@ module Html2rss
|
|
|
134
130
|
|
|
135
131
|
"#{primary}-#{region},#{primary};q=0.9"
|
|
136
132
|
end
|
|
137
|
-
|
|
138
|
-
def request_host
|
|
139
|
-
return nil if url.nil? || url.empty?
|
|
140
|
-
|
|
141
|
-
Html2rss::Url.from_absolute(url).host
|
|
142
|
-
end
|
|
143
133
|
end
|
|
144
134
|
end
|
|
145
135
|
end
|