html2rss 0.27.2 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +32 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/auto_source_contract.rb +7 -0
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +268 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -40
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +48 -24
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
data/lib/html2rss/capture.rb
CHANGED
|
@@ -2,28 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
##
|
|
5
|
-
# Analyzes a URL and produces a durable feed config: items selector +
|
|
5
|
+
# Analyzes a URL and produces a durable feed config: items selector + metadata.
|
|
6
6
|
#
|
|
7
7
|
# Fetches via {FeedPipeline} (including AutoFallback for +:auto+), extracts articles,
|
|
8
|
-
#
|
|
8
|
+
# derives reusable items CSS selectors from SST segments, and infers directory catalog metadata.
|
|
9
9
|
#
|
|
10
10
|
# {include:file:lib/html2rss/capture/README.md}
|
|
11
11
|
class Capture # rubocop:disable Metrics/ClassLength -- segment strategies + CSS trim stay co-located
|
|
12
12
|
LEADING_TRIM_TAGS = %w[html body].freeze
|
|
13
13
|
private_constant :LEADING_TRIM_TAGS
|
|
14
14
|
|
|
15
|
+
# Packaged YAML language server modeline.
|
|
16
|
+
SCHEMA_MODELINE = '# yaml-language-server: $schema=https://raw.githubusercontent.com/html2rss/html2rss/refs/heads/master/schema/html2rss-config.schema.json'
|
|
17
|
+
|
|
15
18
|
# Ordered Segmenter strategies tried until the items selector quality gate passes.
|
|
16
19
|
SEGMENT_STRATEGIES = %i[list cluster semantic].freeze
|
|
17
20
|
|
|
18
21
|
# Minimum matched segment/article pairs required to emit an items selector.
|
|
19
22
|
MIN_SELECTOR_MATCHES = 2
|
|
20
23
|
|
|
24
|
+
# Admission drops at or above this sum default enhance to false (chrome-heavy listing).
|
|
25
|
+
CHROME_DROP_THRESHOLD = 3
|
|
26
|
+
private_constant :CHROME_DROP_THRESHOLD
|
|
27
|
+
|
|
21
28
|
##
|
|
22
29
|
# Result of a capture operation (config plus quality meta).
|
|
23
30
|
CaptureResult = Data.define(
|
|
24
|
-
:config, :articles_count, :channel_title, :has_selectors, :segment_strategy,
|
|
25
|
-
:admission_drops, :selected_strategy
|
|
26
|
-
)
|
|
31
|
+
:config, :yaml, :articles_count, :channel_title, :has_selectors, :segment_strategy,
|
|
32
|
+
:admission_drops, :selected_strategy, :inferred_topics, :native_feed, :suggested_channel_url
|
|
33
|
+
) do
|
|
34
|
+
# rubocop:disable Metrics/ParameterLists
|
|
35
|
+
##
|
|
36
|
+
# @param config [Hash]
|
|
37
|
+
# @param articles_count [Integer]
|
|
38
|
+
# @param channel_title [String]
|
|
39
|
+
# @param has_selectors [Boolean]
|
|
40
|
+
# @param segment_strategy [Symbol]
|
|
41
|
+
# @param yaml [String, nil]
|
|
42
|
+
# @param admission_drops [Hash]
|
|
43
|
+
# @param selected_strategy [Symbol, nil]
|
|
44
|
+
# @param inferred_topics [Array<String>]
|
|
45
|
+
# @param native_feed [String, nil]
|
|
46
|
+
# @param suggested_channel_url [String, nil]
|
|
47
|
+
def initialize(config:, articles_count:, channel_title:, has_selectors:, segment_strategy:, # rubocop:disable Metrics/MethodLength
|
|
48
|
+
yaml: nil, admission_drops: {}, selected_strategy: nil, inferred_topics: [], native_feed: nil,
|
|
49
|
+
suggested_channel_url: nil)
|
|
50
|
+
super(
|
|
51
|
+
config:,
|
|
52
|
+
yaml: yaml || "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
|
|
53
|
+
articles_count:,
|
|
54
|
+
channel_title:,
|
|
55
|
+
has_selectors:,
|
|
56
|
+
segment_strategy:,
|
|
57
|
+
admission_drops:,
|
|
58
|
+
selected_strategy:,
|
|
59
|
+
inferred_topics:,
|
|
60
|
+
native_feed:,
|
|
61
|
+
suggested_channel_url:
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
# rubocop:enable Metrics/ParameterLists
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# @return [Boolean] whether first-party RSS was detected
|
|
68
|
+
def native_feed?
|
|
69
|
+
!native_feed.nil?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
27
72
|
|
|
28
73
|
class << self
|
|
29
74
|
##
|
|
@@ -32,6 +77,11 @@ module Html2rss
|
|
|
32
77
|
# @param url [String] source page URL
|
|
33
78
|
# @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
34
79
|
# @option options [String, nil] :items_selector optional selector hint
|
|
80
|
+
# @option options [Array<String>, nil] :topics optional directory topics override
|
|
81
|
+
# @option options [String, nil] :title optional title override
|
|
82
|
+
# @option options [String, nil] :summary optional summary override
|
|
83
|
+
# @option options [Boolean] :force whether to ignore native feed detection
|
|
84
|
+
# @option options [Boolean, nil] :enhance whether to force enhance: true
|
|
35
85
|
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
36
86
|
# @option options [Integer, nil] :max_requests optional request budget override
|
|
37
87
|
# @option options [Integer, nil] :limit max articles to keep
|
|
@@ -45,16 +95,25 @@ module Html2rss
|
|
|
45
95
|
##
|
|
46
96
|
# @param url [String] source page URL
|
|
47
97
|
# @param strategy [Symbol] request strategy
|
|
48
|
-
# @param options [Hash] additional options
|
|
49
98
|
# @option options [String, nil] :items_selector optional selector hint
|
|
99
|
+
# @option options [Array<String>, nil] :topics optional directory topics override
|
|
100
|
+
# @option options [String, nil] :title optional title override
|
|
101
|
+
# @option options [String, nil] :summary optional summary override
|
|
102
|
+
# @option options [Boolean] :force whether to ignore native feed detection
|
|
103
|
+
# @option options [Boolean, nil] :enhance whether to force enhance: true
|
|
50
104
|
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
51
105
|
# @option options [Integer, nil] :max_requests optional request budget override
|
|
52
106
|
# @option options [Integer, nil] :limit max articles to keep
|
|
53
107
|
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
54
|
-
def initialize(url, strategy: :auto, **options)
|
|
108
|
+
def initialize(url, strategy: :auto, **options) # rubocop:disable Metrics/MethodLength
|
|
55
109
|
@url = url
|
|
56
110
|
@strategy = strategy
|
|
57
111
|
@items_selector_hint = options.delete(:items_selector)
|
|
112
|
+
@topics = options.delete(:topics)
|
|
113
|
+
@title = options.delete(:title)
|
|
114
|
+
@summary = options.delete(:summary)
|
|
115
|
+
@force = options.delete(:force) || false
|
|
116
|
+
@enhance_option = options.delete(:enhance)
|
|
58
117
|
@max_redirects = options.delete(:max_redirects)
|
|
59
118
|
@max_requests = options.delete(:max_requests)
|
|
60
119
|
@limit = options.delete(:limit)
|
|
@@ -65,12 +124,17 @@ module Html2rss
|
|
|
65
124
|
# Runs the capture pipeline.
|
|
66
125
|
#
|
|
67
126
|
# @return [CaptureResult]
|
|
68
|
-
def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
127
|
+
def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
69
128
|
outcome = FeedPipeline.new(raw_config).to_outcome
|
|
129
|
+
@admission_drops = outcome.admission_drops
|
|
70
130
|
selectors, segment_strategy = derive_selectors(outcome.response, outcome.articles)
|
|
131
|
+
ch_title = @title || channel_title_from(outcome.response)
|
|
132
|
+
topics = @topics || infer_topics("#{@url} #{ch_title}")
|
|
133
|
+
native_feed = probe_native_feed(outcome.response) unless @force
|
|
71
134
|
|
|
72
135
|
config = {
|
|
73
|
-
|
|
136
|
+
directory: build_directory(ch_title, topics),
|
|
137
|
+
channel: build_channel(outcome.response, ch_title),
|
|
74
138
|
selectors: selectors.empty? ? nil : selectors,
|
|
75
139
|
**strategy_stamp(outcome),
|
|
76
140
|
**local_file_request_overlay
|
|
@@ -78,17 +142,75 @@ module Html2rss
|
|
|
78
142
|
|
|
79
143
|
CaptureResult.new(
|
|
80
144
|
config:,
|
|
145
|
+
yaml: "#{SCHEMA_MODELINE}\n#{Config.to_yaml(config)}",
|
|
81
146
|
articles_count: outcome.articles.size,
|
|
82
|
-
channel_title:
|
|
147
|
+
channel_title: ch_title,
|
|
83
148
|
has_selectors: !selectors.empty?,
|
|
84
149
|
segment_strategy:,
|
|
85
150
|
admission_drops: outcome.admission_drops,
|
|
86
|
-
selected_strategy: outcome.selected_strategy
|
|
151
|
+
selected_strategy: outcome.selected_strategy,
|
|
152
|
+
inferred_topics: topics,
|
|
153
|
+
native_feed:,
|
|
154
|
+
suggested_channel_url: suggested_channel_url(outcome)
|
|
87
155
|
)
|
|
88
156
|
end
|
|
89
157
|
|
|
90
158
|
private
|
|
91
159
|
|
|
160
|
+
def build_directory(title, topics)
|
|
161
|
+
summary_text = @summary || "#{title} updates and announcements."
|
|
162
|
+
{
|
|
163
|
+
topics: topics.take(2),
|
|
164
|
+
title:,
|
|
165
|
+
summary: summary_text[0, 160]
|
|
166
|
+
}.compact
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def probe_native_feed(response)
|
|
170
|
+
return nil unless response && @url
|
|
171
|
+
|
|
172
|
+
Syndication::Discovery.best_feed_url(
|
|
173
|
+
page_url: @url,
|
|
174
|
+
request_session: discovery_session,
|
|
175
|
+
parsed_body: (response.parsed_body if response.html_response?),
|
|
176
|
+
html: response.body
|
|
177
|
+
)&.to_s
|
|
178
|
+
rescue StandardError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def discovery_session
|
|
183
|
+
config = Config.from_hash(raw_config)
|
|
184
|
+
resources = FeedPipeline::RuntimePolicy.resources_for(config)
|
|
185
|
+
strategy = FeedPipeline::StrategyPlan.concrete_for_diagnostic(@strategy)
|
|
186
|
+
RequestSession.build(
|
|
187
|
+
config:,
|
|
188
|
+
strategy:,
|
|
189
|
+
budget: resources.budget,
|
|
190
|
+
policy: resources.policy
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def infer_topics(text) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/MethodLength
|
|
195
|
+
t = text.to_s.downcase
|
|
196
|
+
inferred = []
|
|
197
|
+
inferred << 'tech' if /tech|developer|software|cloud|api|compute|ai|data|model|hardware|cyber/.match?(t)
|
|
198
|
+
inferred << 'security' if /security|vulnerab|advis|threat|breach|cve|cert|patch/.match?(t)
|
|
199
|
+
inferred << 'science' if /science|space|astron|physics|bio|research|institut|discover|nature/.match?(t)
|
|
200
|
+
inferred << 'energy' if /energy|wind|solar|power|grid|renewab|oil|gas|hydro/.match?(t)
|
|
201
|
+
inferred << 'finance' if /financ|bank|invest|monetary|market|regulat|treasury|econom/.match?(t)
|
|
202
|
+
inferred << 'civic' if /govern|policy|council|parliament|court|treaty|public/.match?(t)
|
|
203
|
+
inferred << 'environment' if /climat|environ|planet|sustainab|carbon|ecolog|earth/.match?(t)
|
|
204
|
+
inferred << 'consumer' if /consum|recall|safety|rating|test|product/.match?(t)
|
|
205
|
+
inferred << 'travel' if /travel|touris|destination|trip|flight|hotel|transit|rail/.match?(t)
|
|
206
|
+
inferred << 'sports' if /sport|olympic|racing|football|soccer|tennis|game/.match?(t)
|
|
207
|
+
inferred << 'health' if /health|medic|pharma|hospital|disease|patient/.match?(t)
|
|
208
|
+
inferred << 'education' if /educat|universit|school|student|academi|learn/.match?(t)
|
|
209
|
+
inferred << 'transport' if /transport|traffic|automotive|vehicle|train|bus/.match?(t)
|
|
210
|
+
valid = inferred.select { |topic| Config::Validator::DIRECTORY_TOPICS.include?(topic) }
|
|
211
|
+
valid.empty? ? ['news'] : valid.first(2)
|
|
212
|
+
end
|
|
213
|
+
|
|
92
214
|
def strategy_stamp(outcome)
|
|
93
215
|
concrete = outcome.selected_strategy || concrete_request_strategy
|
|
94
216
|
return {} if concrete.nil? || concrete == :auto
|
|
@@ -147,11 +269,12 @@ module Html2rss
|
|
|
147
269
|
end
|
|
148
270
|
|
|
149
271
|
def hint_selectors
|
|
150
|
-
[{ items: { selector: @items_selector_hint, enhance:
|
|
272
|
+
[{ items: { selector: @items_selector_hint, enhance: resolve_enhance } }, :hint]
|
|
151
273
|
end
|
|
152
274
|
|
|
153
275
|
def select_enhance_selectors(sst, articles) # rubocop:disable Metrics/MethodLength -- strategy loop + gate
|
|
154
276
|
link_resolver = Scoring::LinkResolver.new(@url)
|
|
277
|
+
enhance = resolve_enhance
|
|
155
278
|
|
|
156
279
|
SEGMENT_STRATEGIES.each do |strategy|
|
|
157
280
|
segments = AutoSource::Segmenter.call(
|
|
@@ -161,7 +284,7 @@ module Html2rss
|
|
|
161
284
|
items_sel = items_selector(matched)
|
|
162
285
|
next unless items_sel && matched.size >= MIN_SELECTOR_MATCHES
|
|
163
286
|
|
|
164
|
-
return [{ items: { selector: items_sel, enhance:
|
|
287
|
+
return [{ items: { selector: items_sel, enhance: } }, strategy]
|
|
165
288
|
end
|
|
166
289
|
|
|
167
290
|
[{}, nil]
|
|
@@ -301,10 +424,10 @@ module Html2rss
|
|
|
301
424
|
"/#{prefix.join('/')}"
|
|
302
425
|
end
|
|
303
426
|
|
|
304
|
-
def build_channel(response)
|
|
427
|
+
def build_channel(response, title = nil)
|
|
305
428
|
{
|
|
306
429
|
url: @url,
|
|
307
|
-
title: channel_title_from(response),
|
|
430
|
+
title: title || channel_title_from(response),
|
|
308
431
|
time_zone: 'UTC'
|
|
309
432
|
}.compact
|
|
310
433
|
end
|
|
@@ -314,5 +437,25 @@ module Html2rss
|
|
|
314
437
|
rescue StandardError
|
|
315
438
|
nil
|
|
316
439
|
end
|
|
440
|
+
|
|
441
|
+
def resolve_enhance
|
|
442
|
+
return @enhance_option unless @enhance_option.nil?
|
|
443
|
+
|
|
444
|
+
drops = (@admission_drops || {}).values.sum
|
|
445
|
+
drops < CHROME_DROP_THRESHOLD
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def suggested_channel_url(outcome)
|
|
449
|
+
entry = outcome.scrape_target.entry_url.to_s
|
|
450
|
+
effective = outcome.scrape_target.effective_url.to_s
|
|
451
|
+
return effective unless Url.from_absolute(entry).same_document?(Url.from_absolute(effective))
|
|
452
|
+
|
|
453
|
+
final = outcome.response.url.to_s
|
|
454
|
+
return final unless Url.from_absolute(@url).same_document?(Url.from_absolute(final))
|
|
455
|
+
|
|
456
|
+
nil
|
|
457
|
+
rescue ArgumentError
|
|
458
|
+
nil
|
|
459
|
+
end
|
|
317
460
|
end
|
|
318
461
|
end
|
data/lib/html2rss/channel.rb
CHANGED
|
@@ -155,7 +155,7 @@ module Html2rss
|
|
|
155
155
|
# @param last_build_date [Time, String]
|
|
156
156
|
# @param image [Html2rss::Url, String, nil]
|
|
157
157
|
# @param author [String, nil]
|
|
158
|
-
# rubocop:disable Metrics/ParameterLists -- Data.define members are the channel contract
|
|
158
|
+
# rubocop:disable-next Metrics/ParameterLists -- Data.define members are the channel contract
|
|
159
159
|
def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
|
|
160
160
|
super(
|
|
161
161
|
title: freeze_string(title),
|
|
@@ -168,7 +168,6 @@ module Html2rss
|
|
|
168
168
|
author: author && freeze_string(author)
|
|
169
169
|
)
|
|
170
170
|
end
|
|
171
|
-
# rubocop:enable Metrics/ParameterLists
|
|
172
171
|
|
|
173
172
|
private
|
|
174
173
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class CLI
|
|
5
|
+
##
|
|
6
|
+
# Display-only probe facts for inspect and recon text cards.
|
|
7
|
+
ProbeView = Data.define(
|
|
8
|
+
:requested,
|
|
9
|
+
:final,
|
|
10
|
+
:status,
|
|
11
|
+
:surface,
|
|
12
|
+
:articles_count,
|
|
13
|
+
:verdict,
|
|
14
|
+
:native_feed,
|
|
15
|
+
:alternate_feeds,
|
|
16
|
+
:notes,
|
|
17
|
+
:strategy
|
|
18
|
+
) do
|
|
19
|
+
class << self
|
|
20
|
+
##
|
|
21
|
+
# @param data [Hash] inspect wire payload
|
|
22
|
+
# @return [ProbeView]
|
|
23
|
+
def from_wire(data) # rubocop:disable Metrics/MethodLength
|
|
24
|
+
new(
|
|
25
|
+
requested: wire_val(data, :requested_url),
|
|
26
|
+
final: wire_val(data, :final_url),
|
|
27
|
+
status: wire_val(data, :status),
|
|
28
|
+
surface: wire_val(data, :surface_category),
|
|
29
|
+
articles_count: wire_val(data, :articles_count),
|
|
30
|
+
verdict: nil,
|
|
31
|
+
native_feed: nil,
|
|
32
|
+
alternate_feeds: wire_val(data, :alternate_feeds) || [],
|
|
33
|
+
notes: [],
|
|
34
|
+
strategy: wire_val(data, :strategy)
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param result [Html2rss::Recon::Result]
|
|
40
|
+
# @return [ProbeView]
|
|
41
|
+
def from_recon(result) # rubocop:disable Metrics/MethodLength
|
|
42
|
+
new(
|
|
43
|
+
requested: result.requested_url.to_s,
|
|
44
|
+
final: result.final_url.to_s,
|
|
45
|
+
status: result.status,
|
|
46
|
+
surface: result.surface_category,
|
|
47
|
+
articles_count: result.articles_count,
|
|
48
|
+
verdict: result.verdict,
|
|
49
|
+
native_feed: result.native_feed&.to_s,
|
|
50
|
+
alternate_feeds: [],
|
|
51
|
+
notes: result.notes,
|
|
52
|
+
strategy: nil
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# @param data [Hash]
|
|
58
|
+
# @param key [Symbol]
|
|
59
|
+
# @return [Object, nil]
|
|
60
|
+
def wire_val(data, key)
|
|
61
|
+
data[key] || data[key.to_s]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class CLI
|
|
7
|
+
##
|
|
8
|
+
# Text and JSON rendering for CLI command output (display only; no policy).
|
|
9
|
+
module Render
|
|
10
|
+
# ANSI colors for recon verdict labels in text output.
|
|
11
|
+
VERDICT_COLORS = { build: "\e[32m", defer: "\e[33m" }.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @param results [Array<Hash>]
|
|
17
|
+
# @param format [String]
|
|
18
|
+
# @param batch_mode [Boolean]
|
|
19
|
+
# @return [void]
|
|
20
|
+
def inspect_output(results, format:, batch_mode:)
|
|
21
|
+
if format == 'json'
|
|
22
|
+
json(results, batch_mode)
|
|
23
|
+
else
|
|
24
|
+
results.each { |data| probe_card(ProbeView.from_wire(data)) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @param results [Array<Html2rss::Recon::Result>]
|
|
30
|
+
# @param format [String]
|
|
31
|
+
# @param batch_mode [Boolean]
|
|
32
|
+
# @param url_only [Boolean]
|
|
33
|
+
# @return [void]
|
|
34
|
+
def recon_output(results, format:, batch_mode:, url_only: false) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
35
|
+
if url_only
|
|
36
|
+
results.each { |r| puts r.requested_url }
|
|
37
|
+
elsif format == 'json'
|
|
38
|
+
json(results.map(&:to_h), batch_mode)
|
|
39
|
+
elsif format == 'tsv'
|
|
40
|
+
puts %w[verdict status requested_url final_url native_feed notes].join("\t")
|
|
41
|
+
results.each do |r|
|
|
42
|
+
row = [
|
|
43
|
+
r.verdict.to_s.upcase,
|
|
44
|
+
r.status || '-',
|
|
45
|
+
r.requested_url,
|
|
46
|
+
r.final_url,
|
|
47
|
+
r.native_feed || '-',
|
|
48
|
+
r.notes.join('; ')
|
|
49
|
+
]
|
|
50
|
+
puts row.join("\t")
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
results.each { |r| probe_card(ProbeView.from_recon(r)) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# @param view [ProbeView]
|
|
59
|
+
# @return [void]
|
|
60
|
+
def probe_card(view) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
61
|
+
if view.verdict
|
|
62
|
+
color = VERDICT_COLORS.fetch(view.verdict.to_sym, "\e[31m")
|
|
63
|
+
puts "#{color}[#{view.verdict.to_s.upcase}]\e[0m #{view.requested}"
|
|
64
|
+
else
|
|
65
|
+
puts view.requested
|
|
66
|
+
end
|
|
67
|
+
probe_lines(view)
|
|
68
|
+
if view.alternate_feeds.any?
|
|
69
|
+
hrefs = view.alternate_feeds.filter_map { |f| ProbeView.wire_val(f, :href) }
|
|
70
|
+
puts " Feeds: #{hrefs.join(', ')}" if hrefs.any?
|
|
71
|
+
end
|
|
72
|
+
puts " Feed: #{view.native_feed}" if view.native_feed
|
|
73
|
+
puts " Notes: #{view.notes.join(', ')}" if view.notes.any?
|
|
74
|
+
puts " Strategy: #{view.strategy}" if view.strategy
|
|
75
|
+
puts ''
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
##
|
|
79
|
+
# @param result [Html2rss::Test::Result]
|
|
80
|
+
# @param source [String]
|
|
81
|
+
# @return [void]
|
|
82
|
+
def test_card(result, source) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
83
|
+
if result.success
|
|
84
|
+
puts "\e[32m✓ Schema valid\e[0m (#{source})"
|
|
85
|
+
dur = "#{result.duration_seconds}s"
|
|
86
|
+
puts "\e[32m✓ Extracted #{result.item_count} items in #{dur}\e[0m (strategy: #{result.strategy_used})"
|
|
87
|
+
puts "\nChannel: #{result.channel_title} (#{result.channel_url})"
|
|
88
|
+
if result.sample_items.any?
|
|
89
|
+
puts 'Sample items:'
|
|
90
|
+
result.sample_items.each_with_index do |item, i|
|
|
91
|
+
puts " #{i + 1}. #{"#{item[:published_at]} | " if item[:published_at]}#{item[:title]}"
|
|
92
|
+
puts " #{item[:url]}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
warn "\e[31m✗ Test failed\e[0m (#{source})"
|
|
97
|
+
warn " Error: #{result.error_message}" if result.error_message
|
|
98
|
+
result.validation_errors&.each { |k, v| warn " Schema error [#{k}]: #{Array(v).join(', ')}" }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
##
|
|
103
|
+
# @param data [Object]
|
|
104
|
+
# @param batch_mode [Boolean]
|
|
105
|
+
# @return [void]
|
|
106
|
+
def json(data, batch_mode)
|
|
107
|
+
payload = batch_mode ? data : data.first
|
|
108
|
+
puts JSON.pretty_generate(payload)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
##
|
|
112
|
+
# @param view [ProbeView]
|
|
113
|
+
# @return [void]
|
|
114
|
+
def probe_lines(view)
|
|
115
|
+
if view.final && view.final != view.requested
|
|
116
|
+
puts " Final: #{view.final} (HTTP #{view.status || 'ERR'})"
|
|
117
|
+
end
|
|
118
|
+
surface = view.surface.respond_to?(:to_s) ? view.surface.to_s : view.surface
|
|
119
|
+
puts " Surface: #{surface} (#{view.articles_count} articles)"
|
|
120
|
+
end
|
|
121
|
+
private_class_method :probe_lines
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class CLI
|
|
5
|
+
##
|
|
6
|
+
# Validate command: file/glob/stdin branching and multi-file reporting.
|
|
7
|
+
module Validate
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# @param files [Array<String>]
|
|
12
|
+
# @param params [Hash]
|
|
13
|
+
# @param quiet [Boolean]
|
|
14
|
+
# @return [void]
|
|
15
|
+
# @raise [Thor::Error]
|
|
16
|
+
def run(files, params:, quiet:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
17
|
+
if files.size == 2 && File.file?(files[0].to_s) && !File.exist?(files[1].to_s)
|
|
18
|
+
raise Thor::Error, "No such file: #{files[1]}" if path_like_config?(files[1])
|
|
19
|
+
|
|
20
|
+
return run_named_feed(files[0], files[1], params:, quiet:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
target_files = resolve_files(files)
|
|
24
|
+
failed = []
|
|
25
|
+
|
|
26
|
+
target_files.each do |file|
|
|
27
|
+
result = validate_file(file, params:)
|
|
28
|
+
|
|
29
|
+
if result.success?
|
|
30
|
+
puts(target_files.size == 1 ? 'Configuration is valid' : "ok #{file}") unless quiet
|
|
31
|
+
else
|
|
32
|
+
error_details = result.errors.to_h
|
|
33
|
+
raise Thor::Error, "Invalid configuration: #{error_details}" if target_files.size == 1
|
|
34
|
+
|
|
35
|
+
warn "FAIL #{file}"
|
|
36
|
+
error_details.each { |key, msg| warn " #{key}: #{Array(msg).join(', ')}" }
|
|
37
|
+
failed << file
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
return if failed.empty?
|
|
42
|
+
|
|
43
|
+
raise Thor::Error, "#{failed.size}/#{target_files.size} configurations failed validation."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# @param files [Array<String>]
|
|
48
|
+
# @return [Array<String>]
|
|
49
|
+
def resolve_files(files)
|
|
50
|
+
return ['-'] if files.empty? || files == ['-']
|
|
51
|
+
|
|
52
|
+
resolved = []
|
|
53
|
+
files.each do |f|
|
|
54
|
+
matched = Dir.glob(f)
|
|
55
|
+
resolved.concat(matched.empty? ? [f] : matched)
|
|
56
|
+
end
|
|
57
|
+
resolved.uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run_named_feed(file, feed_name, params:, quiet:)
|
|
61
|
+
result = Html2rss.validate(file, feed_name, params:)
|
|
62
|
+
raise Thor::Error, "Invalid configuration: #{result.errors.to_h}" unless result.success?
|
|
63
|
+
|
|
64
|
+
puts 'Configuration is valid' unless quiet
|
|
65
|
+
end
|
|
66
|
+
module_function :run_named_feed
|
|
67
|
+
private_class_method :run_named_feed
|
|
68
|
+
|
|
69
|
+
def validate_file(file, params:)
|
|
70
|
+
if file == '-'
|
|
71
|
+
Html2rss.validate($stdin.read, params:)
|
|
72
|
+
else
|
|
73
|
+
Html2rss.validate(file, params:)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
module_function :validate_file
|
|
77
|
+
private_class_method :validate_file
|
|
78
|
+
|
|
79
|
+
def path_like_config?(arg)
|
|
80
|
+
path = arg.to_s
|
|
81
|
+
return true if path.start_with?('-')
|
|
82
|
+
return true if path.end_with?('.yml', '.yaml')
|
|
83
|
+
return true if path.include?('/') || path.include?('*')
|
|
84
|
+
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
module_function :path_like_config?
|
|
88
|
+
private_class_method :path_like_config?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|