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,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Analyzes a URL and produces a durable feed config: items selector + +enhance: true+.
|
|
6
|
+
#
|
|
7
|
+
# Fetches via {FeedPipeline} (including AutoFallback for +:auto+), extracts articles,
|
|
8
|
+
# then derives a reusable items CSS selector from SST segments (list → cluster → semantic).
|
|
9
|
+
#
|
|
10
|
+
# {include:file:lib/html2rss/capture/README.md}
|
|
11
|
+
class Capture # rubocop:disable Metrics/ClassLength -- segment strategies + CSS trim stay co-located
|
|
12
|
+
LEADING_TRIM_TAGS = %w[html body].freeze
|
|
13
|
+
private_constant :LEADING_TRIM_TAGS
|
|
14
|
+
|
|
15
|
+
# Ordered Segmenter strategies tried until the items selector quality gate passes.
|
|
16
|
+
SEGMENT_STRATEGIES = %i[list cluster semantic].freeze
|
|
17
|
+
|
|
18
|
+
# Minimum matched segment/article pairs required to emit an items selector.
|
|
19
|
+
MIN_SELECTOR_MATCHES = 2
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# Result of a capture operation (config plus quality meta).
|
|
23
|
+
CaptureResult = Data.define(
|
|
24
|
+
:config, :articles_count, :channel_title, :has_selectors, :segment_strategy,
|
|
25
|
+
:admission_drops, :selected_strategy
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
##
|
|
30
|
+
# Analyzes a URL and builds a reusable feed config.
|
|
31
|
+
#
|
|
32
|
+
# @param url [String] source page URL
|
|
33
|
+
# @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
34
|
+
# @option options [String, nil] :items_selector optional selector hint
|
|
35
|
+
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
36
|
+
# @option options [Integer, nil] :max_requests optional request budget override
|
|
37
|
+
# @option options [Integer, nil] :limit max articles to keep
|
|
38
|
+
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
39
|
+
# @return [CaptureResult]
|
|
40
|
+
def build(url, strategy: :auto, **)
|
|
41
|
+
new(url, strategy:, **).build
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# @param url [String] source page URL
|
|
47
|
+
# @param strategy [Symbol] request strategy
|
|
48
|
+
# @param options [Hash] additional options
|
|
49
|
+
# @option options [String, nil] :items_selector optional selector hint
|
|
50
|
+
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
51
|
+
# @option options [Integer, nil] :max_requests optional request budget override
|
|
52
|
+
# @option options [Integer, nil] :limit max articles to keep
|
|
53
|
+
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
54
|
+
def initialize(url, strategy: :auto, **options)
|
|
55
|
+
@url = url
|
|
56
|
+
@strategy = strategy
|
|
57
|
+
@items_selector_hint = options.delete(:items_selector)
|
|
58
|
+
@max_redirects = options.delete(:max_redirects)
|
|
59
|
+
@max_requests = options.delete(:max_requests)
|
|
60
|
+
@limit = options.delete(:limit)
|
|
61
|
+
@local_file_path = options.delete(:local_file_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# Runs the capture pipeline.
|
|
66
|
+
#
|
|
67
|
+
# @return [CaptureResult]
|
|
68
|
+
def build # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- outcome + selector + result assembly
|
|
69
|
+
outcome = FeedPipeline.new(raw_config).to_outcome
|
|
70
|
+
selectors, segment_strategy = derive_selectors(outcome.response, outcome.articles)
|
|
71
|
+
|
|
72
|
+
config = {
|
|
73
|
+
channel: build_channel(outcome.response),
|
|
74
|
+
selectors: selectors.empty? ? nil : selectors,
|
|
75
|
+
**strategy_stamp(outcome),
|
|
76
|
+
**local_file_request_overlay
|
|
77
|
+
}.compact
|
|
78
|
+
|
|
79
|
+
CaptureResult.new(
|
|
80
|
+
config:,
|
|
81
|
+
articles_count: outcome.articles.size,
|
|
82
|
+
channel_title: channel_title_from(outcome.response),
|
|
83
|
+
has_selectors: !selectors.empty?,
|
|
84
|
+
segment_strategy:,
|
|
85
|
+
admission_drops: outcome.admission_drops,
|
|
86
|
+
selected_strategy: outcome.selected_strategy
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def strategy_stamp(outcome)
|
|
93
|
+
concrete = outcome.selected_strategy || concrete_request_strategy
|
|
94
|
+
return {} if concrete.nil? || concrete == :auto
|
|
95
|
+
|
|
96
|
+
{ strategy: concrete }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def concrete_request_strategy
|
|
100
|
+
plan = FeedPipeline::StrategyPlan.resolve(@strategy)
|
|
101
|
+
plan.is_a?(FeedPipeline::StrategyPlan::Concrete) ? plan.strategy : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def raw_config
|
|
105
|
+
@raw_config ||= build_raw_config
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_raw_config
|
|
109
|
+
Config.auto_source_config(
|
|
110
|
+
url: @url,
|
|
111
|
+
items_selector: @items_selector_hint,
|
|
112
|
+
request_controls: Config::RequestControls.from_shortcut(
|
|
113
|
+
strategy: @strategy,
|
|
114
|
+
max_redirects: @max_redirects,
|
|
115
|
+
max_requests: @max_requests
|
|
116
|
+
),
|
|
117
|
+
limit: @limit
|
|
118
|
+
).tap { |config| apply_local_file_path!(config) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def apply_local_file_path!(config)
|
|
122
|
+
return unless @local_file_path
|
|
123
|
+
|
|
124
|
+
config[:strategy] = :local_file
|
|
125
|
+
config[:request] ||= {}
|
|
126
|
+
config[:request][:local_file_path] = @local_file_path
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def local_file_request_overlay
|
|
130
|
+
return {} unless @local_file_path
|
|
131
|
+
|
|
132
|
+
{ strategy: :local_file, request: { local_file_path: @local_file_path } }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Array(Hash, Symbol, nil)] selectors hash and winning segment strategy
|
|
136
|
+
def derive_selectors(response, articles)
|
|
137
|
+
return hint_selectors if @items_selector_hint
|
|
138
|
+
return [{}, nil] if articles.empty? || !response.html_response?
|
|
139
|
+
|
|
140
|
+
sst = SST::Normalizer.call(response.body)
|
|
141
|
+
return [{}, nil] unless sst
|
|
142
|
+
|
|
143
|
+
select_enhance_selectors(sst, articles)
|
|
144
|
+
rescue ArgumentError => error
|
|
145
|
+
Log.warn("Capture selector derivation failed: #{error.message}")
|
|
146
|
+
[{}, nil]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def hint_selectors
|
|
150
|
+
[{ items: { selector: @items_selector_hint, enhance: true } }, :hint]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def select_enhance_selectors(sst, articles) # rubocop:disable Metrics/MethodLength -- strategy loop + gate
|
|
154
|
+
link_resolver = Scoring::LinkResolver.new(@url)
|
|
155
|
+
|
|
156
|
+
SEGMENT_STRATEGIES.each do |strategy|
|
|
157
|
+
segments = AutoSource::Segmenter.call(
|
|
158
|
+
sst, base_url: @url, strategy:, permit_unanchored: false, link_resolver:
|
|
159
|
+
)
|
|
160
|
+
matched = match_segments_to_articles(segments, articles)
|
|
161
|
+
items_sel = items_selector(matched)
|
|
162
|
+
next unless items_sel && matched.size >= MIN_SELECTOR_MATCHES
|
|
163
|
+
|
|
164
|
+
return [{ items: { selector: items_sel, enhance: true } }, strategy]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
[{}, nil]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def match_segments_to_articles(segments, articles)
|
|
171
|
+
articles_by_url = articles.each_with_object({}) do |article, hash|
|
|
172
|
+
url_str = article.url.to_s
|
|
173
|
+
hash[url_str] = article unless url_str.empty?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
segments.filter_map do |segment|
|
|
177
|
+
next unless segment.primary_link
|
|
178
|
+
|
|
179
|
+
article = find_matching_article(segment, articles_by_url)
|
|
180
|
+
{ segment:, article: } if article
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def find_matching_article(segment, articles_by_url) # rubocop:disable Metrics/AbcSize
|
|
185
|
+
href = segment.primary_link.attrs.href.to_s
|
|
186
|
+
return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
|
|
187
|
+
|
|
188
|
+
resolved = Url.from_relative(href, @url)
|
|
189
|
+
articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def fuzzy_url_match?(article_url, resolved)
|
|
193
|
+
article_url.to_s.end_with?(resolved.path.to_s)
|
|
194
|
+
rescue StandardError
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def title_from_segment(segment)
|
|
199
|
+
segment.root_node.visible_text.to_s.strip
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def items_selector(matched)
|
|
203
|
+
return nil if matched.empty?
|
|
204
|
+
|
|
205
|
+
roots = lift_heading_link_roots(matched.map { |m| m[:segment].root_node })
|
|
206
|
+
shared = shared_class_items_selector(roots)
|
|
207
|
+
return shared if shared
|
|
208
|
+
|
|
209
|
+
paths = roots.map(&:tag_path)
|
|
210
|
+
common = common_path_prefix(paths)
|
|
211
|
+
tag_path = common.empty? ? paths.first.to_s : common
|
|
212
|
+
return nil if tag_path.empty?
|
|
213
|
+
|
|
214
|
+
css_from_trimmed_tag_path(tag_path)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def lift_heading_link_roots(roots)
|
|
218
|
+
roots.map { |root| lift_heading_link_root(root, roots) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def lift_heading_link_root(root, all_roots)
|
|
222
|
+
return root unless heading_or_inner_title_link?(root)
|
|
223
|
+
|
|
224
|
+
index = SST::Index.for_node(root)
|
|
225
|
+
return root unless index
|
|
226
|
+
|
|
227
|
+
walk_usable_card(root, index, all_roots)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def walk_usable_card(root, index, all_roots)
|
|
231
|
+
candidate = root
|
|
232
|
+
parent = index.parent_of(root)
|
|
233
|
+
while parent && Html::Navigator.usable_card_parent?(parent)
|
|
234
|
+
break if contains_other_root?(parent, root, all_roots)
|
|
235
|
+
|
|
236
|
+
candidate = parent
|
|
237
|
+
parent = index.parent_of(parent)
|
|
238
|
+
end
|
|
239
|
+
candidate
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def heading_or_inner_title_link?(node)
|
|
243
|
+
name = node.name.to_s
|
|
244
|
+
return false if wrapping_anchor_root?(node)
|
|
245
|
+
|
|
246
|
+
Html::Navigator::HEADING_TAGS.include?(name) || name == 'a'
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def wrapping_anchor_root?(node)
|
|
250
|
+
return false unless node.name.to_s == 'a'
|
|
251
|
+
return false unless node.respond_to?(:find)
|
|
252
|
+
|
|
253
|
+
tags = Html::Navigator::WRAPPING_ANCHOR_CHILD_TAGS
|
|
254
|
+
node.find { |child| !child.equal?(node) && tags.include?(child.name.to_s) }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def contains_other_root?(parent, root, all_roots)
|
|
258
|
+
all_roots.any? do |other|
|
|
259
|
+
next if other.equal?(root)
|
|
260
|
+
|
|
261
|
+
other.equal?(parent) || sst_descendant?(other, parent)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def sst_descendant?(child, ancestor)
|
|
266
|
+
SST::Index.for_node(child)&.descendant_of?(child, ancestor)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def shared_class_items_selector(roots)
|
|
270
|
+
shared = roots.map { |root| root.attrs.class_names }.reduce { |left, right| left & right }
|
|
271
|
+
return nil if shared.nil? || shared.empty?
|
|
272
|
+
|
|
273
|
+
"#{roots.first.name}.#{shared.min}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def css_from_trimmed_tag_path(tag_path)
|
|
277
|
+
segments = tag_path.split('/').reject(&:empty?)
|
|
278
|
+
trimmed = trim_leading_items_segments(segments)
|
|
279
|
+
trimmed.join(' > ')
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def trim_leading_items_segments(segments)
|
|
283
|
+
return segments if segments.empty?
|
|
284
|
+
|
|
285
|
+
trimmed = segments.dup
|
|
286
|
+
trimmed.shift while trimmed.any? && LEADING_TRIM_TAGS.include?(trimmed.first)
|
|
287
|
+
trimmed.shift while trimmed.length > 1 && trimmed.first == 'div'
|
|
288
|
+
trimmed.empty? ? [segments.last] : trimmed
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def common_path_prefix(paths) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
292
|
+
return '' if paths.empty?
|
|
293
|
+
|
|
294
|
+
segments = paths.map { |p| p.split('/').reject(&:empty?) }
|
|
295
|
+
prefix = segments.first
|
|
296
|
+
segments.each do |seg|
|
|
297
|
+
i = 0
|
|
298
|
+
i += 1 while i < prefix.length && i < seg.length && prefix[i] == seg[i]
|
|
299
|
+
prefix = prefix[0...i]
|
|
300
|
+
end
|
|
301
|
+
"/#{prefix.join('/')}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def build_channel(response)
|
|
305
|
+
{
|
|
306
|
+
url: @url,
|
|
307
|
+
title: channel_title_from(response),
|
|
308
|
+
time_zone: 'UTC'
|
|
309
|
+
}.compact
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def channel_title_from(response)
|
|
313
|
+
Channel.from_response(response).title
|
|
314
|
+
rescue StandardError
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
data/lib/html2rss/cli.rb
CHANGED
|
@@ -9,10 +9,6 @@ module Html2rss
|
|
|
9
9
|
# The Html2rss command line interface.
|
|
10
10
|
class CLI < Thor # rubocop:disable Metrics/ClassLength
|
|
11
11
|
check_unknown_options!
|
|
12
|
-
# Ordered fallback chain attempted by the feed-level :auto plan.
|
|
13
|
-
#
|
|
14
|
-
# @return [Array<Symbol>]
|
|
15
|
-
AUTO_FALLBACK_CHAIN = Html2rss::FeedPipeline::AutoFallback::CHAIN.freeze
|
|
16
12
|
# Supported CLI strategy plan option values (:auto plus concrete strategies).
|
|
17
13
|
#
|
|
18
14
|
# @return [Array<String>]
|
|
@@ -23,7 +19,7 @@ module Html2rss
|
|
|
23
19
|
# @return [String]
|
|
24
20
|
STRATEGY_OPTION_DESC = [
|
|
25
21
|
'Optional request strategy (defaults to auto; auto tries',
|
|
26
|
-
"#{
|
|
22
|
+
"#{Html2rss::FeedPipeline::AutoFallback::CHAIN.join(' -> ')})"
|
|
27
23
|
].join(' ').freeze
|
|
28
24
|
|
|
29
25
|
# @return [Boolean] whether Thor should terminate process on command failures
|
|
@@ -85,17 +81,61 @@ module Html2rss
|
|
|
85
81
|
method_option :input,
|
|
86
82
|
type: :string,
|
|
87
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
88
|
# @param url [String, nil] source page URL for auto discovery
|
|
89
89
|
# @return [void]
|
|
90
90
|
def auto(url = nil)
|
|
91
91
|
format = options.fetch(:format, 'rss')
|
|
92
92
|
strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
|
|
93
|
+
feed_result = execute_feed { auto_feed_result_for(url, strategy, local_file_path) }
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
explain_status!(feed_result.status) if options[:explain]
|
|
96
|
+
puts(format == 'jsonfeed' ? JSON.pretty_generate(feed_result.to_json_feed) : feed_result.to_rss)
|
|
97
|
+
end
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
|
|
100
|
+
method_option :strategy,
|
|
101
|
+
type: :string,
|
|
102
|
+
desc: STRATEGY_OPTION_DESC,
|
|
103
|
+
enum: STRATEGY_OPTION_ENUM
|
|
104
|
+
method_option :items_selector, type: :string, desc: 'CSS selector hint for items (optional)'
|
|
105
|
+
method_option :max_redirects,
|
|
106
|
+
type: :numeric,
|
|
107
|
+
desc: 'Maximum redirects to follow per request'
|
|
108
|
+
method_option :max_requests,
|
|
109
|
+
type: :numeric,
|
|
110
|
+
desc: 'Maximum requests to allow for this feed build'
|
|
111
|
+
method_option :limit,
|
|
112
|
+
type: :numeric,
|
|
113
|
+
desc: 'Maximum number of articles to keep (default: 25)'
|
|
114
|
+
method_option :input,
|
|
115
|
+
type: :string,
|
|
116
|
+
desc: 'Local HTML file path to read input from'
|
|
117
|
+
method_option :explain,
|
|
118
|
+
type: :boolean,
|
|
119
|
+
desc: 'Print capture quality JSON to stderr (stdout stays YAML)',
|
|
120
|
+
default: false
|
|
121
|
+
##
|
|
122
|
+
# Captures a URL and prints a reusable YAML config.
|
|
123
|
+
#
|
|
124
|
+
# @param url [String, nil] source page URL for capture
|
|
125
|
+
# @return [void]
|
|
126
|
+
def capture(url = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- CLI option wiring
|
|
127
|
+
strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
|
|
128
|
+
result = Html2rss::Capture.build(
|
|
129
|
+
url,
|
|
130
|
+
strategy:,
|
|
131
|
+
items_selector: options[:items_selector],
|
|
132
|
+
limit: options[:limit]&.to_i,
|
|
133
|
+
max_redirects: options[:max_redirects],
|
|
134
|
+
max_requests: options[:max_requests],
|
|
135
|
+
local_file_path:
|
|
136
|
+
)
|
|
137
|
+
explain_capture!(result) if options[:explain]
|
|
138
|
+
puts Config.to_yaml(result.config)
|
|
99
139
|
end
|
|
100
140
|
|
|
101
141
|
desc 'schema', 'Print the exported config JSON Schema'
|
|
@@ -123,6 +163,27 @@ module Html2rss
|
|
|
123
163
|
puts schema_json
|
|
124
164
|
end
|
|
125
165
|
|
|
166
|
+
desc 'mcp', 'Start the MCP server for AI client consumption'
|
|
167
|
+
method_option :transport,
|
|
168
|
+
type: :string,
|
|
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
|
+
#
|
|
179
|
+
# @return [void]
|
|
180
|
+
def mcp
|
|
181
|
+
Html2rss::MCP.start(
|
|
182
|
+
transport: options[:transport].to_sym,
|
|
183
|
+
port: options[:port]
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
126
187
|
desc 'validate YAML_FILE [feed_name]', 'Validate a YAML config with the runtime validator'
|
|
127
188
|
method_option :params,
|
|
128
189
|
type: :hash,
|
|
@@ -183,7 +244,7 @@ module Html2rss
|
|
|
183
244
|
|
|
184
245
|
file_path = check_file_exists!(input_option)
|
|
185
246
|
detected_url = url || detect_base_url!(
|
|
186
|
-
file_path, 'Please specify a URL: html2rss
|
|
247
|
+
file_path, 'Please specify a URL: html2rss <command> [URL] --input <file>'
|
|
187
248
|
)
|
|
188
249
|
|
|
189
250
|
[:local_file, file_path, detected_url]
|
|
@@ -221,23 +282,22 @@ module Html2rss
|
|
|
221
282
|
yield
|
|
222
283
|
rescue Faraday::FollowRedirects::RedirectLimitReached => error
|
|
223
284
|
raise Thor::Error,
|
|
224
|
-
"#{error.message}.
|
|
285
|
+
"#{error.message}. already retried the last redirect hop once; " \
|
|
286
|
+
"retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
|
|
225
287
|
rescue Html2rss::RequestService::RequestBudgetExceeded => error
|
|
226
288
|
raise Thor::Error,
|
|
227
289
|
"#{error.message}. retry with --max-requests #{suggested_max_requests} " \
|
|
228
290
|
'or increase request.max_requests in the config.'
|
|
229
|
-
rescue Html2rss::RequestService::
|
|
230
|
-
Html2rss::RequestService::BrowserlessConnectionFailed,
|
|
231
|
-
Html2rss::RequestService::BotasaurusConfigurationError,
|
|
291
|
+
rescue Html2rss::RequestService::BotasaurusConfigurationError,
|
|
232
292
|
Html2rss::RequestService::BotasaurusConnectionFailed,
|
|
293
|
+
Html2rss::RequestService::BotasaurusServiceError,
|
|
233
294
|
Html2rss::RequestService::BlockedSurfaceDetected,
|
|
234
295
|
Html2rss::NoFeedItemsExtracted => error
|
|
235
296
|
raise Thor::Error, error.message
|
|
236
297
|
end
|
|
237
298
|
|
|
238
|
-
def
|
|
239
|
-
|
|
240
|
-
method.call(
|
|
299
|
+
def auto_feed_result_for(url, strategy, local_file_path)
|
|
300
|
+
Html2rss.auto_feed_result(
|
|
241
301
|
url,
|
|
242
302
|
strategy:,
|
|
243
303
|
items_selector: options[:items_selector],
|
|
@@ -248,6 +308,23 @@ module Html2rss
|
|
|
248
308
|
)
|
|
249
309
|
end
|
|
250
310
|
|
|
311
|
+
def explain_status!(status)
|
|
312
|
+
$stderr.puts JSON.pretty_generate(status.to_h) # rubocop:disable Style/StderrPuts -- CLI explain contract
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def explain_capture!(result)
|
|
316
|
+
$stderr.puts JSON.pretty_generate( # rubocop:disable Style/StderrPuts -- CLI explain contract
|
|
317
|
+
{
|
|
318
|
+
articles_count: result.articles_count,
|
|
319
|
+
channel_title: result.channel_title,
|
|
320
|
+
has_selectors: result.has_selectors,
|
|
321
|
+
segment_strategy: result.segment_strategy,
|
|
322
|
+
selected_strategy: result.selected_strategy,
|
|
323
|
+
admission_drops: result.admission_drops
|
|
324
|
+
}.compact
|
|
325
|
+
)
|
|
326
|
+
end
|
|
327
|
+
|
|
251
328
|
def check_file_exists!(path)
|
|
252
329
|
File.expand_path(path).tap do |file_path|
|
|
253
330
|
raise Thor::Error, "Input file does not exist: #{path}" unless File.exist?(file_path)
|
|
@@ -29,6 +29,9 @@ module Html2rss
|
|
|
29
29
|
optional(:json_state).hash do
|
|
30
30
|
optional(:enabled).filled(:bool)
|
|
31
31
|
end
|
|
32
|
+
optional(:xhr_articles).hash do
|
|
33
|
+
optional(:enabled).filled(:bool)
|
|
34
|
+
end
|
|
32
35
|
optional(:meta_oembed).hash do
|
|
33
36
|
optional(:enabled).filled(:bool)
|
|
34
37
|
end
|
|
@@ -46,7 +49,6 @@ module Html2rss
|
|
|
46
49
|
|
|
47
50
|
optional(:cleanup).hash do
|
|
48
51
|
optional(:keep_different_domain).filled(:bool)
|
|
49
|
-
optional(:min_words_title).filled(:integer, gt?: 0)
|
|
50
52
|
end
|
|
51
53
|
end
|
|
52
54
|
end
|
|
@@ -48,6 +48,7 @@ module Html2rss
|
|
|
48
48
|
##
|
|
49
49
|
# Orchestrates schema assembly from runtime validator contracts plus
|
|
50
50
|
# client-facing overlays.
|
|
51
|
+
# rubocop:disable Metrics/ClassLength -- overlay assembly stays in one builder
|
|
51
52
|
class Builder
|
|
52
53
|
class << self
|
|
53
54
|
# @return [Hash{String => Object}] fully assembled JSON schema hash
|
|
@@ -60,9 +61,10 @@ module Html2rss
|
|
|
60
61
|
def call
|
|
61
62
|
schema = validator_schema
|
|
62
63
|
apply_top_level(schema)
|
|
64
|
+
schema[:$defs] = registry_catalog_defs
|
|
63
65
|
schema.fetch(:properties).merge!(overlay)
|
|
64
66
|
schema.fetch(:properties).delete(:dynamic_params_error)
|
|
65
|
-
|
|
67
|
+
HashUtil.deep_stringify_keys(schema)
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
private
|
|
@@ -77,6 +79,35 @@ module Html2rss
|
|
|
77
79
|
{ 'required' => ['selectors'] },
|
|
78
80
|
{ 'required' => ['auto_source'] }
|
|
79
81
|
]
|
|
82
|
+
|
|
83
|
+
topics = schema.dig(:properties, :directory, :properties, :topics)
|
|
84
|
+
topics[:minItems] = 1 if topics.is_a?(Hash)
|
|
85
|
+
apply_botasaurus_schema!(schema)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def apply_botasaurus_schema!(schema)
|
|
89
|
+
request = schema.dig(:properties, :request, :properties)
|
|
90
|
+
return unless request.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
exported = Html2rss::Config::Validator::BotasaurusRequestExport
|
|
93
|
+
.new.schema.json_schema(loose: true).except(:$schema)
|
|
94
|
+
exported[:additionalProperties] = false
|
|
95
|
+
exported.fetch(:properties).fetch(:window_size)[:additionalProperties] = false
|
|
96
|
+
request[:botasaurus] = exported
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
|
|
100
|
+
def registry_catalog_defs
|
|
101
|
+
{
|
|
102
|
+
post_processors: catalog_from_registry(Selectors::PostProcessors::NAME_TO_CLASS),
|
|
103
|
+
extractors: catalog_from_registry(Selectors::Extractors::NAME_TO_CLASS)
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @param registry [Hash{Symbol => Class}]
|
|
108
|
+
# @return [Hash{String => Hash}]
|
|
109
|
+
def catalog_from_registry(registry)
|
|
110
|
+
registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
|
|
80
111
|
end
|
|
81
112
|
|
|
82
113
|
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
|
|
@@ -105,6 +136,13 @@ module Html2rss
|
|
|
105
136
|
]
|
|
106
137
|
}
|
|
107
138
|
|
|
139
|
+
selector_schema = Schema.apply_selector_registry_refs!(
|
|
140
|
+
Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true)
|
|
141
|
+
)
|
|
142
|
+
enclosure_schema = Schema.apply_selector_registry_refs!(
|
|
143
|
+
Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true)
|
|
144
|
+
)
|
|
145
|
+
|
|
108
146
|
{
|
|
109
147
|
strategy: {
|
|
110
148
|
type: 'string',
|
|
@@ -121,7 +159,7 @@ module Html2rss
|
|
|
121
159
|
items: Html2rss::Config::Validator::StylesheetConfig.json_schema(loose: true)
|
|
122
160
|
},
|
|
123
161
|
auto_source: Html2rss::Config::AutoSourceContract.json_schema(loose: true).merge(
|
|
124
|
-
default:
|
|
162
|
+
default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG)
|
|
125
163
|
),
|
|
126
164
|
selectors: {
|
|
127
165
|
type: 'object',
|
|
@@ -130,14 +168,14 @@ module Html2rss
|
|
|
130
168
|
items: items_schema.merge(
|
|
131
169
|
description: 'Defines the items selector and optional enhancement settings.'
|
|
132
170
|
),
|
|
133
|
-
enclosure:
|
|
171
|
+
enclosure: enclosure_schema.merge(
|
|
134
172
|
description: 'Describes enclosure extraction settings.'
|
|
135
173
|
),
|
|
136
174
|
guid: reference_array('List of selector keys used to build the GUID. Each entry must reference a sibling selector key; runtime validation enforces those references.'),
|
|
137
175
|
categories: reference_array('List of selector keys whose values will be used as categories. Each entry must reference a sibling selector key; runtime validation enforces those references.')
|
|
138
176
|
},
|
|
139
177
|
patternProperties: {
|
|
140
|
-
'^(?!items$|enclosure$|guid$|categories$).+$' =>
|
|
178
|
+
'^(?!items$|enclosure$|guid$|categories$).+$' => selector_schema.merge(
|
|
141
179
|
description: 'Dynamic selector definition keyed by attribute name.'
|
|
142
180
|
)
|
|
143
181
|
},
|
|
@@ -159,31 +197,46 @@ module Html2rss
|
|
|
159
197
|
}
|
|
160
198
|
end
|
|
161
199
|
end
|
|
200
|
+
# rubocop:enable Metrics/ClassLength
|
|
162
201
|
|
|
163
202
|
##
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
203
|
+
# Wires extractor / post_process to thin oneOf $refs into the registry catalog.
|
|
204
|
+
#
|
|
205
|
+
# @param schema [Hash] dry-schema JSON schema fragment for a selector
|
|
206
|
+
# @return [Hash] same schema with registry $ref wiring applied
|
|
207
|
+
def apply_selector_registry_refs!(schema)
|
|
208
|
+
properties = schema.fetch(:properties)
|
|
209
|
+
properties[:extractor] = extractor_wire_schema
|
|
210
|
+
properties[:post_process] = post_process_wire_schema
|
|
211
|
+
schema
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# @return [Hash{Symbol => Object}]
|
|
215
|
+
def extractor_wire_schema
|
|
216
|
+
{
|
|
217
|
+
description: 'Extractor used to pull a value from the selected element.',
|
|
218
|
+
oneOf: registry_ref_list('extractors', Selectors::Extractors::NAME_TO_CLASS)
|
|
219
|
+
}
|
|
220
|
+
end
|
|
182
221
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
222
|
+
# @return [Hash{Symbol => Object}]
|
|
223
|
+
def post_process_wire_schema
|
|
224
|
+
{
|
|
225
|
+
type: 'array',
|
|
226
|
+
description: 'Ordered transforms applied to the extracted value.',
|
|
227
|
+
items: {
|
|
228
|
+
oneOf: registry_ref_list('post_processors', Selectors::PostProcessors::NAME_TO_CLASS)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
##
|
|
234
|
+
# @param catalog [String] $defs catalog name (`extractors` or `post_processors`)
|
|
235
|
+
# @param registry [Hash{Symbol => Class}]
|
|
236
|
+
# @return [Array<Hash>] oneOf entries of `{ '$ref' => ... }`
|
|
237
|
+
def registry_ref_list(catalog, registry)
|
|
238
|
+
registry.keys.sort.map do |name|
|
|
239
|
+
{ '$ref' => "#/$defs/#{catalog}/#{name}" }
|
|
187
240
|
end
|
|
188
241
|
end
|
|
189
242
|
end
|