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
|
@@ -75,6 +75,9 @@ module Html2rss
|
|
|
75
75
|
##
|
|
76
76
|
# Validates the configuration of a single selector.
|
|
77
77
|
class Selector < Dry::Validation::Contract
|
|
78
|
+
# Extractor Options members supplied at runtime, not from feed YAML.
|
|
79
|
+
RUNTIME_EXTRACTOR_FIELDS = %i[selector channel].to_set.freeze
|
|
80
|
+
|
|
78
81
|
params do
|
|
79
82
|
optional(:selector)
|
|
80
83
|
optional(:extractor).filled(:string)
|
|
@@ -88,33 +91,69 @@ module Html2rss
|
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
rule(:extractor) do
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
next unless value
|
|
95
|
+
|
|
96
|
+
klass = Selectors::Extractors::NAME_TO_CLASS[value.to_sym]
|
|
97
|
+
next key(:extractor).failure("unknown extractor: #{value}") unless klass
|
|
98
|
+
|
|
99
|
+
klass::Options.members.each do |field|
|
|
100
|
+
next if RUNTIME_EXTRACTOR_FIELDS.include?(field)
|
|
101
|
+
next if values[field]
|
|
102
|
+
|
|
103
|
+
key(field).failure("`#{field}` is required for extractor `#{value}`")
|
|
97
104
|
end
|
|
98
105
|
end
|
|
99
106
|
|
|
100
107
|
rule(:post_process).each do
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
key(
|
|
110
|
-
when 'html_to_markdown', 'markdown_to_html', 'parse_time', 'parse_uri', 'sanitize_html'
|
|
111
|
-
# nothing to validate
|
|
112
|
-
when nil
|
|
113
|
-
key(:post_process).failure('Missing post_processor `name`')
|
|
114
|
-
else
|
|
115
|
-
key(:post_process).failure("Unknown post_processor `name`: #{name}")
|
|
108
|
+
name = value[:name]
|
|
109
|
+
next key(:post_process).failure('Missing post_processor `name`') if name.nil?
|
|
110
|
+
|
|
111
|
+
klass = Selectors::PostProcessors::NAME_TO_CLASS[name.to_sym]
|
|
112
|
+
next key(:post_process).failure("Unknown post_processor `name`: #{name}") unless klass
|
|
113
|
+
next unless klass.const_defined?(:Options)
|
|
114
|
+
|
|
115
|
+
post_process_option_type_errors(klass, value).each do |field, message|
|
|
116
|
+
key(field).failure(message)
|
|
116
117
|
end
|
|
117
118
|
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def post_process_option_type_errors(klass, value)
|
|
123
|
+
required_option_type_errors(klass, value) + optional_option_type_errors(klass, value)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def required_option_type_errors(klass, value)
|
|
127
|
+
option_types = klass.const_defined?(:OPTION_TYPES) ? klass::OPTION_TYPES : {}
|
|
128
|
+
|
|
129
|
+
klass::Options.members.filter_map do |field|
|
|
130
|
+
expected = option_types.fetch(field, String)
|
|
131
|
+
next if value[field].is_a?(expected)
|
|
132
|
+
|
|
133
|
+
[field, option_type_failure(field, expected)]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def optional_option_type_errors(klass, value)
|
|
138
|
+
return [] unless klass.const_defined?(:OPTIONAL_OPTION_TYPES)
|
|
139
|
+
|
|
140
|
+
klass::OPTIONAL_OPTION_TYPES.filter_map do |field, expected|
|
|
141
|
+
actual = value[field]
|
|
142
|
+
next if actual.nil? || actual.is_a?(expected)
|
|
143
|
+
|
|
144
|
+
[field, option_type_failure(field, expected, optional: true)]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def option_type_failure(field, expected, optional: false)
|
|
149
|
+
label = {
|
|
150
|
+
Integer => 'an integer',
|
|
151
|
+
String => 'a string',
|
|
152
|
+
Hash => 'a hash'
|
|
153
|
+
}.fetch(expected) { "a #{expected}" }
|
|
154
|
+
suffix = optional ? ' or omitted' : ''
|
|
155
|
+
"`#{field}` must be #{label}#{suffix}"
|
|
156
|
+
end
|
|
118
157
|
end
|
|
119
158
|
|
|
120
159
|
##
|
|
@@ -15,6 +15,11 @@ module Html2rss
|
|
|
15
15
|
LANGUAGE_FORMAT_REGEX = /\A[a-z]{2}(-[A-Z]{2})?\z/
|
|
16
16
|
# Baseline strategy-plan enum (:auto plus concrete RequestService strategies).
|
|
17
17
|
BASE_STRATEGY_OPTIONS = Html2rss::FeedPipeline::StrategyPlan.accepted_names.freeze
|
|
18
|
+
# Controlled vocabulary for catalog-only `directory.topics` (not RSS channel fields).
|
|
19
|
+
DIRECTORY_TOPICS = %w[
|
|
20
|
+
sports energy tech science news entertainment jobs finance
|
|
21
|
+
security travel environment consumer civic product research
|
|
22
|
+
].freeze
|
|
18
23
|
|
|
19
24
|
# Contract for the top-level `channel` section.
|
|
20
25
|
ChannelConfig = Dry::Schema.Params do
|
|
@@ -28,6 +33,11 @@ module Html2rss
|
|
|
28
33
|
optional(:image).maybe(:string, format?: URI_REGEXP)
|
|
29
34
|
end
|
|
30
35
|
|
|
36
|
+
# Contract for catalog-only `directory` metadata (topics for feed directories).
|
|
37
|
+
DirectoryConfig = Dry::Schema.Params do
|
|
38
|
+
optional(:topics).value(:array, min_size?: 1).each(:string, included_in?: DIRECTORY_TOPICS)
|
|
39
|
+
end
|
|
40
|
+
|
|
31
41
|
# Contract for a stylesheet entry in `stylesheets`.
|
|
32
42
|
StylesheetConfig = Dry::Schema.Params do
|
|
33
43
|
required(:href).filled(:string)
|
|
@@ -35,47 +45,40 @@ module Html2rss
|
|
|
35
45
|
optional(:media).maybe(:string)
|
|
36
46
|
end
|
|
37
47
|
|
|
38
|
-
# Contract for
|
|
39
|
-
BrowserlessPreloadClickSelectorConfig = Dry::Schema.Params do
|
|
40
|
-
required(:selector).filled(:string)
|
|
41
|
-
optional(:max_clicks).filled(:integer, gt?: 0)
|
|
42
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Contract for Browserless scroll-preload options.
|
|
46
|
-
BrowserlessPreloadScrollConfig = Dry::Schema.Params do
|
|
47
|
-
optional(:iterations).filled(:integer, gt?: 0)
|
|
48
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Contract for Browserless preload orchestration options.
|
|
52
|
-
BrowserlessPreloadConfig = Dry::Schema.Params do
|
|
53
|
-
optional(:wait_after_ms).filled(:integer, gteq?: 0)
|
|
54
|
-
optional(:click_selectors).array(BrowserlessPreloadClickSelectorConfig)
|
|
55
|
-
optional(:scroll_down).hash(BrowserlessPreloadScrollConfig)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Contract for Browserless-specific request options.
|
|
59
|
-
BrowserlessRequestConfig = Dry::Schema.Params do
|
|
60
|
-
optional(:preload).hash(BrowserlessPreloadConfig)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Contract for Botasaurus-specific request options.
|
|
48
|
+
# Contract for Botasaurus-specific request options (OpenAPI ScrapeRequest minus url).
|
|
64
49
|
BotasaurusRequestConfig = Dry::Schema.Params do
|
|
65
50
|
config.validate_keys = true
|
|
66
51
|
|
|
67
|
-
|
|
68
|
-
|
|
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)
|
|
69
57
|
optional(:wait_for_selector).maybe(:string)
|
|
70
|
-
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
|
+
)
|
|
61
|
+
optional(:scroll).filled(:bool)
|
|
71
62
|
optional(:block_images).filled(:bool)
|
|
72
63
|
optional(:block_images_and_css).filled(:bool)
|
|
64
|
+
optional(:block_trackers).filled(:bool)
|
|
73
65
|
optional(:wait_for_complete_page_load).filled(:bool)
|
|
74
66
|
optional(:headless).filled(:bool)
|
|
75
67
|
optional(:proxy).filled(:string)
|
|
76
68
|
optional(:user_agent).filled(:string)
|
|
77
|
-
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
|
|
78
74
|
optional(:lang).filled(:string)
|
|
75
|
+
optional(:cookies).hash
|
|
76
|
+
optional(:headers).hash
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# JSON Schema export adapter for +BotasaurusRequestConfig+ (params schemas have no json_schema).
|
|
80
|
+
class BotasaurusRequestExport < Dry::Validation::Contract
|
|
81
|
+
params(BotasaurusRequestConfig)
|
|
79
82
|
end
|
|
80
83
|
|
|
81
84
|
# Contract for the top-level `request` section.
|
|
@@ -83,14 +86,14 @@ module Html2rss
|
|
|
83
86
|
optional(:max_redirects).filled(:integer, gteq?: 0)
|
|
84
87
|
optional(:max_requests).filled(:integer, gt?: 0)
|
|
85
88
|
optional(:total_timeout_seconds).filled(:integer, gt?: 0)
|
|
86
|
-
optional(:
|
|
87
|
-
optional(:botasaurus).hash(BotasaurusRequestConfig)
|
|
89
|
+
optional(:botasaurus).hash
|
|
88
90
|
optional(:local_file_path).filled(:string)
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
params do
|
|
92
94
|
optional(:strategy).filled(:symbol)
|
|
93
95
|
required(:channel).hash(ChannelConfig)
|
|
96
|
+
optional(:directory).hash(DirectoryConfig)
|
|
94
97
|
optional(:headers).hash
|
|
95
98
|
optional(:stylesheets).array(StylesheetConfig)
|
|
96
99
|
optional(:auto_source).hash(Config::AutoSourceContract)
|
|
@@ -132,6 +135,17 @@ module Html2rss
|
|
|
132
135
|
errors.each { |error| key(:selectors).failure(error.text) } unless errors.empty?
|
|
133
136
|
end
|
|
134
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
|
+
|
|
135
149
|
# URL validation delegated to Url class
|
|
136
150
|
rule(:channel) do
|
|
137
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
|
@@ -6,19 +6,31 @@ module Html2rss
|
|
|
6
6
|
|
|
7
7
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
8
8
|
class NoFeedItemsExtracted < Error
|
|
9
|
+
# Categories that append shared surface guidance to the empty-feed message.
|
|
10
|
+
SURFACE_HINT_CATEGORIES = %i[app_shell blocked_surface high_entropy_surface].freeze
|
|
11
|
+
|
|
9
12
|
##
|
|
10
13
|
# @param attempts [Array<Hash{Symbol => Object}>] tier attempt diagnostics
|
|
11
|
-
|
|
14
|
+
# @param surface_category [Symbol, nil] optional Scraper surface classification
|
|
15
|
+
def initialize(attempts:, surface_category: nil)
|
|
12
16
|
@attempts = attempts
|
|
17
|
+
@surface_category = surface_category
|
|
13
18
|
super(build_message)
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
# @return [Array<Hash{Symbol => Object}>] tier attempt diagnostics
|
|
17
22
|
attr_reader :attempts
|
|
18
23
|
|
|
24
|
+
# @return [Symbol, nil] surface classification when a response body was available
|
|
25
|
+
attr_reader :surface_category
|
|
26
|
+
|
|
19
27
|
private
|
|
20
28
|
|
|
21
29
|
def build_message
|
|
30
|
+
[base_message, surface_guidance, botasaurus_guidance].compact.join(' ')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def base_message
|
|
22
34
|
summaries = attempts.map do |attempt|
|
|
23
35
|
details = attempt[:items_count].nil? ? "#{attempt[:error_class]} error" : "#{attempt[:items_count]} items"
|
|
24
36
|
"#{attempt[:strategy]} (#{details})"
|
|
@@ -27,5 +39,23 @@ module Html2rss
|
|
|
27
39
|
"No feed items extracted after auto fallback across strategies: #{summaries}. " \
|
|
28
40
|
'Try a more specific listing URL or provide explicit selectors.'
|
|
29
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
|
|
55
|
+
|
|
56
|
+
def botasaurus_configuration_error_attempt?
|
|
57
|
+
error_name = RequestService::BotasaurusConfigurationError.name
|
|
58
|
+
attempts.any? { |attempt| attempt[:error_class] == error_name }
|
|
59
|
+
end
|
|
30
60
|
end
|
|
31
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.
|
|
@@ -6,7 +6,7 @@ module Html2rss
|
|
|
6
6
|
#
|
|
7
7
|
# Owned by {FeedPipeline}; invoked only after {StrategyPlan} resolves +:auto+.
|
|
8
8
|
# Hosted by the pipeline instance: session + extract call back into FeedPipeline.
|
|
9
|
-
class AutoFallback
|
|
9
|
+
class AutoFallback # rubocop:disable Metrics/ClassLength -- attempt state + logging stay co-located
|
|
10
10
|
# Ordered list of concrete request strategies attempted by the :auto plan.
|
|
11
11
|
CHAIN = %i[faraday botasaurus].freeze
|
|
12
12
|
|
|
@@ -15,22 +15,21 @@ module Html2rss
|
|
|
15
15
|
RequestService::UnknownStrategy,
|
|
16
16
|
RequestService::InvalidUrl,
|
|
17
17
|
RequestService::UnsupportedUrlScheme,
|
|
18
|
-
RequestService::UnsupportedResponseContentType,
|
|
19
18
|
RequestService::RequestBudgetExceeded,
|
|
20
19
|
RequestService::PrivateNetworkDenied,
|
|
21
20
|
RequestService::CrossOriginFollowUpDenied,
|
|
22
|
-
RequestService::ResponseTooLarge
|
|
23
|
-
RequestService::BrowserlessConfigurationError
|
|
21
|
+
RequestService::ResponseTooLarge
|
|
24
22
|
].freeze
|
|
25
23
|
|
|
26
24
|
##
|
|
27
25
|
# Mutable run state for one auto-fallback chain: attempts plus selected result.
|
|
28
26
|
class AttemptState
|
|
29
|
-
attr_reader :attempts, :result
|
|
27
|
+
attr_reader :attempts, :result, :last_response
|
|
30
28
|
|
|
31
29
|
def initialize
|
|
32
30
|
@attempts = []
|
|
33
31
|
@result = nil
|
|
32
|
+
@last_response = nil
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
# @return [Boolean] true when a strategy already yielded items
|
|
@@ -53,22 +52,33 @@ module Html2rss
|
|
|
53
52
|
@attempts << attempt
|
|
54
53
|
end
|
|
55
54
|
|
|
55
|
+
# @param response [RequestService::Response] last response that reached extraction
|
|
56
|
+
# @return [void]
|
|
57
|
+
def remember_response(response)
|
|
58
|
+
@last_response = response
|
|
59
|
+
end
|
|
60
|
+
|
|
56
61
|
# @param response [RequestService::Response] successful response
|
|
57
62
|
# @param articles [Array] extracted articles
|
|
58
63
|
# @param dedup_dropped [Integer] articles removed by deduplication
|
|
59
64
|
# @param selected_strategy [Symbol] concrete strategy that produced items
|
|
60
65
|
# @param attempt_count [Integer] number of attempts recorded for this chain
|
|
66
|
+
# @param admission_drops [Hash{String => Integer}] Cleanup drop tallies
|
|
61
67
|
# @return [void]
|
|
62
|
-
|
|
68
|
+
# rubocop:disable Metrics/ParameterLists -- PipelineOutcome kwargs stay co-located
|
|
69
|
+
def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
70
|
+
admission_drops: {})
|
|
63
71
|
@result = PipelineOutcome.new(
|
|
64
72
|
response:,
|
|
65
73
|
articles:,
|
|
66
74
|
dedup_dropped:,
|
|
67
75
|
selected_strategy:,
|
|
68
76
|
attempt_count:,
|
|
69
|
-
strategy_attempts: attempts
|
|
77
|
+
strategy_attempts: attempts,
|
|
78
|
+
admission_drops:
|
|
70
79
|
)
|
|
71
80
|
end
|
|
81
|
+
# rubocop:enable Metrics/ParameterLists
|
|
72
82
|
end
|
|
73
83
|
|
|
74
84
|
##
|
|
@@ -92,7 +102,7 @@ module Html2rss
|
|
|
92
102
|
state = run_attempts
|
|
93
103
|
return state.result if state.succeeded?
|
|
94
104
|
|
|
95
|
-
finalize_failure(attempts: state.attempts)
|
|
105
|
+
finalize_failure(attempts: state.attempts, response: state.last_response)
|
|
96
106
|
end
|
|
97
107
|
|
|
98
108
|
private
|
|
@@ -114,6 +124,9 @@ module Html2rss
|
|
|
114
124
|
return unless response
|
|
115
125
|
|
|
116
126
|
process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
127
|
+
rescue RequestService::UnsupportedResponseContentType => error
|
|
128
|
+
state.record_error(strategy:, error:)
|
|
129
|
+
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
|
|
117
130
|
end
|
|
118
131
|
|
|
119
132
|
def fetch_response(request_session:, strategy:, next_strategy:, state:)
|
|
@@ -126,14 +139,18 @@ module Html2rss
|
|
|
126
139
|
nil
|
|
127
140
|
end
|
|
128
141
|
|
|
129
|
-
def process_response(response:, strategy:, next_strategy:, request_session:, state:)
|
|
130
|
-
articles, dedup_dropped = articles_for(response:, request_session:)
|
|
142
|
+
def process_response(response:, strategy:, next_strategy:, request_session:, state:) # rubocop:disable Metrics/MethodLength -- extract + success path
|
|
143
|
+
articles, dedup_dropped, admission_drops = articles_for(response:, request_session:)
|
|
144
|
+
state.remember_response(response)
|
|
131
145
|
items_count = articles.size
|
|
132
146
|
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
|
|
133
147
|
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
134
148
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
135
149
|
"budget_remaining=#{budget_remaining_label}")
|
|
136
|
-
|
|
150
|
+
if items_count.positive?
|
|
151
|
+
return record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:,
|
|
152
|
+
state:)
|
|
153
|
+
end
|
|
137
154
|
|
|
138
155
|
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
139
156
|
end
|
|
@@ -142,18 +159,24 @@ module Html2rss
|
|
|
142
159
|
pipeline.deduplicated_articles(config:, response:, request_session:)
|
|
143
160
|
end
|
|
144
161
|
|
|
145
|
-
|
|
162
|
+
# rubocop:disable Metrics/ParameterLists -- success kwargs match PipelineOutcome
|
|
163
|
+
def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
|
|
146
164
|
attempt_count = state.attempts.size
|
|
147
|
-
state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy,
|
|
165
|
+
state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy,
|
|
166
|
+
attempt_count:, admission_drops:)
|
|
148
167
|
return unless attempt_count > 1
|
|
149
168
|
|
|
150
169
|
Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
|
|
151
170
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
152
171
|
"budget_remaining=#{budget_remaining_label}")
|
|
153
172
|
end
|
|
173
|
+
# rubocop:enable Metrics/ParameterLists
|
|
154
174
|
|
|
155
|
-
def finalize_failure(attempts:)
|
|
156
|
-
|
|
175
|
+
def finalize_failure(attempts:, response:)
|
|
176
|
+
surface_category = response && AutoSource::Scraper.classify_no_scraper_surface(
|
|
177
|
+
response.parsed_body, body: response.body
|
|
178
|
+
)
|
|
179
|
+
raise NoFeedItemsExtracted.new(attempts:, surface_category:)
|
|
157
180
|
end
|
|
158
181
|
|
|
159
182
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -6,8 +6,7 @@ module Html2rss
|
|
|
6
6
|
# Planner for the runtime request policy and budgets of a feed run.
|
|
7
7
|
#
|
|
8
8
|
# HTTP request slots (pagination, document GETs, strategy fallback) are planned
|
|
9
|
-
#
|
|
10
|
-
# pagination slots.
|
|
9
|
+
# from configuration options.
|
|
11
10
|
class RuntimePolicy
|
|
12
11
|
# Policy plus Budget from one config expansion (shared meters for a feed run).
|
|
13
12
|
Resources = Data.define(:policy, :budget)
|
|
@@ -34,26 +33,18 @@ module Html2rss
|
|
|
34
33
|
policy:,
|
|
35
34
|
budget: RequestService::Budget.new(
|
|
36
35
|
max_requests: policy.max_requests,
|
|
37
|
-
max_interactions: interaction_budget_for(config),
|
|
38
36
|
total_timeout_seconds: policy.total_timeout_seconds
|
|
39
37
|
)
|
|
40
38
|
)
|
|
41
39
|
end
|
|
42
40
|
|
|
43
41
|
##
|
|
44
|
-
# Builds a Budget with
|
|
42
|
+
# Builds a Budget with request slot pools.
|
|
45
43
|
#
|
|
46
44
|
# @param config [Html2rss::Config] validated feed config
|
|
47
45
|
# @return [Html2rss::RequestService::Budget] shared budget for the feed build
|
|
48
46
|
def self.budget_for(config) = resources_for(config).budget
|
|
49
47
|
|
|
50
|
-
##
|
|
51
|
-
# @param config [Html2rss::Config] validated feed config
|
|
52
|
-
# @return [Integer] preload interaction slots derived from browserless preload config
|
|
53
|
-
def self.interaction_budget_for(config)
|
|
54
|
-
browserless_preload_budget_for(config)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
48
|
class << self
|
|
58
49
|
private
|
|
59
50
|
|
|
@@ -64,44 +55,13 @@ module Html2rss
|
|
|
64
55
|
end
|
|
65
56
|
|
|
66
57
|
# Reserve enough HTTP request slots for the initial request plus predictable
|
|
67
|
-
# follow-ups.
|
|
58
|
+
# follow-ups.
|
|
68
59
|
def baseline_request_budget_for(config)
|
|
69
60
|
1 +
|
|
70
61
|
RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
|
|
71
62
|
AutoSource.request_slots_for(config.auto_source) +
|
|
72
63
|
StrategyPlan.resolve(config.strategy).request_slots
|
|
73
64
|
end
|
|
74
|
-
|
|
75
|
-
def browserless_preload_budget_for(config)
|
|
76
|
-
preload = config.request.dig(:browserless, :preload)
|
|
77
|
-
return 0 unless preload
|
|
78
|
-
|
|
79
|
-
top_level_preload_wait_budget(preload) +
|
|
80
|
-
click_selector_preload_budget(preload) +
|
|
81
|
-
scroll_preload_budget(preload)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def top_level_preload_wait_budget(preload)
|
|
85
|
-
preload[:wait_after_ms] ? 2 : 0
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def click_selector_preload_budget(preload)
|
|
89
|
-
preload.fetch(:click_selectors, []).sum { preload_action_budget(_1, :max_clicks) }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def scroll_preload_budget(preload)
|
|
93
|
-
scroll = preload[:scroll_down]
|
|
94
|
-
return 0 unless scroll
|
|
95
|
-
|
|
96
|
-
preload_action_budget(scroll, :iterations)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def preload_action_budget(config, count_key)
|
|
100
|
-
action_count = config.fetch(count_key, 1)
|
|
101
|
-
wait_budget = config[:wait_after_ms] ? action_count : 0
|
|
102
|
-
|
|
103
|
-
action_count + wait_budget
|
|
104
|
-
end
|
|
105
65
|
end
|
|
106
66
|
end
|
|
107
67
|
end
|
|
@@ -41,6 +41,17 @@ module Html2rss
|
|
|
41
41
|
Concrete.new(strategy: normalized)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
##
|
|
45
|
+
# Cheap single-request diagnostic default: +:auto+ → +:faraday+.
|
|
46
|
+
# Does not run {AutoFallback}. Prefer {resolve} for scrape/capture feeds.
|
|
47
|
+
#
|
|
48
|
+
# @param name [Symbol, String, nil] plan name (+nil+ → +:auto+)
|
|
49
|
+
# @return [Symbol] concrete {RequestService} strategy
|
|
50
|
+
def concrete_for_diagnostic(name = AUTO_NAME)
|
|
51
|
+
plan = resolve(name || AUTO_NAME)
|
|
52
|
+
plan.is_a?(Auto) ? RequestService.default_strategy_name : plan.strategy
|
|
53
|
+
end
|
|
54
|
+
|
|
44
55
|
##
|
|
45
56
|
# @param name [Symbol, String, nil] candidate plan name
|
|
46
57
|
# @return [Boolean] whether +name+ is a valid feed-level strategy plan
|