html2rss 0.32.4 → 0.33.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/lib/html2rss/article/enclosure.rb +2 -6
- data/lib/html2rss/auto_source/cleanup.rb +1 -3
- data/lib/html2rss/auto_source/scraper/html.rb +2 -8
- data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
- data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
- data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
- data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
- data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
- data/lib/html2rss/auto_source/scraper.rb +0 -11
- data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
- data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
- data/lib/html2rss/auto_source.rb +1 -1
- data/lib/html2rss/batch.rb +22 -32
- data/lib/html2rss/capture/README.md +1 -1
- data/lib/html2rss/capture.rb +7 -27
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +33 -27
- data/lib/html2rss/config/request_controls.rb +10 -2
- data/lib/html2rss/config/schema.rb +8 -10
- data/lib/html2rss/config/selectors_validator.rb +1 -0
- data/lib/html2rss/config/validator.rb +3 -3
- data/lib/html2rss/config.rb +1 -1
- data/lib/html2rss/defaults.rb +5 -16
- data/lib/html2rss/doctor/botasaurus.rb +11 -19
- data/lib/html2rss/error.rb +7 -3
- data/lib/html2rss/feed_builder/rss.rb +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +3 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +3 -3
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +5 -9
- data/lib/html2rss/feed_result.rb +4 -3
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
- data/lib/html2rss/html/probe.rb +51 -51
- data/lib/html2rss/link_destination/path_classifier.rb +11 -33
- data/lib/html2rss/mcp/config_argument.rb +9 -2
- data/lib/html2rss/mcp/contract.rb +7 -5
- data/lib/html2rss/mcp/outcome.rb +4 -5
- data/lib/html2rss/mcp/runtime.rb +17 -24
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/diagnostics.rb +7 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +3 -7
- data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +0 -1
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +8 -26
- data/lib/html2rss/request_service/response.rb +3 -6
- data/lib/html2rss/request_session/pager/base.rb +3 -3
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/scoring/engine.rb +4 -32
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +14 -18
- data/lib/html2rss/sst/node.rb +9 -3
- data/lib/html2rss/sst/normalizer.rb +25 -24
- data/lib/html2rss/sst/text.rb +53 -57
- data/lib/html2rss/status.rb +23 -30
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/discovery.rb +117 -187
- data/lib/html2rss/syndication/parser.rb +92 -115
- data/lib/html2rss/test/enhance_audit.rb +13 -15
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +29 -48
- data/lib/html2rss/url.rb +53 -53
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +2 -1
- metadata +4 -2
|
@@ -32,7 +32,7 @@ module Html2rss
|
|
|
32
32
|
|
|
33
33
|
def parse_id_from_dom(article_tag, url, selected_anchor)
|
|
34
34
|
candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
|
|
35
|
-
candidates += [url&.path
|
|
35
|
+
candidates += [url&.path] if selected_anchor
|
|
36
36
|
candidates.compact.reject(&:empty?).first
|
|
37
37
|
end
|
|
38
38
|
|
data/lib/html2rss/html/probe.rb
CHANGED
|
@@ -16,66 +16,66 @@ module Html2rss
|
|
|
16
16
|
# @see #mime_match?
|
|
17
17
|
APPLICATION_ATOM_XML = 'application/atom+xml'
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
class << self
|
|
20
|
+
##
|
|
21
|
+
# @param string [String, Symbol, #to_s] wire value
|
|
22
|
+
# @return [String] folded comparison key
|
|
23
|
+
def fold(string)
|
|
24
|
+
string.to_s.downcase
|
|
25
|
+
end
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
##
|
|
29
|
-
# @param node [Nokogiri::XML::Node]
|
|
30
|
-
# @return [String] folded element name
|
|
31
|
-
def tag(node)
|
|
32
|
-
name = node.name
|
|
33
|
-
return fold(name) unless name.is_a?(String)
|
|
34
|
-
return name if name.ascii_only? && !name.match?(/[A-Z]/)
|
|
27
|
+
##
|
|
28
|
+
# @param node [Nokogiri::XML::Node]
|
|
29
|
+
# @return [String] folded element name
|
|
30
|
+
def tag(node)
|
|
31
|
+
name = node.name
|
|
32
|
+
return fold(name) unless name.is_a?(String)
|
|
33
|
+
return name if name.ascii_only? && !name.match?(/[A-Z]/)
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
fold(name)
|
|
36
|
+
end
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
##
|
|
39
|
+
# @param type [String, Symbol, #to_s, nil] MIME type attribute value
|
|
40
|
+
# @return [String] media type without parameters, folded
|
|
41
|
+
def mime_base(type)
|
|
42
|
+
raw = type.to_s
|
|
43
|
+
return fold(raw) unless raw.include?(';') || raw.match?(/\A\s|\s\z/)
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
fold(raw.split(';', 2).first.to_s.strip)
|
|
46
|
+
end
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
##
|
|
49
|
+
# @param actual [String, Symbol, #to_s, nil] observed MIME type
|
|
50
|
+
# @param expected [Array<String>] canonical MIME types to match
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def mime_match?(actual, *expected)
|
|
53
|
+
base = mime_base(actual)
|
|
54
|
+
expected.any? { |candidate| base == candidate || base == mime_base(candidate) }
|
|
55
|
+
end
|
|
57
56
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
##
|
|
58
|
+
# @param doc [Nokogiri::XML::Node]
|
|
59
|
+
# @param mime_types [Array<String>] optional MIME filters (folded once per call)
|
|
60
|
+
# @return [Array<Nokogiri::XML::Element>]
|
|
61
|
+
def scripts(doc, *mime_types)
|
|
62
|
+
return doc.css('script') if mime_types.empty?
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
allowed = mime_types.to_set { |mime| mime_base(mime) }
|
|
65
|
+
doc.css('script').select { |script| allowed.include?(mime_base(script['type'])) }
|
|
66
|
+
end
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
##
|
|
69
|
+
# @param doc [Nokogiri::XML::Node]
|
|
70
|
+
# @param rel [String] link rel token
|
|
71
|
+
# @param mime [String, nil] optional MIME filter
|
|
72
|
+
# @return [Array<Nokogiri::XML::Element>]
|
|
73
|
+
def alternate_links(doc, rel:, mime: nil)
|
|
74
|
+
nodes = doc.css(%(link[rel~="#{rel}"][href]))
|
|
75
|
+
return nodes if mime.nil?
|
|
77
76
|
|
|
78
|
-
|
|
77
|
+
nodes.select { |node| mime_match?(node['type'], mime) }
|
|
78
|
+
end
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
end
|
|
@@ -106,13 +106,13 @@ module Html2rss
|
|
|
106
106
|
def shallow?
|
|
107
107
|
segment_count = segments.size
|
|
108
108
|
|
|
109
|
-
segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(
|
|
109
|
+
segment_count <= 1 || (segment_count == 2 && high_confidence_junk_at?(1))
|
|
110
110
|
end
|
|
111
111
|
|
|
112
112
|
# @return [Boolean] true when the final path segment looks like a post slug
|
|
113
113
|
def strong_post_suffix?
|
|
114
114
|
@strong_post_suffix ||= segments.any? &&
|
|
115
|
-
|
|
115
|
+
slug_last_segment? &&
|
|
116
116
|
trusted_post_context?(segments.size - 1)
|
|
117
117
|
end
|
|
118
118
|
|
|
@@ -121,13 +121,9 @@ module Html2rss
|
|
|
121
121
|
(0...segments.size).all? { |i| high_confidence_junk_at?(i) }
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
# @return [Boolean] true when the route is shallow and
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
shallow? && (0...segments.size).any? do |i|
|
|
129
|
-
high_confidence_junk_at?(i) || vanity_segments.include?(@lexicon[i])
|
|
130
|
-
end
|
|
124
|
+
# @return [Boolean] true when the route is shallow and names a soft vanity segment
|
|
125
|
+
def shallow_vanity_route?
|
|
126
|
+
shallow? && @lexicon.any? { |folded| SEGMENT_SETS.fetch(:vanity).include?(folded) }
|
|
131
127
|
end
|
|
132
128
|
|
|
133
129
|
# @return [Boolean] true when the leading segments are all utility chrome
|
|
@@ -139,11 +135,7 @@ module Html2rss
|
|
|
139
135
|
def junk_path?
|
|
140
136
|
return false if excluded_content_route?
|
|
141
137
|
|
|
142
|
-
any_high_confidence_junk_segment? ||
|
|
143
|
-
taxonomy_path? ||
|
|
144
|
-
utility_only_route? ||
|
|
145
|
-
deep_utility_context_route? ||
|
|
146
|
-
shallow_high_confidence_route?
|
|
138
|
+
any_high_confidence_junk_segment? || shallow_vanity_route?
|
|
147
139
|
end
|
|
148
140
|
|
|
149
141
|
# @return [Boolean] true when the route points at conversion or account chrome
|
|
@@ -164,8 +156,7 @@ module Html2rss
|
|
|
164
156
|
end
|
|
165
157
|
|
|
166
158
|
def yearish_content_context?
|
|
167
|
-
segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
|
|
168
|
-
(strong_post_suffix? || trusted_post_context?(segments.size - 1))
|
|
159
|
+
segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } && trusted_post_context?(segments.size - 1)
|
|
169
160
|
end
|
|
170
161
|
|
|
171
162
|
def excluded_content_route?
|
|
@@ -199,28 +190,15 @@ module Html2rss
|
|
|
199
190
|
end
|
|
200
191
|
|
|
201
192
|
def trusted_post_context?(limit)
|
|
202
|
-
return false if limit <= 0
|
|
203
193
|
return false if leading_high_confidence_junk?
|
|
204
194
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
content_segments.include?(@lexicon[i]) ||
|
|
210
|
-
segments[i].match?(PathClassifier::YEARISH_SEGMENT) ||
|
|
211
|
-
context_segments.include?(@lexicon[i])
|
|
195
|
+
content = SEGMENT_SETS.fetch(:content)
|
|
196
|
+
context = SEGMENT_SETS.fetch(:deep_post_context)
|
|
197
|
+
segments.zip(@lexicon).first(limit.clamp(0..)).any? do |segment, folded|
|
|
198
|
+
content.include?(folded) || segment.match?(YEARISH_SEGMENT) || context.include?(folded)
|
|
212
199
|
end
|
|
213
200
|
end
|
|
214
201
|
|
|
215
|
-
def included_last_segment?
|
|
216
|
-
!excluded_last_segment? && slug_last_segment?
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def excluded_last_segment?
|
|
220
|
-
last_i = segments.size - 1
|
|
221
|
-
high_confidence_junk_at?(last_i) || SEGMENT_SETS[:vanity].include?(@lexicon[last_i])
|
|
222
|
-
end
|
|
223
|
-
|
|
224
202
|
def slug_last_segment?
|
|
225
203
|
last = segments.last
|
|
226
204
|
last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
|
|
@@ -14,14 +14,15 @@ module Html2rss
|
|
|
14
14
|
# @param yaml [String, nil] feed configuration YAML
|
|
15
15
|
# @return [ConfigArgument]
|
|
16
16
|
# @raise [ArgumentError] unless exactly one of +config+ or +yaml+ is present
|
|
17
|
+
# @raise [Html2rss::InvalidOption] when +config+ is not a Hash or +yaml+ is not a String
|
|
17
18
|
# @raise [Contract::UnpublishedRequestError] when the parsed config uses
|
|
18
19
|
# an unpublished MCP strategy or +request.local_file_path+
|
|
19
20
|
def parse(config: nil, yaml: nil)
|
|
20
21
|
parsed = case [present?(config), present?(yaml)]
|
|
21
22
|
in [true, false]
|
|
22
|
-
HashUtil.deep_symbolize_keys(config, context: 'config')
|
|
23
|
+
HashUtil.deep_symbolize_keys(require_type!(config, Hash, 'config'), context: 'config')
|
|
23
24
|
in [false, true]
|
|
24
|
-
Config.from_yaml(yaml)
|
|
25
|
+
Config.from_yaml(require_type!(yaml, String, 'yaml'))
|
|
25
26
|
else
|
|
26
27
|
raise ArgumentError, 'Provide exactly one of config or yaml'
|
|
27
28
|
end
|
|
@@ -31,6 +32,12 @@ module Html2rss
|
|
|
31
32
|
|
|
32
33
|
private
|
|
33
34
|
|
|
35
|
+
def require_type!(value, type, name)
|
|
36
|
+
return value if value.is_a?(type)
|
|
37
|
+
|
|
38
|
+
raise Html2rss::InvalidOption, "#{name} must be a #{type}, got #{value.class}"
|
|
39
|
+
end
|
|
40
|
+
|
|
34
41
|
def present?(value)
|
|
35
42
|
case value
|
|
36
43
|
in nil then false
|
|
@@ -103,7 +103,7 @@ module Html2rss
|
|
|
103
103
|
properties: {
|
|
104
104
|
url: URL_PROPERTY,
|
|
105
105
|
strategy: STRATEGY_PROPERTY,
|
|
106
|
-
limit: { type: 'integer', description: 'Max articles to keep (default 25)', default: 25 },
|
|
106
|
+
limit: { type: 'integer', minimum: 1, description: 'Max articles to keep (default 25)', default: 25 },
|
|
107
107
|
items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
|
|
108
108
|
}.freeze,
|
|
109
109
|
required: %w[url]
|
|
@@ -135,9 +135,9 @@ module Html2rss
|
|
|
135
135
|
title: { type: 'string', description: 'Channel title override' },
|
|
136
136
|
summary: { type: 'string', description: 'Directory summary override' },
|
|
137
137
|
enhance: { type: 'boolean', description: 'Force enhance on or off' },
|
|
138
|
-
limit: { type: 'integer', description: 'Max articles to keep' },
|
|
139
|
-
max_redirects: { type: 'integer', description: 'Optional redirect limit override' },
|
|
140
|
-
max_requests: { type: 'integer', description: 'Optional request budget override' }
|
|
138
|
+
limit: { type: 'integer', minimum: 1, description: 'Max articles to keep' },
|
|
139
|
+
max_redirects: { type: 'integer', minimum: 0, description: 'Optional redirect limit override' },
|
|
140
|
+
max_requests: { type: 'integer', minimum: 1, description: 'Optional request budget override' }
|
|
141
141
|
}.freeze,
|
|
142
142
|
required: %w[url]
|
|
143
143
|
}.freeze
|
|
@@ -163,6 +163,8 @@ module Html2rss
|
|
|
163
163
|
strategy: strategy_property,
|
|
164
164
|
concurrency: {
|
|
165
165
|
type: 'integer',
|
|
166
|
+
minimum: 1,
|
|
167
|
+
maximum: 10,
|
|
166
168
|
description: 'Max parallel worker threads (1..10, default: 5)',
|
|
167
169
|
default: 5
|
|
168
170
|
},
|
|
@@ -177,7 +179,7 @@ module Html2rss
|
|
|
177
179
|
urls_description: 'List of page URLs to scrape (1..25)',
|
|
178
180
|
strategy_property: STRATEGY_PROPERTY,
|
|
179
181
|
extra_properties: {
|
|
180
|
-
limit: { type: 'integer', description: 'Max articles per URL to keep (default 10)', default: 10 }
|
|
182
|
+
limit: { type: 'integer', minimum: 1, description: 'Max articles per URL to keep (default 10)', default: 10 }
|
|
181
183
|
}
|
|
182
184
|
).freeze
|
|
183
185
|
|
data/lib/html2rss/mcp/outcome.rb
CHANGED
|
@@ -60,7 +60,7 @@ module Html2rss
|
|
|
60
60
|
##
|
|
61
61
|
# @param report [PageRecon::Diagnostics::Report]
|
|
62
62
|
# @return [Outcome]
|
|
63
|
-
def
|
|
63
|
+
def inspect_report(report:)
|
|
64
64
|
next_step = inspect_next_step(report)
|
|
65
65
|
guidance = Playbook.inspect_guidance(report)
|
|
66
66
|
new(ok: true, next_step:, guidance:, payload: report.to_wire_h)
|
|
@@ -194,7 +194,7 @@ module Html2rss
|
|
|
194
194
|
NextStep[:scrape]
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
-
def capture_next_step(articles_count:, has_selectors:, native_feed:
|
|
197
|
+
def capture_next_step(articles_count:, has_selectors:, native_feed:)
|
|
198
198
|
return NextStep[:done] if native_feed
|
|
199
199
|
|
|
200
200
|
articles_count.positive? && has_selectors ? NextStep[:test] : NextStep[:inspect]
|
|
@@ -205,7 +205,6 @@ module Html2rss
|
|
|
205
205
|
|
|
206
206
|
kind = test_result.failure_kind
|
|
207
207
|
return NextStep[:validate] if kind&.schema?
|
|
208
|
-
return NextStep[:capture] if kind&.execution? || kind&.min_items? || kind&.quality?
|
|
209
208
|
|
|
210
209
|
NextStep[:capture]
|
|
211
210
|
end
|
|
@@ -223,8 +222,8 @@ module Html2rss
|
|
|
223
222
|
end
|
|
224
223
|
|
|
225
224
|
def capture_payload(yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy:, # rubocop:disable Metrics/ParameterLists
|
|
226
|
-
segment_strategy:, selected_strategy:, admission_drops:, native_feed
|
|
227
|
-
suggested_channel_url:
|
|
225
|
+
segment_strategy:, selected_strategy:, admission_drops:, native_feed:,
|
|
226
|
+
suggested_channel_url:)
|
|
228
227
|
{
|
|
229
228
|
yaml:, articles_count:, has_selectors:, channel_title:, requested_strategy: requested_strategy.to_s,
|
|
230
229
|
**(native_feed ? { native_feed: native_feed.to_s } : {}),
|
data/lib/html2rss/mcp/runtime.rb
CHANGED
|
@@ -14,31 +14,24 @@ module Html2rss
|
|
|
14
14
|
:botasaurus_configured
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
class << self
|
|
18
|
+
##
|
|
19
|
+
# @return [Boolean] whether Botasaurus transport is configured in this process
|
|
20
|
+
def botasaurus_configured?
|
|
21
|
+
!ENV['BOTASAURUS_SCRAPER_URL'].to_s.strip.empty?
|
|
22
|
+
end
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
|
|
31
|
-
catalog_fingerprint: Contract.catalog_fingerprint,
|
|
32
|
-
tools: Contract.catalog_tools,
|
|
33
|
-
botasaurus_configured: botasaurus_configured?
|
|
34
|
-
)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
##
|
|
38
|
-
# @param strategy [String, Symbol, nil]
|
|
39
|
-
# @return [Symbol]
|
|
40
|
-
def coerce_strategy(strategy)
|
|
41
|
-
(strategy || :auto).to_sym
|
|
24
|
+
##
|
|
25
|
+
# @return [Snapshot] runtime capabilities and catalog identity for MCP clients
|
|
26
|
+
def snapshot
|
|
27
|
+
Snapshot.new(
|
|
28
|
+
version: Html2rss::VERSION,
|
|
29
|
+
mcp_contract_version: Contract::MCP_CONTRACT_VERSION,
|
|
30
|
+
catalog_fingerprint: Contract.catalog_fingerprint,
|
|
31
|
+
tools: Contract.catalog_tools,
|
|
32
|
+
botasaurus_configured: botasaurus_configured?
|
|
33
|
+
)
|
|
34
|
+
end
|
|
42
35
|
end
|
|
43
36
|
end
|
|
44
37
|
end
|
|
@@ -36,9 +36,9 @@ module Html2rss
|
|
|
36
36
|
input_schema: Contract::INSPECT_INPUT_SCHEMA,
|
|
37
37
|
annotations: Contract::ANNOTATIONS_OPEN_WORLD,
|
|
38
38
|
call: lambda { |url:, strategy: 'auto', **|
|
|
39
|
-
Outcome.
|
|
39
|
+
Outcome.inspect_report(
|
|
40
40
|
report: PageRecon::Diagnostics.call(
|
|
41
|
-
url:, strategy:
|
|
41
|
+
url:, strategy: Option.strategy(strategy), deep: false
|
|
42
42
|
)
|
|
43
43
|
)
|
|
44
44
|
}
|
|
@@ -51,7 +51,7 @@ module Html2rss
|
|
|
51
51
|
input_schema: Contract::RECON_INPUT_SCHEMA,
|
|
52
52
|
annotations: Contract::ANNOTATIONS_OPEN_WORLD,
|
|
53
53
|
call: lambda { |url:, strategy: 'auto', **|
|
|
54
|
-
Outcome.recon(result: Html2rss.recon(url, strategy:
|
|
54
|
+
Outcome.recon(result: Html2rss.recon(url, strategy: Option.strategy(strategy)))
|
|
55
55
|
}
|
|
56
56
|
),
|
|
57
57
|
Tool.new(
|
|
@@ -63,7 +63,7 @@ module Html2rss
|
|
|
63
63
|
annotations: Contract::ANNOTATIONS_OPEN_WORLD,
|
|
64
64
|
call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, limit: 10, **|
|
|
65
65
|
Outcome.batch_scrape(
|
|
66
|
-
Batch.batch_scrape(urls:, strategy:
|
|
66
|
+
Batch.batch_scrape(urls:, strategy: Option.strategy(strategy), concurrency:, limit:)
|
|
67
67
|
)
|
|
68
68
|
}
|
|
69
69
|
),
|
|
@@ -76,7 +76,7 @@ module Html2rss
|
|
|
76
76
|
annotations: Contract::ANNOTATIONS_OPEN_WORLD,
|
|
77
77
|
call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, **|
|
|
78
78
|
Outcome.batch_inspect(
|
|
79
|
-
Batch.batch_inspect(urls:, strategy:
|
|
79
|
+
Batch.batch_inspect(urls:, strategy: Option.strategy(strategy), concurrency:)
|
|
80
80
|
)
|
|
81
81
|
}
|
|
82
82
|
),
|
|
@@ -89,7 +89,7 @@ module Html2rss
|
|
|
89
89
|
annotations: Contract::ANNOTATIONS_OPEN_WORLD,
|
|
90
90
|
call: lambda { |urls:, strategy: 'auto', concurrency: Batch::DEFAULT_CONCURRENCY, **|
|
|
91
91
|
Outcome.batch_recon(
|
|
92
|
-
Batch.batch_recon(urls:, strategy:
|
|
92
|
+
Batch.batch_recon(urls:, strategy: Option.strategy(strategy), concurrency:)
|
|
93
93
|
)
|
|
94
94
|
}
|
|
95
95
|
),
|
|
@@ -136,7 +136,7 @@ module Html2rss
|
|
|
136
136
|
compare_enhance: false, **kwargs|
|
|
137
137
|
feed_config = ConfigArgument.parse(config:, yaml:).config
|
|
138
138
|
test_args = { min_items:, strict_quality:, compare_enhance: }
|
|
139
|
-
test_args[:strategy] =
|
|
139
|
+
test_args[:strategy] = Option.strategy(kwargs[:strategy]) if kwargs.key?(:strategy)
|
|
140
140
|
Outcome.test(Html2rss.test(feed_config, **test_args))
|
|
141
141
|
}
|
|
142
142
|
),
|
|
@@ -163,7 +163,7 @@ module Html2rss
|
|
|
163
163
|
# @param items_selector [String, nil]
|
|
164
164
|
# @return [Outcome]
|
|
165
165
|
def scrape_outcome(url:, strategy: 'auto', limit: 25, items_selector: nil)
|
|
166
|
-
wire = Batch.scrape_wire(url:, strategy:
|
|
166
|
+
wire = Batch.scrape_wire(url:, strategy: Option.strategy(strategy), limit:, items_selector:)
|
|
167
167
|
Outcome.scrape(
|
|
168
168
|
items: wire[:items],
|
|
169
169
|
requested_strategy: wire[:strategy],
|
|
@@ -189,7 +189,7 @@ module Html2rss
|
|
|
189
189
|
# @return [Outcome]
|
|
190
190
|
def capture_outcome(url:, strategy: 'auto', items_selector: nil, force: false, topics: nil, title: nil, # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
191
191
|
summary: nil, enhance: nil, limit: nil, max_redirects: nil, max_requests: nil)
|
|
192
|
-
plan =
|
|
192
|
+
plan = Option.strategy(strategy)
|
|
193
193
|
result = Html2rss::Capture.build(
|
|
194
194
|
url,
|
|
195
195
|
strategy: plan,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
##
|
|
5
|
+
# Edge validation for caller-supplied option values (public API, CLI, MCP).
|
|
6
|
+
# Everything past these checks may trust its types.
|
|
7
|
+
module Option
|
|
8
|
+
class << self
|
|
9
|
+
##
|
|
10
|
+
# @param name [Symbol] option name used in the error message
|
|
11
|
+
# @param value [Object] candidate value
|
|
12
|
+
# @param min [Integer] inclusive lower bound
|
|
13
|
+
# @param max [Integer, nil] inclusive upper bound
|
|
14
|
+
# @param optional [Boolean] whether +nil+ passes through
|
|
15
|
+
# @return [Integer, nil] +value+ when valid
|
|
16
|
+
# @raise [InvalidOption] when +value+ is not an Integer within bounds
|
|
17
|
+
def integer(name, value, min:, max: nil, optional: false)
|
|
18
|
+
return value if value.nil? && optional
|
|
19
|
+
return value if value.is_a?(Integer) && (min..max).cover?(value)
|
|
20
|
+
|
|
21
|
+
raise InvalidOption, "#{name} must be an Integer in #{min}..#{max}, got #{value.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# @param value [Symbol, String, nil] strategy plan name (+nil+ means +:auto+)
|
|
26
|
+
# @return [Symbol] the plan name
|
|
27
|
+
# @raise [InvalidOption] when +value+ is not a registered plan
|
|
28
|
+
def strategy(value)
|
|
29
|
+
name = value.nil? ? FeedPipeline::StrategyPlan::AUTO_NAME : value
|
|
30
|
+
return name.to_sym if FeedPipeline::StrategyPlan.valid?(name)
|
|
31
|
+
|
|
32
|
+
accepted = FeedPipeline::StrategyPlan.accepted_names.join(', ')
|
|
33
|
+
raise InvalidOption, "strategy must be one of #{accepted}, got #{value.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# @param name [Symbol] option name used in the error message
|
|
38
|
+
# @param value [Object] candidate list
|
|
39
|
+
# @return [Array] +value+ when it is a non-empty Array
|
|
40
|
+
# @raise [InvalidOption] otherwise
|
|
41
|
+
def list(name, value)
|
|
42
|
+
return value if value.is_a?(Array) && value.any?
|
|
43
|
+
|
|
44
|
+
raise InvalidOption, "#{name} must be a non-empty Array, got #{value.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -34,14 +34,12 @@ module Html2rss
|
|
|
34
34
|
JS_SHELL_MIN_BODY_BYTES = 8_192
|
|
35
35
|
private_constant :JS_SHELL_MIN_BODY_BYTES
|
|
36
36
|
|
|
37
|
-
module_function
|
|
38
|
-
|
|
39
37
|
##
|
|
40
38
|
# @param url [String]
|
|
41
39
|
# @param strategy [String, Symbol]
|
|
42
40
|
# @param deep [Boolean] when true and strategy is auto, one Botasaurus hop if configured
|
|
43
41
|
# @return [Report]
|
|
44
|
-
def call(url:, strategy: :auto, deep: false)
|
|
42
|
+
def self.call(url:, strategy: :auto, deep: false)
|
|
45
43
|
probe = PageRecon.probe(url, strategy: resolve_inspect_strategy(strategy, deep:))
|
|
46
44
|
recon = probe.result
|
|
47
45
|
response = probe.response
|
|
@@ -56,10 +54,10 @@ module Html2rss
|
|
|
56
54
|
# @param strategy [Symbol, String]
|
|
57
55
|
# @param concurrency [Integer]
|
|
58
56
|
# @return [Array<Report>]
|
|
59
|
-
def batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
|
|
57
|
+
def self.batch(urls:, strategy: :auto, concurrency: Batch::DEFAULT_CONCURRENCY)
|
|
60
58
|
Batch.map(Array(urls), concurrency:) do |url|
|
|
61
59
|
call(url:, strategy:)
|
|
62
|
-
rescue
|
|
60
|
+
rescue Html2rss::Error => error
|
|
63
61
|
error_report(url, error)
|
|
64
62
|
end
|
|
65
63
|
end
|
|
@@ -67,7 +65,7 @@ module Html2rss
|
|
|
67
65
|
##
|
|
68
66
|
# @param response [Html2rss::RequestService::Response]
|
|
69
67
|
# @return [Hash] redacted XHR capture diagnostics (no query strings)
|
|
70
|
-
def xhr_capture_info(response)
|
|
68
|
+
def self.xhr_capture_info(response)
|
|
71
69
|
captured = response.captured_responses
|
|
72
70
|
{
|
|
73
71
|
count: captured.size,
|
|
@@ -79,7 +77,7 @@ module Html2rss
|
|
|
79
77
|
##
|
|
80
78
|
# @param entry [Hash] captured response hash
|
|
81
79
|
# @return [String, nil] scheme+host+path only
|
|
82
|
-
def redacted_endpoint(entry)
|
|
80
|
+
def self.redacted_endpoint(entry)
|
|
83
81
|
raw = entry['url'] || entry[:url]
|
|
84
82
|
return unless raw
|
|
85
83
|
|
|
@@ -94,7 +92,7 @@ module Html2rss
|
|
|
94
92
|
##
|
|
95
93
|
# @param entry [Hash] captured response hash
|
|
96
94
|
# @return [Boolean]
|
|
97
|
-
def xhr_candidate_articles?(entry)
|
|
95
|
+
def self.xhr_candidate_articles?(entry)
|
|
98
96
|
body = entry['body'] || entry[:body]
|
|
99
97
|
return false unless body.is_a?(String)
|
|
100
98
|
|
|
@@ -107,7 +105,7 @@ module Html2rss
|
|
|
107
105
|
##
|
|
108
106
|
# @param parsed [Object] parsed response body
|
|
109
107
|
# @return [Array<String>, Hash]
|
|
110
|
-
def scraper_info(parsed)
|
|
108
|
+
def self.scraper_info(parsed)
|
|
111
109
|
return { error: 'Response is not HTML' } unless parsed.is_a?(Nokogiri::HTML::Document)
|
|
112
110
|
|
|
113
111
|
begin
|
data/lib/html2rss/page_recon.rb
CHANGED
|
@@ -158,8 +158,6 @@ module Html2rss
|
|
|
158
158
|
def self.discover_segments(sst, url)
|
|
159
159
|
link_resolver = Scoring::LinkResolver.new(url)
|
|
160
160
|
AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
|
|
161
|
-
rescue StandardError
|
|
162
|
-
[]
|
|
163
161
|
end
|
|
164
162
|
|
|
165
163
|
##
|
|
@@ -268,8 +266,6 @@ module Html2rss
|
|
|
268
266
|
|
|
269
267
|
def sst_document
|
|
270
268
|
Html2rss::SST::Normalizer.call(response.body)
|
|
271
|
-
rescue ArgumentError
|
|
272
|
-
nil
|
|
273
269
|
end
|
|
274
270
|
|
|
275
271
|
def segment_stats(sst, page_url)
|