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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. 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
- # dependent on the extractor, validate required fields, (i.e. static, attribute)
92
- case value
93
- when 'attribute'
94
- key(:attribute).failure('`attribute` must be a string') unless values[:attribute].is_a?(String)
95
- when 'static'
96
- key(:static).failure('`static` must be a string') unless values[:static].is_a?(String)
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
- case (name = value[:name])
102
- when 'gsub'
103
- key(:pattern).failure('`pattern` must be a string') unless value[:pattern].is_a?(String)
104
- key(:replacement).failure('`replacement` must be a string') unless value[:replacement].is_a?(String)
105
- when 'substring'
106
- key(:start).failure('`start` must be an integer') unless value[:start].is_a?(Integer)
107
- key(:end).failure('`end` must be an integer or omitted') if !value[:end].nil? && !value[:end].is_a?(Integer)
108
- when 'template'
109
- key(:string).failure('`string` must be a string') unless value[:string].is_a?(String)
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 Browserless click-preload options.
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
- optional(:navigation_mode).filled(:string, included_in?: %w[auto get google_get google_get_bypass])
68
- optional(:max_retries).filled(:integer, gteq?: 0, lteq?: 3)
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(:integer, gt?: 0)
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).value(:array, min_size?: 2, max_size?: 2).each(:integer, gt?: 0)
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(:browserless).hash(BrowserlessRequestConfig)
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?
@@ -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
  #
@@ -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
- def initialize(attempts:)
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
- def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:)
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
- return record_success(response:, strategy:, articles:, dedup_dropped:, state:) if items_count.positive?
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
- def record_success(response:, strategy:, articles:, dedup_dropped:, state:)
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, attempt_count:)
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
- raise NoFeedItemsExtracted.new(attempts:)
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
- # separately from Browserless preload interaction budget so preload cannot steal
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 separate request and interaction slot pools.
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. Preload interactions are planned separately.
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