html2rss 0.25.0 → 0.26.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 +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- 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 +34 -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 +7 -2
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +64 -5
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +18 -27
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +10 -3
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/sst_article_extractor.rb +36 -7
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +3 -0
- data/lib/html2rss/mcp/server.rb +577 -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 +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- 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/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -1
- data/schema/html2rss-config.schema.json +408 -85
- metadata +54 -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
|
@@ -44,7 +44,7 @@ module Html2rss
|
|
|
44
44
|
@minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
|
|
45
45
|
@use_top_selectors = opts.fetch(:use_top_selectors, 5)
|
|
46
46
|
@link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
|
|
47
|
-
@noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver
|
|
47
|
+
@noise_policy = LinkDestination::NoisePolicy.new(link_resolver: @link_resolver)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# @return [Array<Segment>]
|
|
@@ -61,6 +61,35 @@ module Html2rss
|
|
|
61
61
|
|
|
62
62
|
# @return [SST::Index]
|
|
63
63
|
def index = document.index
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# Whether +anchor+ has a utility-landmark ancestor outside +container+.
|
|
67
|
+
#
|
|
68
|
+
# @param anchor [SST::Node]
|
|
69
|
+
# @param container [SST::Node]
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def landmark_ancestor?(anchor, container)
|
|
72
|
+
cache = (@landmark_cache ||= {})
|
|
73
|
+
key = [anchor.object_id, container.object_id]
|
|
74
|
+
return cache[key] if cache.key?(key)
|
|
75
|
+
|
|
76
|
+
cache[key] = compute_landmark_ancestor?(anchor:, container:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def compute_landmark_ancestor?(anchor:, container:) # rubocop:disable Metrics/CyclomaticComplexity
|
|
82
|
+
return false unless anchor && container
|
|
83
|
+
|
|
84
|
+
curr = index.parent_of(anchor)
|
|
85
|
+
while curr && curr.name != :html
|
|
86
|
+
return true if curr != container && curr.utility_landmark?
|
|
87
|
+
return false if curr.equal?(container)
|
|
88
|
+
|
|
89
|
+
curr = index.parent_of(curr)
|
|
90
|
+
end
|
|
91
|
+
false
|
|
92
|
+
end
|
|
64
93
|
end
|
|
65
94
|
end
|
|
66
95
|
end
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -44,6 +44,9 @@ module Html2rss
|
|
|
44
44
|
json_state: {
|
|
45
45
|
enabled: true
|
|
46
46
|
},
|
|
47
|
+
xhr_articles: {
|
|
48
|
+
enabled: true
|
|
49
|
+
},
|
|
47
50
|
meta_oembed: {
|
|
48
51
|
enabled: true
|
|
49
52
|
},
|
|
@@ -93,6 +96,7 @@ module Html2rss
|
|
|
93
96
|
@parsed_body = response.parsed_body
|
|
94
97
|
@body = response.body
|
|
95
98
|
@url = response.url
|
|
99
|
+
@captured_responses = response.captured_responses
|
|
96
100
|
@opts = opts
|
|
97
101
|
@request_session = request_session
|
|
98
102
|
end
|
|
@@ -114,7 +118,7 @@ module Html2rss
|
|
|
114
118
|
|
|
115
119
|
private
|
|
116
120
|
|
|
117
|
-
attr_reader :url, :parsed_body, :body, :request_session
|
|
121
|
+
attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
|
|
118
122
|
|
|
119
123
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
120
124
|
def extract_articles
|
|
@@ -143,7 +147,8 @@ module Html2rss
|
|
|
143
147
|
request_session:,
|
|
144
148
|
body:,
|
|
145
149
|
document:,
|
|
146
|
-
link_resolver
|
|
150
|
+
link_resolver:,
|
|
151
|
+
captured_responses:
|
|
147
152
|
)
|
|
148
153
|
next unless instance
|
|
149
154
|
next unless Scraper.extractable_instance?(instance, parsed_body)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Analyzes a URL and produces a reusable feed config hash with derived CSS selectors.
|
|
6
|
+
#
|
|
7
|
+
# Uses the auto-source pipeline to extract articles, then traces back through
|
|
8
|
+
# SST segments to build items + attribute selectors suitable for a static feed config.
|
|
9
|
+
class Capture # rubocop:disable Metrics/ClassLength
|
|
10
|
+
LEADING_TRIM_TAGS = %w[html body].freeze
|
|
11
|
+
private_constant :LEADING_TRIM_TAGS
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# Result of a capture operation.
|
|
15
|
+
# @!attribute config [Hash] feed config hash with +:channel+ and +:selectors+
|
|
16
|
+
# @!attribute articles_count [Integer] number of articles extracted
|
|
17
|
+
# @!attribute channel_title [String] derived channel title
|
|
18
|
+
CaptureResult = Data.define(:config, :articles_count, :channel_title)
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
##
|
|
22
|
+
# Analyzes a URL and builds a reusable feed config.
|
|
23
|
+
#
|
|
24
|
+
# @param url [String] source page URL
|
|
25
|
+
# @param strategy [Symbol] request strategy (+:auto+, +:faraday+, +:botasaurus+)
|
|
26
|
+
# @option options [String, nil] :items_selector optional selector hint
|
|
27
|
+
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
28
|
+
# @option options [Integer, nil] :max_requests optional request budget override
|
|
29
|
+
# @option options [Integer, nil] :limit max articles to keep
|
|
30
|
+
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
31
|
+
# @return [CaptureResult]
|
|
32
|
+
def build(url, strategy: :auto, **)
|
|
33
|
+
new(url, strategy:, **).build
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# @param url [String] source page URL
|
|
39
|
+
# @param strategy [Symbol] request strategy
|
|
40
|
+
# @param options [Hash] additional options
|
|
41
|
+
# @option options [String, nil] :items_selector optional selector hint
|
|
42
|
+
# @option options [Integer, nil] :max_redirects optional redirect limit override
|
|
43
|
+
# @option options [Integer, nil] :max_requests optional request budget override
|
|
44
|
+
# @option options [Integer, nil] :limit max articles to keep
|
|
45
|
+
# @option options [String, nil] :local_file_path optional local HTML file path
|
|
46
|
+
def initialize(url, strategy: :auto, **options)
|
|
47
|
+
@url = url
|
|
48
|
+
@strategy = strategy
|
|
49
|
+
@items_selector_hint = options.delete(:items_selector)
|
|
50
|
+
@max_redirects = options.delete(:max_redirects)
|
|
51
|
+
@max_requests = options.delete(:max_requests)
|
|
52
|
+
@limit = options.delete(:limit)
|
|
53
|
+
@local_file_path = options.delete(:local_file_path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Runs the capture pipeline.
|
|
58
|
+
#
|
|
59
|
+
# @return [CaptureResult]
|
|
60
|
+
def build # rubocop:disable Metrics/MethodLength
|
|
61
|
+
response = fetch_response
|
|
62
|
+
articles = extract_articles(response)
|
|
63
|
+
selectors = derive_selectors(response, articles)
|
|
64
|
+
|
|
65
|
+
config = {
|
|
66
|
+
channel: build_channel(response),
|
|
67
|
+
selectors: selectors.empty? ? nil : selectors,
|
|
68
|
+
**local_file_request_overlay
|
|
69
|
+
}.compact
|
|
70
|
+
|
|
71
|
+
CaptureResult.new(
|
|
72
|
+
config:,
|
|
73
|
+
articles_count: articles.size,
|
|
74
|
+
channel_title: channel_title_from(response)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def raw_config
|
|
81
|
+
@raw_config ||= build_raw_config
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_raw_config # rubocop:disable Metrics/MethodLength
|
|
85
|
+
Config.auto_source_config(
|
|
86
|
+
url: @url,
|
|
87
|
+
items_selector: @items_selector_hint,
|
|
88
|
+
request_controls: Config::RequestControls.from_shortcut(
|
|
89
|
+
strategy: @strategy,
|
|
90
|
+
max_redirects: @max_redirects,
|
|
91
|
+
max_requests: @max_requests
|
|
92
|
+
),
|
|
93
|
+
limit: @limit
|
|
94
|
+
).tap do |config|
|
|
95
|
+
config[:strategy] = resolve_strategy(config[:strategy] || @strategy)
|
|
96
|
+
apply_local_file_path!(config)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_local_file_path!(config)
|
|
101
|
+
return unless @local_file_path
|
|
102
|
+
|
|
103
|
+
config[:strategy] = :local_file
|
|
104
|
+
config[:request] ||= {}
|
|
105
|
+
config[:request][:local_file_path] = @local_file_path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def local_file_request_overlay
|
|
109
|
+
return {} unless @local_file_path
|
|
110
|
+
|
|
111
|
+
{ strategy: :local_file, request: { local_file_path: @local_file_path } }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def resolve_strategy(strategy)
|
|
115
|
+
plan = FeedPipeline::StrategyPlan.resolve(strategy)
|
|
116
|
+
plan.is_a?(FeedPipeline::StrategyPlan::Auto) ? :faraday : plan.strategy
|
|
117
|
+
end
|
|
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
|
|
143
|
+
|
|
144
|
+
segments = discover_segments(sst)
|
|
145
|
+
return {} if segments.empty?
|
|
146
|
+
|
|
147
|
+
matched = match_segments_to_articles(segments, articles)
|
|
148
|
+
return {} if matched.empty?
|
|
149
|
+
|
|
150
|
+
build_selector_hash(matched, sst)
|
|
151
|
+
rescue ArgumentError => error
|
|
152
|
+
Log.warn("Capture selector derivation failed: #{error.message}")
|
|
153
|
+
{}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def normalize_sst(response)
|
|
157
|
+
SST::Normalizer.call(response.body)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def discover_segments(sst)
|
|
161
|
+
link_resolver = Scoring::LinkResolver.new(@url)
|
|
162
|
+
|
|
163
|
+
AutoSource::Segmenter.call(
|
|
164
|
+
sst,
|
|
165
|
+
base_url: @url,
|
|
166
|
+
strategy: :list,
|
|
167
|
+
permit_unanchored: false,
|
|
168
|
+
link_resolver:
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def match_segments_to_articles(segments, articles)
|
|
173
|
+
articles_by_url = articles.each_with_object({}) do |article, hash|
|
|
174
|
+
url_str = article.url.to_s
|
|
175
|
+
hash[url_str] = article unless url_str.empty?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
segments.filter_map do |segment|
|
|
179
|
+
next unless segment.primary_link
|
|
180
|
+
|
|
181
|
+
article = find_matching_article(segment, articles_by_url)
|
|
182
|
+
{ segment:, article: } if article
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def find_matching_article(segment, articles_by_url) # rubocop:disable Metrics/AbcSize
|
|
187
|
+
href = segment.primary_link.attrs.href.to_s
|
|
188
|
+
return articles_by_url.values.find { |a| a.title == title_from_segment(segment) } if href.empty?
|
|
189
|
+
|
|
190
|
+
resolved = Url.from_relative(href, @url)
|
|
191
|
+
articles_by_url[resolved.to_s] || articles_by_url.values.find { |a| fuzzy_url_match?(a.url, resolved) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def fuzzy_url_match?(article_url, resolved)
|
|
195
|
+
article_url.to_s.end_with?(resolved.path.to_s)
|
|
196
|
+
rescue StandardError
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def title_from_segment(segment)
|
|
201
|
+
segment.root_node.visible_text.to_s.strip
|
|
202
|
+
end
|
|
203
|
+
|
|
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
|
+
def items_selector(matched)
|
|
221
|
+
roots = matched.map { |m| m[:segment].root_node }
|
|
222
|
+
shared = shared_class_items_selector(roots)
|
|
223
|
+
return shared if shared
|
|
224
|
+
|
|
225
|
+
paths = roots.map(&:tag_path)
|
|
226
|
+
common = common_path_prefix(paths)
|
|
227
|
+
tag_path = common.empty? ? paths.first.to_s : common
|
|
228
|
+
return nil if tag_path.empty?
|
|
229
|
+
|
|
230
|
+
css_from_trimmed_tag_path(tag_path)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def shared_class_items_selector(roots)
|
|
234
|
+
shared = roots.map { |root| root.attrs.class_names }.reduce { |left, right| left & right }
|
|
235
|
+
return nil if shared.nil? || shared.empty?
|
|
236
|
+
|
|
237
|
+
"#{roots.first.name}.#{shared.min}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def css_from_trimmed_tag_path(tag_path)
|
|
241
|
+
segments = tag_path.split('/').reject(&:empty?)
|
|
242
|
+
trimmed = trim_leading_items_segments(segments)
|
|
243
|
+
trimmed.join(' > ')
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def trim_leading_items_segments(segments)
|
|
247
|
+
return segments if segments.empty?
|
|
248
|
+
|
|
249
|
+
trimmed = segments.dup
|
|
250
|
+
trimmed.shift while trimmed.any? && LEADING_TRIM_TAGS.include?(trimmed.first)
|
|
251
|
+
trimmed.shift while trimmed.length > 1 && trimmed.first == 'div'
|
|
252
|
+
trimmed.empty? ? [segments.last] : trimmed
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def common_path_prefix(paths) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
256
|
+
return '' if paths.empty?
|
|
257
|
+
|
|
258
|
+
segments = paths.map { |p| p.split('/').reject(&:empty?) }
|
|
259
|
+
prefix = segments.first
|
|
260
|
+
segments.each do |seg|
|
|
261
|
+
i = 0
|
|
262
|
+
i += 1 while i < prefix.length && i < seg.length && prefix[i] == seg[i]
|
|
263
|
+
prefix = prefix[0...i]
|
|
264
|
+
end
|
|
265
|
+
"/#{prefix.join('/')}"
|
|
266
|
+
end
|
|
267
|
+
|
|
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
|
+
def build_channel(response)
|
|
302
|
+
{
|
|
303
|
+
url: @url,
|
|
304
|
+
title: channel_title_from(response),
|
|
305
|
+
time_zone: 'UTC'
|
|
306
|
+
}.compact
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def channel_title_from(response)
|
|
310
|
+
Channel.from_response(response).title
|
|
311
|
+
rescue StandardError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
data/lib/html2rss/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'yaml'
|
|
5
6
|
require 'thor'
|
|
6
7
|
|
|
7
8
|
module Html2rss
|
|
@@ -98,6 +99,43 @@ module Html2rss
|
|
|
98
99
|
puts(format == 'jsonfeed' ? JSON.pretty_generate(result) : result)
|
|
99
100
|
end
|
|
100
101
|
|
|
102
|
+
desc 'capture URL', 'Analyze a URL and print a reusable YAML feed config'
|
|
103
|
+
method_option :strategy,
|
|
104
|
+
type: :string,
|
|
105
|
+
desc: STRATEGY_OPTION_DESC,
|
|
106
|
+
enum: STRATEGY_OPTION_ENUM
|
|
107
|
+
method_option :items_selector, type: :string, desc: 'CSS selector hint for items (optional)'
|
|
108
|
+
method_option :max_redirects,
|
|
109
|
+
type: :numeric,
|
|
110
|
+
desc: 'Maximum redirects to follow per request'
|
|
111
|
+
method_option :max_requests,
|
|
112
|
+
type: :numeric,
|
|
113
|
+
desc: 'Maximum requests to allow for this feed build'
|
|
114
|
+
method_option :limit,
|
|
115
|
+
type: :numeric,
|
|
116
|
+
desc: 'Maximum number of articles to keep (default: 25)'
|
|
117
|
+
method_option :input,
|
|
118
|
+
type: :string,
|
|
119
|
+
desc: 'Local HTML file path to read input from'
|
|
120
|
+
##
|
|
121
|
+
# Captures a URL and prints a reusable YAML config.
|
|
122
|
+
#
|
|
123
|
+
# @param url [String, nil] source page URL for capture
|
|
124
|
+
# @return [void]
|
|
125
|
+
def capture(url = nil) # rubocop:disable Metrics/MethodLength
|
|
126
|
+
strategy, local_file_path, url = prepare_auto_inputs(url, options[:input])
|
|
127
|
+
config = Html2rss.capture(
|
|
128
|
+
url,
|
|
129
|
+
strategy:,
|
|
130
|
+
items_selector: options[:items_selector],
|
|
131
|
+
limit: options[:limit]&.to_i,
|
|
132
|
+
max_redirects: options[:max_redirects],
|
|
133
|
+
max_requests: options[:max_requests],
|
|
134
|
+
local_file_path:
|
|
135
|
+
)
|
|
136
|
+
puts YAML.dump(HashUtil.deep_stringify_keys(config))
|
|
137
|
+
end
|
|
138
|
+
|
|
101
139
|
desc 'schema', 'Print the exported config JSON Schema'
|
|
102
140
|
method_option :pretty,
|
|
103
141
|
type: :boolean,
|
|
@@ -123,6 +161,27 @@ module Html2rss
|
|
|
123
161
|
puts schema_json
|
|
124
162
|
end
|
|
125
163
|
|
|
164
|
+
desc 'mcp', 'Start the MCP server for AI client consumption'
|
|
165
|
+
method_option :transport,
|
|
166
|
+
type: :string,
|
|
167
|
+
desc: 'MCP transport protocol',
|
|
168
|
+
enum: %w[stdio http],
|
|
169
|
+
default: 'stdio'
|
|
170
|
+
method_option :port,
|
|
171
|
+
type: :numeric,
|
|
172
|
+
desc: 'Port for HTTP transport (binds 127.0.0.1)',
|
|
173
|
+
default: 8080
|
|
174
|
+
##
|
|
175
|
+
# Starts the MCP server for AI client consumption.
|
|
176
|
+
#
|
|
177
|
+
# @return [void]
|
|
178
|
+
def mcp
|
|
179
|
+
Html2rss::MCP.start(
|
|
180
|
+
transport: options[:transport].to_sym,
|
|
181
|
+
port: options[:port]
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
126
185
|
desc 'validate YAML_FILE [feed_name]', 'Validate a YAML config with the runtime validator'
|
|
127
186
|
method_option :params,
|
|
128
187
|
type: :hash,
|
|
@@ -183,7 +242,7 @@ module Html2rss
|
|
|
183
242
|
|
|
184
243
|
file_path = check_file_exists!(input_option)
|
|
185
244
|
detected_url = url || detect_base_url!(
|
|
186
|
-
file_path, 'Please specify a URL: html2rss
|
|
245
|
+
file_path, 'Please specify a URL: html2rss <command> [URL] --input <file>'
|
|
187
246
|
)
|
|
188
247
|
|
|
189
248
|
[:local_file, file_path, detected_url]
|
|
@@ -221,15 +280,15 @@ module Html2rss
|
|
|
221
280
|
yield
|
|
222
281
|
rescue Faraday::FollowRedirects::RedirectLimitReached => error
|
|
223
282
|
raise Thor::Error,
|
|
224
|
-
"#{error.message}.
|
|
283
|
+
"#{error.message}. already retried the last redirect hop once; " \
|
|
284
|
+
"retry with --max-redirects #{suggested_max_redirects} or use the final URL directly."
|
|
225
285
|
rescue Html2rss::RequestService::RequestBudgetExceeded => error
|
|
226
286
|
raise Thor::Error,
|
|
227
287
|
"#{error.message}. retry with --max-requests #{suggested_max_requests} " \
|
|
228
288
|
'or increase request.max_requests in the config.'
|
|
229
|
-
rescue Html2rss::RequestService::
|
|
230
|
-
Html2rss::RequestService::BrowserlessConnectionFailed,
|
|
231
|
-
Html2rss::RequestService::BotasaurusConfigurationError,
|
|
289
|
+
rescue Html2rss::RequestService::BotasaurusConfigurationError,
|
|
232
290
|
Html2rss::RequestService::BotasaurusConnectionFailed,
|
|
291
|
+
Html2rss::RequestService::BotasaurusServiceError,
|
|
233
292
|
Html2rss::RequestService::BlockedSurfaceDetected,
|
|
234
293
|
Html2rss::NoFeedItemsExtracted => error
|
|
235
294
|
raise Thor::Error, error.message
|
|
@@ -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,23 @@ 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
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Hash{Symbol => Hash}] catalog under $defs.post_processors / $defs.extractors
|
|
88
|
+
def registry_catalog_defs
|
|
89
|
+
{
|
|
90
|
+
post_processors: catalog_from_registry(Selectors::PostProcessors::NAME_TO_CLASS),
|
|
91
|
+
extractors: catalog_from_registry(Selectors::Extractors::NAME_TO_CLASS)
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param registry [Hash{Symbol => Class}]
|
|
96
|
+
# @return [Hash{String => Hash}]
|
|
97
|
+
def catalog_from_registry(registry)
|
|
98
|
+
registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
|
|
80
99
|
end
|
|
81
100
|
|
|
82
101
|
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
|
|
@@ -105,6 +124,13 @@ module Html2rss
|
|
|
105
124
|
]
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
selector_schema = Schema.apply_selector_registry_refs!(
|
|
128
|
+
Html2rss::Config::SelectorsValidator::Selector.new.schema.json_schema(loose: true)
|
|
129
|
+
)
|
|
130
|
+
enclosure_schema = Schema.apply_selector_registry_refs!(
|
|
131
|
+
Html2rss::Config::SelectorsValidator::Enclosure.new.schema.json_schema(loose: true)
|
|
132
|
+
)
|
|
133
|
+
|
|
108
134
|
{
|
|
109
135
|
strategy: {
|
|
110
136
|
type: 'string',
|
|
@@ -121,7 +147,7 @@ module Html2rss
|
|
|
121
147
|
items: Html2rss::Config::Validator::StylesheetConfig.json_schema(loose: true)
|
|
122
148
|
},
|
|
123
149
|
auto_source: Html2rss::Config::AutoSourceContract.json_schema(loose: true).merge(
|
|
124
|
-
default:
|
|
150
|
+
default: HashUtil.deep_stringify_keys(Html2rss::AutoSource::DEFAULT_CONFIG)
|
|
125
151
|
),
|
|
126
152
|
selectors: {
|
|
127
153
|
type: 'object',
|
|
@@ -130,14 +156,14 @@ module Html2rss
|
|
|
130
156
|
items: items_schema.merge(
|
|
131
157
|
description: 'Defines the items selector and optional enhancement settings.'
|
|
132
158
|
),
|
|
133
|
-
enclosure:
|
|
159
|
+
enclosure: enclosure_schema.merge(
|
|
134
160
|
description: 'Describes enclosure extraction settings.'
|
|
135
161
|
),
|
|
136
162
|
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
163
|
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
164
|
},
|
|
139
165
|
patternProperties: {
|
|
140
|
-
'^(?!items$|enclosure$|guid$|categories$).+$' =>
|
|
166
|
+
'^(?!items$|enclosure$|guid$|categories$).+$' => selector_schema.merge(
|
|
141
167
|
description: 'Dynamic selector definition keyed by attribute name.'
|
|
142
168
|
)
|
|
143
169
|
},
|
|
@@ -159,31 +185,46 @@ module Html2rss
|
|
|
159
185
|
}
|
|
160
186
|
end
|
|
161
187
|
end
|
|
188
|
+
# rubocop:enable Metrics/ClassLength
|
|
162
189
|
|
|
163
190
|
##
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
191
|
+
# Wires extractor / post_process to thin oneOf $refs into the registry catalog.
|
|
192
|
+
#
|
|
193
|
+
# @param schema [Hash] dry-schema JSON schema fragment for a selector
|
|
194
|
+
# @return [Hash] same schema with registry $ref wiring applied
|
|
195
|
+
def apply_selector_registry_refs!(schema)
|
|
196
|
+
properties = schema.fetch(:properties)
|
|
197
|
+
properties[:extractor] = extractor_wire_schema
|
|
198
|
+
properties[:post_process] = post_process_wire_schema
|
|
199
|
+
schema
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @return [Hash{Symbol => Object}]
|
|
203
|
+
def extractor_wire_schema
|
|
204
|
+
{
|
|
205
|
+
description: 'Extractor used to pull a value from the selected element.',
|
|
206
|
+
oneOf: registry_ref_list('extractors', Selectors::Extractors::NAME_TO_CLASS)
|
|
207
|
+
}
|
|
208
|
+
end
|
|
182
209
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
210
|
+
# @return [Hash{Symbol => Object}]
|
|
211
|
+
def post_process_wire_schema
|
|
212
|
+
{
|
|
213
|
+
type: 'array',
|
|
214
|
+
description: 'Ordered transforms applied to the extracted value.',
|
|
215
|
+
items: {
|
|
216
|
+
oneOf: registry_ref_list('post_processors', Selectors::PostProcessors::NAME_TO_CLASS)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
##
|
|
222
|
+
# @param catalog [String] $defs catalog name (`extractors` or `post_processors`)
|
|
223
|
+
# @param registry [Hash{Symbol => Class}]
|
|
224
|
+
# @return [Array<Hash>] oneOf entries of `{ '$ref' => ... }`
|
|
225
|
+
def registry_ref_list(catalog, registry)
|
|
226
|
+
registry.keys.sort.map do |name|
|
|
227
|
+
{ '$ref' => "#/$defs/#{catalog}/#{name}" }
|
|
187
228
|
end
|
|
188
229
|
end
|
|
189
230
|
end
|