html2rss 0.26.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 +41 -18
- data/html2rss.gemspec +1 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +121 -56
- data/lib/html2rss/auto_source/scraper.rb +13 -0
- data/lib/html2rss/auto_source.rb +34 -8
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +120 -117
- data/lib/html2rss/cli.rb +35 -17
- data/lib/html2rss/config/schema.rb +12 -0
- data/lib/html2rss/config/validator.rb +31 -8
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +24 -6
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +30 -12
- 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 +119 -32
- data/lib/html2rss/link_destination/path_classifier.rb +49 -35
- 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 +253 -409
- data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
- data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +57 -6
- data/lib/html2rss/request_service.rb +6 -1
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +20 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +30 -6
- data/schema/html2rss-config.schema.json +26 -15
- metadata +15 -4
data/lib/html2rss/capture.rb
CHANGED
|
@@ -2,20 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
##
|
|
5
|
-
# Analyzes a URL and produces a
|
|
5
|
+
# Analyzes a URL and produces a durable feed config: items selector + +enhance: true+.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
|
|
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
|
|
10
12
|
LEADING_TRIM_TAGS = %w[html body].freeze
|
|
11
13
|
private_constant :LEADING_TRIM_TAGS
|
|
12
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
|
+
|
|
13
21
|
##
|
|
14
|
-
# Result of a capture operation.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
)
|
|
19
27
|
|
|
20
28
|
class << self
|
|
21
29
|
##
|
|
@@ -57,31 +65,47 @@ module Html2rss
|
|
|
57
65
|
# Runs the capture pipeline.
|
|
58
66
|
#
|
|
59
67
|
# @return [CaptureResult]
|
|
60
|
-
def build # rubocop:disable Metrics/MethodLength
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
selectors = derive_selectors(response, articles)
|
|
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)
|
|
64
71
|
|
|
65
72
|
config = {
|
|
66
|
-
channel: build_channel(response),
|
|
73
|
+
channel: build_channel(outcome.response),
|
|
67
74
|
selectors: selectors.empty? ? nil : selectors,
|
|
75
|
+
**strategy_stamp(outcome),
|
|
68
76
|
**local_file_request_overlay
|
|
69
77
|
}.compact
|
|
70
78
|
|
|
71
79
|
CaptureResult.new(
|
|
72
80
|
config:,
|
|
73
|
-
articles_count: articles.size,
|
|
74
|
-
channel_title: channel_title_from(response)
|
|
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
|
|
75
87
|
)
|
|
76
88
|
end
|
|
77
89
|
|
|
78
90
|
private
|
|
79
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
|
+
|
|
80
104
|
def raw_config
|
|
81
105
|
@raw_config ||= build_raw_config
|
|
82
106
|
end
|
|
83
107
|
|
|
84
|
-
def build_raw_config
|
|
108
|
+
def build_raw_config
|
|
85
109
|
Config.auto_source_config(
|
|
86
110
|
url: @url,
|
|
87
111
|
items_selector: @items_selector_hint,
|
|
@@ -91,10 +115,7 @@ module Html2rss
|
|
|
91
115
|
max_requests: @max_requests
|
|
92
116
|
),
|
|
93
117
|
limit: @limit
|
|
94
|
-
).tap
|
|
95
|
-
config[:strategy] = resolve_strategy(config[:strategy] || @strategy)
|
|
96
|
-
apply_local_file_path!(config)
|
|
97
|
-
end
|
|
118
|
+
).tap { |config| apply_local_file_path!(config) }
|
|
98
119
|
end
|
|
99
120
|
|
|
100
121
|
def apply_local_file_path!(config)
|
|
@@ -111,62 +132,39 @@ module Html2rss
|
|
|
111
132
|
{ strategy: :local_file, request: { local_file_path: @local_file_path } }
|
|
112
133
|
end
|
|
113
134
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def fetch_response
|
|
120
|
-
config = Config.from_hash(raw_config)
|
|
121
|
-
resources = FeedPipeline::RuntimePolicy.resources_for(config)
|
|
122
|
-
session = RequestSession.build(
|
|
123
|
-
config:,
|
|
124
|
-
strategy: config.strategy,
|
|
125
|
-
budget: resources.budget,
|
|
126
|
-
policy: resources.policy
|
|
127
|
-
)
|
|
128
|
-
session.fetch_initial_response
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def extract_articles(response)
|
|
132
|
-
auto_source_opts = raw_config[:auto_source] || AutoSource::DEFAULT_CONFIG
|
|
133
|
-
AutoSource.new(response, auto_source_opts).articles
|
|
134
|
-
rescue AutoSource::Scraper::NoScraperFound
|
|
135
|
-
[]
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def derive_selectors(response, articles) # rubocop:disable Metrics/MethodLength
|
|
139
|
-
return {} if articles.empty? || !response.html_response?
|
|
140
|
-
|
|
141
|
-
sst = normalize_sst(response)
|
|
142
|
-
return {} unless sst
|
|
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?
|
|
143
139
|
|
|
144
|
-
|
|
145
|
-
return {}
|
|
140
|
+
sst = SST::Normalizer.call(response.body)
|
|
141
|
+
return [{}, nil] unless sst
|
|
146
142
|
|
|
147
|
-
|
|
148
|
-
return {} if matched.empty?
|
|
149
|
-
|
|
150
|
-
build_selector_hash(matched, sst)
|
|
143
|
+
select_enhance_selectors(sst, articles)
|
|
151
144
|
rescue ArgumentError => error
|
|
152
145
|
Log.warn("Capture selector derivation failed: #{error.message}")
|
|
153
|
-
{}
|
|
146
|
+
[{}, nil]
|
|
154
147
|
end
|
|
155
148
|
|
|
156
|
-
def
|
|
157
|
-
|
|
149
|
+
def hint_selectors
|
|
150
|
+
[{ items: { selector: @items_selector_hint, enhance: true } }, :hint]
|
|
158
151
|
end
|
|
159
152
|
|
|
160
|
-
def
|
|
153
|
+
def select_enhance_selectors(sst, articles) # rubocop:disable Metrics/MethodLength -- strategy loop + gate
|
|
161
154
|
link_resolver = Scoring::LinkResolver.new(@url)
|
|
162
155
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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]
|
|
170
168
|
end
|
|
171
169
|
|
|
172
170
|
def match_segments_to_articles(segments, articles)
|
|
@@ -201,24 +199,10 @@ module Html2rss
|
|
|
201
199
|
segment.root_node.visible_text.to_s.strip
|
|
202
200
|
end
|
|
203
201
|
|
|
204
|
-
def build_selector_hash(matched, _sst)
|
|
205
|
-
items_sel = items_selector(matched)
|
|
206
|
-
return {} unless items_sel
|
|
207
|
-
|
|
208
|
-
first = matched.first
|
|
209
|
-
root = first[:segment].root_node
|
|
210
|
-
|
|
211
|
-
attrs = {}.tap do |a|
|
|
212
|
-
a[:title] = title_selector(root)
|
|
213
|
-
a[:url] = url_selector(first[:segment])
|
|
214
|
-
a[:description] = description_selector(root, first[:segment])
|
|
215
|
-
end.compact
|
|
216
|
-
|
|
217
|
-
{ items: { selector: items_sel }, **attrs }
|
|
218
|
-
end
|
|
219
|
-
|
|
220
202
|
def items_selector(matched)
|
|
221
|
-
|
|
203
|
+
return nil if matched.empty?
|
|
204
|
+
|
|
205
|
+
roots = lift_heading_link_roots(matched.map { |m| m[:segment].root_node })
|
|
222
206
|
shared = shared_class_items_selector(roots)
|
|
223
207
|
return shared if shared
|
|
224
208
|
|
|
@@ -230,6 +214,58 @@ module Html2rss
|
|
|
230
214
|
css_from_trimmed_tag_path(tag_path)
|
|
231
215
|
end
|
|
232
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
|
+
|
|
233
269
|
def shared_class_items_selector(roots)
|
|
234
270
|
shared = roots.map { |root| root.attrs.class_names }.reduce { |left, right| left & right }
|
|
235
271
|
return nil if shared.nil? || shared.empty?
|
|
@@ -265,39 +301,6 @@ module Html2rss
|
|
|
265
301
|
"/#{prefix.join('/')}"
|
|
266
302
|
end
|
|
267
303
|
|
|
268
|
-
def title_selector(root) # rubocop:disable Metrics/CyclomaticComplexity
|
|
269
|
-
heading = root.find(&:heading?)
|
|
270
|
-
relative = css_for_path(heading.tag_path, root.tag_path) if heading
|
|
271
|
-
relative ||= begin
|
|
272
|
-
link = root.find { |n| n.link? && n.visible_text.to_s.strip.length > 3 }
|
|
273
|
-
css_for_path(link.tag_path, root.tag_path) if link
|
|
274
|
-
end
|
|
275
|
-
return nil unless relative
|
|
276
|
-
|
|
277
|
-
{ selector: relative }
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def url_selector(segment)
|
|
281
|
-
return nil unless segment.primary_link
|
|
282
|
-
|
|
283
|
-
relative = css_for_path(segment.primary_link.tag_path, segment.root_node.tag_path)
|
|
284
|
-
{ selector: relative, extractor: 'href' }
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def description_selector(root, segment)
|
|
288
|
-
relative = css_for_path(root.tag_path, segment.root_node.tag_path)
|
|
289
|
-
return nil if relative.nil? || relative.empty? || relative == '.'
|
|
290
|
-
|
|
291
|
-
{ selector: relative }
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
def css_for_path(full_path, root_path)
|
|
295
|
-
relative = full_path.delete_prefix(root_path)
|
|
296
|
-
return '.' if relative.empty?
|
|
297
|
-
|
|
298
|
-
relative.split('/').reject(&:empty?).join(' > ')
|
|
299
|
-
end
|
|
300
|
-
|
|
301
304
|
def build_channel(response)
|
|
302
305
|
{
|
|
303
306
|
url: @url,
|
data/lib/html2rss/cli.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'json'
|
|
5
|
-
require 'yaml'
|
|
6
5
|
require 'thor'
|
|
7
6
|
|
|
8
7
|
module Html2rss
|
|
@@ -10,10 +9,6 @@ module Html2rss
|
|
|
10
9
|
# The Html2rss command line interface.
|
|
11
10
|
class CLI < Thor # rubocop:disable Metrics/ClassLength
|
|
12
11
|
check_unknown_options!
|
|
13
|
-
# Ordered fallback chain attempted by the feed-level :auto plan.
|
|
14
|
-
#
|
|
15
|
-
# @return [Array<Symbol>]
|
|
16
|
-
AUTO_FALLBACK_CHAIN = Html2rss::FeedPipeline::AutoFallback::CHAIN.freeze
|
|
17
12
|
# Supported CLI strategy plan option values (:auto plus concrete strategies).
|
|
18
13
|
#
|
|
19
14
|
# @return [Array<String>]
|
|
@@ -24,7 +19,7 @@ module Html2rss
|
|
|
24
19
|
# @return [String]
|
|
25
20
|
STRATEGY_OPTION_DESC = [
|
|
26
21
|
'Optional request strategy (defaults to auto; auto tries',
|
|
27
|
-
"#{
|
|
22
|
+
"#{Html2rss::FeedPipeline::AutoFallback::CHAIN.join(' -> ')})"
|
|
28
23
|
].join(' ').freeze
|
|
29
24
|
|
|
30
25
|
# @return [Boolean] whether Thor should terminate process on command failures
|
|
@@ -86,17 +81,19 @@ module Html2rss
|
|
|
86
81
|
method_option :input,
|
|
87
82
|
type: :string,
|
|
88
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
|
|
89
88
|
# @param url [String, nil] source page URL for auto discovery
|
|
90
89
|
# @return [void]
|
|
91
90
|
def auto(url = nil)
|
|
92
91
|
format = options.fetch(:format, 'rss')
|
|
93
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) }
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
|
|
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)
|
|
100
97
|
end
|
|
101
98
|
|
|
102
99
|
desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
|
|
@@ -117,14 +114,18 @@ module Html2rss
|
|
|
117
114
|
method_option :input,
|
|
118
115
|
type: :string,
|
|
119
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
|
|
120
121
|
##
|
|
121
122
|
# Captures a URL and prints a reusable YAML config.
|
|
122
123
|
#
|
|
123
124
|
# @param url [String, nil] source page URL for capture
|
|
124
125
|
# @return [void]
|
|
125
|
-
def capture(url = nil) # rubocop:disable Metrics/MethodLength
|
|
126
|
+
def capture(url = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- CLI option wiring
|
|
126
127
|
strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
|
|
127
|
-
|
|
128
|
+
result = Html2rss::Capture.build(
|
|
128
129
|
url,
|
|
129
130
|
strategy:,
|
|
130
131
|
items_selector: options[:items_selector],
|
|
@@ -133,7 +134,8 @@ module Html2rss
|
|
|
133
134
|
max_requests: options[:max_requests],
|
|
134
135
|
local_file_path:
|
|
135
136
|
)
|
|
136
|
-
|
|
137
|
+
explain_capture!(result) if options[:explain]
|
|
138
|
+
puts Config.to_yaml(result.config)
|
|
137
139
|
end
|
|
138
140
|
|
|
139
141
|
desc 'schema', 'Print the exported config JSON Schema'
|
|
@@ -294,9 +296,8 @@ module Html2rss
|
|
|
294
296
|
raise Thor::Error, error.message
|
|
295
297
|
end
|
|
296
298
|
|
|
297
|
-
def
|
|
298
|
-
|
|
299
|
-
method.call(
|
|
299
|
+
def auto_feed_result_for(url, strategy, local_file_path)
|
|
300
|
+
Html2rss.auto_feed_result(
|
|
300
301
|
url,
|
|
301
302
|
strategy:,
|
|
302
303
|
items_selector: options[:items_selector],
|
|
@@ -307,6 +308,23 @@ module Html2rss
|
|
|
307
308
|
)
|
|
308
309
|
end
|
|
309
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
|
+
|
|
310
328
|
def check_file_exists!(path)
|
|
311
329
|
File.expand_path(path).tap do |file_path|
|
|
312
330
|
raise Thor::Error, "Input file does not exist: #{path}" unless File.exist?(file_path)
|
|
@@ -82,6 +82,18 @@ module Html2rss
|
|
|
82
82
|
|
|
83
83
|
topics = schema.dig(:properties, :directory, :properties, :topics)
|
|
84
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
|
|
85
97
|
end
|
|
86
98
|
|
|
87
99
|
# @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
|
|
@@ -45,17 +45,20 @@ module Html2rss
|
|
|
45
45
|
optional(:media).maybe(:string)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Contract for Botasaurus-specific request options.
|
|
48
|
+
# Contract for Botasaurus-specific request options (OpenAPI ScrapeRequest minus url).
|
|
49
49
|
BotasaurusRequestConfig = Dry::Schema.Params do
|
|
50
50
|
config.validate_keys = true
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
optional(:
|
|
52
|
+
botasaurus = Html2rss::RequestService::BotasaurusContract
|
|
53
|
+
|
|
54
|
+
optional(:execution_mode).filled(:string, included_in?: botasaurus::EXECUTION_MODES)
|
|
55
|
+
optional(:navigation_mode).filled(:string, included_in?: botasaurus::NAVIGATION_MODES)
|
|
56
|
+
optional(:max_retries).filled(:integer, gteq?: 0, lteq?: botasaurus::MAX_RETRIES)
|
|
55
57
|
optional(:wait_for_selector).maybe(:string)
|
|
56
|
-
optional(:wait_timeout_seconds).filled(
|
|
58
|
+
optional(:wait_timeout_seconds).filled(
|
|
59
|
+
:integer, gteq?: botasaurus::MIN_WAIT_TIMEOUT_SECONDS, lteq?: botasaurus::MAX_WAIT_TIMEOUT_SECONDS
|
|
60
|
+
)
|
|
57
61
|
optional(:scroll).filled(:bool)
|
|
58
|
-
optional(:scroll_to_bottom).filled(:bool)
|
|
59
62
|
optional(:block_images).filled(:bool)
|
|
60
63
|
optional(:block_images_and_css).filled(:bool)
|
|
61
64
|
optional(:block_trackers).filled(:bool)
|
|
@@ -63,18 +66,27 @@ module Html2rss
|
|
|
63
66
|
optional(:headless).filled(:bool)
|
|
64
67
|
optional(:proxy).filled(:string)
|
|
65
68
|
optional(:user_agent).filled(:string)
|
|
66
|
-
optional(:window_size).
|
|
69
|
+
optional(:window_size).hash do
|
|
70
|
+
botasaurus::WINDOW_SIZE_PROPERTIES.each do |key|
|
|
71
|
+
required(key).filled(:integer, gt?: 0)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
67
74
|
optional(:lang).filled(:string)
|
|
68
75
|
optional(:cookies).hash
|
|
69
76
|
optional(:headers).hash
|
|
70
77
|
end
|
|
71
78
|
|
|
79
|
+
# JSON Schema export adapter for +BotasaurusRequestConfig+ (params schemas have no json_schema).
|
|
80
|
+
class BotasaurusRequestExport < Dry::Validation::Contract
|
|
81
|
+
params(BotasaurusRequestConfig)
|
|
82
|
+
end
|
|
83
|
+
|
|
72
84
|
# Contract for the top-level `request` section.
|
|
73
85
|
RequestConfig = Dry::Schema.Params do
|
|
74
86
|
optional(:max_redirects).filled(:integer, gteq?: 0)
|
|
75
87
|
optional(:max_requests).filled(:integer, gt?: 0)
|
|
76
88
|
optional(:total_timeout_seconds).filled(:integer, gt?: 0)
|
|
77
|
-
optional(:botasaurus).hash
|
|
89
|
+
optional(:botasaurus).hash
|
|
78
90
|
optional(:local_file_path).filled(:string)
|
|
79
91
|
end
|
|
80
92
|
|
|
@@ -123,6 +135,17 @@ module Html2rss
|
|
|
123
135
|
errors.each { |error| key(:selectors).failure(error.text) } unless errors.empty?
|
|
124
136
|
end
|
|
125
137
|
|
|
138
|
+
rule(request: :botasaurus) do
|
|
139
|
+
next unless value
|
|
140
|
+
|
|
141
|
+
result = BotasaurusRequestConfig.call(value)
|
|
142
|
+
next if result.success?
|
|
143
|
+
|
|
144
|
+
result.errors.each do |error|
|
|
145
|
+
key([:request, :botasaurus, *error.path]).failure(error.text)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
126
149
|
# URL validation delegated to Url class
|
|
127
150
|
rule(:channel) do
|
|
128
151
|
if (url_string = values.dig(:channel, :url)) && !url_string.empty?
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -71,6 +71,34 @@ module Html2rss
|
|
|
71
71
|
validate(load_yaml(file, feed_name, multiple_feeds_key:), params:)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
##
|
|
75
|
+
# Serializes a configuration hash to string-key YAML.
|
|
76
|
+
#
|
|
77
|
+
# This is the single serializer for CLI capture and MCP +capture_config+.
|
|
78
|
+
#
|
|
79
|
+
# @param hash [Hash] configuration hash (symbol or string keys)
|
|
80
|
+
# @return [String] YAML document without Ruby symbol-key prefixes
|
|
81
|
+
def to_yaml(hash)
|
|
82
|
+
YAML.dump(HashUtil.deep_stringify_keys(hash))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
# Parses a YAML configuration string into a symbol-keyed hash.
|
|
87
|
+
#
|
|
88
|
+
# Does not validate. Call {validate} or {from_hash} after this.
|
|
89
|
+
#
|
|
90
|
+
# @param string [String] YAML document
|
|
91
|
+
# @return [Hash{Symbol => Object}] configuration hash
|
|
92
|
+
# @raise [ArgumentError] if +string+ is not a String or does not deserialize to a Hash
|
|
93
|
+
def from_yaml(string)
|
|
94
|
+
raise ArgumentError, 'YAML must be a String' unless string.is_a?(String)
|
|
95
|
+
|
|
96
|
+
parsed = YAML.safe_load(string)
|
|
97
|
+
raise ArgumentError, 'YAML must deserialize to a Hash' unless parsed.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
HashUtil.deep_symbolize_keys(parsed, context: 'config')
|
|
100
|
+
end
|
|
101
|
+
|
|
74
102
|
##
|
|
75
103
|
# Loads the feed configuration from a YAML file.
|
|
76
104
|
#
|
data/lib/html2rss/error.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Html2rss
|
|
|
7
7
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
8
8
|
class NoFeedItemsExtracted < Error
|
|
9
9
|
# Categories that append shared surface guidance to the empty-feed message.
|
|
10
|
-
SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface].freeze
|
|
10
|
+
SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface high_entropy_surface].freeze
|
|
11
11
|
|
|
12
12
|
##
|
|
13
13
|
# @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
|
|
@@ -27,17 +27,35 @@ module Html2rss
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
29
|
def build_message
|
|
30
|
+
[base_message, surface_guidance, botasaurus_guidance].compact.join(' ')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def base_message
|
|
30
34
|
summaries = attempts.map do |attempt|
|
|
31
35
|
details = attempt[:items_count].nil? ? "#{attempt[:error_class]} error" : "#{attempt[:items_count]} items"
|
|
32
36
|
"#{attempt[:strategy]} (#{details})"
|
|
33
37
|
end.join(', ')
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
"No feed items extracted after auto fallback across strategies: #{summaries}. " \
|
|
40
|
+
'Try a more specific listing URL or provide explicit selectors.'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def surface_guidance
|
|
44
|
+
return unless SURFACE_HINT_CATEGORIES.include?(surface_category)
|
|
45
|
+
|
|
46
|
+
AutoSource::Scraper::NoScraperFound::CATEGORY_MESSAGES.fetch(surface_category)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def botasaurus_guidance
|
|
50
|
+
return unless botasaurus_configuration_error_attempt?
|
|
51
|
+
return if surface_guidance&.include?('BOTASAURUS_SCRAPER_URL')
|
|
52
|
+
|
|
53
|
+
RequestService::BotasaurusConfigurationError::EMPTY_FEED_HINT
|
|
54
|
+
end
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
def botasaurus_configuration_error_attempt?
|
|
57
|
+
error_name = RequestService::BotasaurusConfigurationError.name
|
|
58
|
+
attempts.any? { |attempt| attempt[:error_class] == error_name }
|
|
41
59
|
end
|
|
42
60
|
end
|
|
43
61
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# FeedPipeline — `auto` request strategy
|
|
2
|
+
|
|
3
|
+
`:auto` is the default request plan for feed builds (`auto_source`, `auto_json_feed`, Capture, and MCP `scrape_url` / `capture_config`). `FeedPipeline::StrategyPlan` resolves it; `FeedPipeline::AutoFallback` executes `AutoFallback::CHAIN`.
|
|
4
|
+
|
|
5
|
+
Use `:auto` when you want Faraday first and a browser-backed hop only if that fetch fails or yields zero items. Pin a concrete strategy (`faraday`, `botasaurus`, `local_file`) when you need a single transport.
|
|
6
|
+
|
|
7
|
+
## Chain
|
|
8
|
+
|
|
9
|
+
`AutoFallback::CHAIN` is:
|
|
10
|
+
|
|
11
|
+
1. **Faraday** — plain HTTP (faster, cheaper).
|
|
12
|
+
2. **Botasaurus** — attempted when Faraday raises a fallback-eligible error (for example `BlockedSurfaceDetected` or timeout) or extracts zero feed items.
|
|
13
|
+
|
|
14
|
+
There is no Browserless / Puppeteer-in-gem tier. Pin `botasaurus` when you want browser rendering without Faraday first. Botasaurus needs `BOTASAURUS_SCRAPER_URL`.
|
|
15
|
+
|
|
16
|
+
## Surfaces
|
|
17
|
+
|
|
18
|
+
| Surface | `:auto` behavior |
|
|
19
|
+
|---------|------------------|
|
|
20
|
+
| Gem / CLI feed build, MCP `scrape_url`, Capture | Full AutoFallback chain (`faraday` → `botasaurus`) |
|
|
21
|
+
| MCP `inspect_url` | Cheap diagnostic: `StrategyPlan.concrete_for_diagnostic` maps `auto` to Faraday (pin `botasaurus` when you need browser rendering) |
|
|
22
|
+
|
|
23
|
+
## Fallback vs abort
|
|
24
|
+
|
|
25
|
+
These typically hop to the next chain member (among other `StandardError`s `AutoFallback` rescues):
|
|
26
|
+
|
|
27
|
+
- `Html2rss::RequestService::BlockedSurfaceDetected`
|
|
28
|
+
- `Html2rss::RequestService::RequestTimedOut`
|
|
29
|
+
- Faraday connection / timeout errors
|
|
30
|
+
- Empty extraction results when a later chain member may succeed
|
|
31
|
+
|
|
32
|
+
These abort immediately (`AutoFallback::NON_FALLBACK_ERRORS`): unknown strategy, invalid URL, unsupported scheme, budget exceeded, private network denied, cross-origin follow-up denied, response too large.
|
|
33
|
+
|
|
34
|
+
Retries share the feed's request/session policy. Pin a concrete strategy when you need a single-hop budget profile.
|
|
35
|
+
|
|
36
|
+
## Success signals
|
|
37
|
+
|
|
38
|
+
- `Html2rss::RequestService::Response` includes transport metadata for the strategy that produced it.
|
|
39
|
+
- `Html2rss::Status` records selected strategy and attempt tallies for MCP envelope payload / CLI `--explain`.
|
|
40
|
+
- Fallback hops log at info/warn (`AutoFallback`).
|
|
41
|
+
|
|
42
|
+
See also {Html2rss::AutoSource} for article scraping (a different pipeline) and {Html2rss::Capture} for durable configs that stamp the selected strategy.
|