html2rss 0.27.2 → 0.29.1

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -25
  3. data/html2rss.gemspec +0 -1
  4. data/lib/html2rss/article.rb +1 -2
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +92 -1
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
  8. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
  9. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
  10. data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +6 -4
  13. data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
  14. data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
  15. data/lib/html2rss/auto_source/segmenter.rb +1 -2
  16. data/lib/html2rss/auto_source.rb +32 -11
  17. data/lib/html2rss/batch.rb +169 -0
  18. data/lib/html2rss/capture/README.md +15 -6
  19. data/lib/html2rss/capture.rb +158 -15
  20. data/lib/html2rss/channel.rb +1 -2
  21. data/lib/html2rss/cli/probe_view.rb +66 -0
  22. data/lib/html2rss/cli/render.rb +124 -0
  23. data/lib/html2rss/cli/validate.rb +91 -0
  24. data/lib/html2rss/cli.rb +318 -193
  25. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  26. data/lib/html2rss/config/request_headers.rb +4 -14
  27. data/lib/html2rss/config/schema.rb +7 -5
  28. data/lib/html2rss/config/validator.rb +1 -0
  29. data/lib/html2rss/config.rb +64 -18
  30. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  31. data/lib/html2rss/feed_builder/rss.rb +1 -2
  32. data/lib/html2rss/feed_pipeline/README.md +7 -5
  33. data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
  34. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  35. data/lib/html2rss/feed_pipeline.rb +48 -21
  36. data/lib/html2rss/feed_resolution/README.md +52 -0
  37. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  38. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  39. data/lib/html2rss/feed_resolution/options.rb +31 -0
  40. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  41. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  42. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  43. data/lib/html2rss/feed_resolution.rb +268 -0
  44. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  45. data/lib/html2rss/html/article_extractor.rb +4 -9
  46. data/lib/html2rss/html/feed_link.rb +4 -2
  47. data/lib/html2rss/html/navigator.rb +4 -39
  48. data/lib/html2rss/html/probe.rb +82 -0
  49. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  50. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  51. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  52. data/lib/html2rss/mcp/README.md +80 -0
  53. data/lib/html2rss/mcp/contract.rb +135 -13
  54. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  55. data/lib/html2rss/mcp/outcome.rb +112 -40
  56. data/lib/html2rss/mcp/runtime.rb +45 -0
  57. data/lib/html2rss/mcp/server/tools.rb +277 -0
  58. data/lib/html2rss/mcp/server.rb +91 -232
  59. data/lib/html2rss/mcp.rb +3 -0
  60. data/lib/html2rss/page_recon/README.md +69 -0
  61. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  62. data/lib/html2rss/page_recon.rb +297 -0
  63. data/lib/html2rss/recon.rb +252 -0
  64. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  65. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  66. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  67. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  68. data/lib/html2rss/request_service/policy.rb +1 -2
  69. data/lib/html2rss/request_service/response.rb +27 -4
  70. data/lib/html2rss/request_service/strategy.rb +1 -2
  71. data/lib/html2rss/request_service.rb +15 -1
  72. data/lib/html2rss/request_session.rb +4 -2
  73. data/lib/html2rss/scrape_target.rb +24 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  75. data/lib/html2rss/selectors/post_processors.rb +0 -1
  76. data/lib/html2rss/selectors.rb +33 -10
  77. data/lib/html2rss/sst/attrs.rb +1 -2
  78. data/lib/html2rss/sst/normalizer.rb +49 -11
  79. data/lib/html2rss/status.rb +48 -24
  80. data/lib/html2rss/surface_category.rb +63 -0
  81. data/lib/html2rss/syndication/README.md +21 -0
  82. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  83. data/lib/html2rss/syndication/discovery.rb +215 -0
  84. data/lib/html2rss/syndication/parser.rb +137 -0
  85. data/lib/html2rss/syndication.rb +10 -0
  86. data/lib/html2rss/test/enhance_audit.rb +192 -0
  87. data/lib/html2rss/test.rb +458 -0
  88. data/lib/html2rss/url.rb +23 -2
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +157 -35
  91. data/schema/html2rss-config.schema.json +47 -29
  92. metadata +34 -18
  93. data/lib/html2rss/mcp/inspect.rb +0 -241
  94. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
@@ -48,7 +48,7 @@ module Html2rss
48
48
  ##
49
49
  # Orchestrates schema assembly from runtime validator contracts plus
50
50
  # client-facing overlays.
51
- # rubocop:disable Metrics/ClassLength -- overlay assembly stays in one builder
51
+ # rubocop:disable-next Metrics/ClassLength -- overlay assembly stays in one builder
52
52
  class Builder
53
53
  class << self
54
54
  # @return [Hash{String => Object}] fully assembled JSON schema hash
@@ -110,9 +110,13 @@ module Html2rss
110
110
  registry.keys.sort.to_h { |name| [name.to_s, registry.fetch(name).schema_doc] }
111
111
  end
112
112
 
113
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
113
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
114
114
  def overlay
115
115
  items_schema = Html2rss::Config::SelectorsValidator::Items.new.schema.json_schema(loose: true)
116
+ items_schema[:properties][:enhance] = items_schema.fetch(:properties).fetch(:enhance).merge(
117
+ description: 'List-card enrichment: run Html::ArticleExtractor on each matched item node ' \
118
+ 'to fill missing fields from the card HTML.'
119
+ )
116
120
  items_schema[:properties][:pagination] = {
117
121
  description: 'Pagination configuration or maximum page count integer.',
118
122
  oneOf: [
@@ -166,7 +170,7 @@ module Html2rss
166
170
  description: 'Selectors used to extract article attributes.',
167
171
  properties: {
168
172
  items: items_schema.merge(
169
- description: 'Defines the items selector and optional enhancement settings.'
173
+ description: 'Defines the items selector and list-card enhance settings.'
170
174
  ),
171
175
  enclosure: enclosure_schema.merge(
172
176
  description: 'Describes enclosure extraction settings.'
@@ -183,7 +187,6 @@ module Html2rss
183
187
  }
184
188
  }
185
189
  end
186
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
187
190
 
188
191
  def reference_array(description)
189
192
  {
@@ -197,7 +200,6 @@ module Html2rss
197
200
  }
198
201
  end
199
202
  end
200
- # rubocop:enable Metrics/ClassLength
201
203
 
202
204
  ##
203
205
  # Wires extractor / post_process to thin oneOf $refs into the registry catalog.
@@ -19,6 +19,7 @@ module Html2rss
19
19
  DIRECTORY_TOPICS = %w[
20
20
  sports energy tech science news entertainment jobs finance
21
21
  security travel environment consumer civic product research
22
+ health culture education rights transport
22
23
  ].freeze
23
24
 
24
25
  # Contract for the top-level `channel` section.
@@ -16,6 +16,32 @@ module Html2rss
16
16
  # Sentinel to differentiate omitted params from explicit `nil`.
17
17
  UNSET = Object.new.freeze
18
18
 
19
+ ValidationResult = Data.define(:success, :errors_hash)
20
+
21
+ ##
22
+ # Duck-compatible with +Dry::Validation::Result+ for resolve-time outcomes
23
+ # (+#success?+, +#errors#to_h+). Used for parse failures so callers never see a Struct snowflake.
24
+ class ValidationResult
25
+ ##
26
+ # @param message [String]
27
+ # @return [Html2rss::Config::ValidationResult]
28
+ def self.parse_failure(message)
29
+ new(success: false, errors_hash: { parse: [message] })
30
+ end
31
+
32
+ ##
33
+ # @return [Boolean]
34
+ def success? = success
35
+
36
+ ##
37
+ # @return [self]
38
+ def errors = self
39
+
40
+ ##
41
+ # @return [Hash]
42
+ def to_h = errors_hash
43
+ end
44
+
19
45
  class << self
20
46
  ##
21
47
  # Returns the exported JSON Schema for html2rss configuration.
@@ -34,6 +60,22 @@ module Html2rss
34
60
  pretty ? JSON.pretty_generate(json_schema) : JSON.generate(json_schema)
35
61
  end
36
62
 
63
+ ##
64
+ # Resolves a Hash, YAML file path, or YAML string to a working config Hash and validates it.
65
+ # The returned Hash is a deep copy — callers may stamp strategy/params without mutating input.
66
+ #
67
+ # @param config_input [Hash, String] config hash, YAML string, or file path
68
+ # @param feed_name [String, nil] optional feed name for multi-feed files
69
+ # @param params [Hash] dynamic feed params
70
+ # @return [Array(Hash, Dry::Validation::Result, Html2rss::Config::ValidationResult)]
71
+ def resolve_and_validate(config_input, feed_name: nil, params: {})
72
+ param_arg = params.empty? ? UNSET : params
73
+ working = HashUtil.deep_dup(resolve_raw_hash(config_input, feed_name))
74
+ [working, validate(working, params: param_arg)]
75
+ rescue StandardError => error
76
+ [{}, ValidationResult.parse_failure(error.message)]
77
+ end
78
+
37
79
  ##
38
80
  # Validates a configuration hash with the runtime validator.
39
81
  #
@@ -59,22 +101,10 @@ module Html2rss
59
101
  Schema.path
60
102
  end
61
103
 
62
- ##
63
- # Loads and validates a YAML configuration file.
64
- #
65
- # @param file [String] the YAML file to load
66
- # @param feed_name [String, nil] optional feed name for multi-feed files
67
- # @param multiple_feeds_key [Symbol] key under which multiple feeds are defined
68
- # @param params [Hash{Symbol => Object, Hash{String => Object, nil}}] dynamic parameters for string formatting
69
- # @return [Dry::Validation::Result] validation result after defaults are applied
70
- def validate_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS, params: UNSET)
71
- validate(load_yaml(file, feed_name, multiple_feeds_key:), params:)
72
- end
73
-
74
104
  ##
75
105
  # Serializes a configuration hash to string-key YAML.
76
106
  #
77
- # This is the single serializer for CLI capture and MCP +capture_config+.
107
+ # This is the single serializer for CLI capture and MCP +capture+.
78
108
  #
79
109
  # @param hash [Hash] configuration hash (symbol or string keys)
80
110
  # @return [String] YAML document without Ruby symbol-key prefixes
@@ -109,7 +139,7 @@ module Html2rss
109
139
  # @param multiple_feeds_key [Symbol] the key under which multiple feeds are defined.
110
140
  # @return [Hash{Symbol => Object}] the configuration hash.
111
141
  # @raise [ArgumentError] if the file doesn't exist or feed is not found.
112
- # rubocop:disable Metrics/MethodLength
142
+ # rubocop:disable-next Metrics/MethodLength
113
143
  def load_yaml(file, feed_name = nil, multiple_feeds_key: MultipleFeedsConfig::CONFIG_KEY_FEEDS)
114
144
  raise ArgumentError, "File '#{file}' does not exist" unless File.exist?(file)
115
145
  raise ArgumentError, "`#{multiple_feeds_key}` is a reserved feed name" if feed_name == multiple_feeds_key
@@ -129,7 +159,6 @@ module Html2rss
129
159
 
130
160
  MultipleFeedsConfig.to_single_feed(config, yaml, multiple_feeds_key:)
131
161
  end
132
- # rubocop:enable Metrics/MethodLength
133
162
 
134
163
  ##
135
164
  # Processes the provided configuration hash, applying dynamic parameters if given,
@@ -216,6 +245,20 @@ module Html2rss
216
245
  def prepare_for_validation(config)
217
246
  Config::Preparer.new.call(HashUtil.deep_dup(config))
218
247
  end
248
+
249
+ # @param config_input [Hash, String]
250
+ # @param feed_name [String, nil]
251
+ # @return [Hash]
252
+ def resolve_raw_hash(config_input, feed_name)
253
+ if config_input.is_a?(Hash)
254
+ config_input
255
+ elsif File.file?(config_input.to_s)
256
+ load_yaml(config_input.to_s, feed_name)
257
+ else
258
+ from_yaml(config_input.to_s)
259
+ end
260
+ end
261
+ private :resolve_raw_hash
219
262
  end
220
263
 
221
264
  ##
@@ -264,8 +307,12 @@ module Html2rss
264
307
  def headers = config[:headers]
265
308
  # @return [Hash{Symbol => Object}] channel configuration
266
309
  def channel = config[:channel]
267
- # @return [String] source channel URL
310
+ ##
311
+ # Source channel URL (also the default scrape URL).
312
+ #
313
+ # @return [String]
268
314
  def url = config.dig(:channel, :url)
315
+
269
316
  # @return [String, nil] configured channel time zone
270
317
  def time_zone = config.dig(:channel, :time_zone)
271
318
 
@@ -322,8 +369,7 @@ module Html2rss
322
369
  def normalized_headers(validated_config)
323
370
  validated_config[:headers] = RequestHeaders.normalize(
324
371
  validated_config[:headers],
325
- channel_language: validated_config.dig(:channel, :language),
326
- url: validated_config.dig(:channel, :url)
372
+ channel_language: validated_config.dig(:channel, :language)
327
373
  )
328
374
  validated_config
329
375
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module Html2rss
7
+ ##
8
+ # CLI runtime health checks for optional scrape dependencies.
9
+ module Doctor
10
+ ##
11
+ # Preflight checks for Botasaurus scrape-api connectivity.
12
+ module Botasaurus
13
+ ##
14
+ # One named doctor check outcome.
15
+ Check = Data.define(:name, :ok, :detail) do
16
+ ##
17
+ # @return [Hash{Symbol => Object}]
18
+ def to_h
19
+ { name: name.to_s, ok:, detail: }
20
+ end
21
+ end
22
+
23
+ ##
24
+ # Aggregate doctor command outcome.
25
+ Result = Data.define(:ok, :checks, :message) do
26
+ ##
27
+ # @return [Hash{Symbol => Object}]
28
+ def to_h
29
+ { ok:, message:, checks: checks.map(&:to_h) }
30
+ end
31
+ end
32
+
33
+ module_function
34
+
35
+ ENV_VAR = 'BOTASAURUS_SCRAPER_URL'
36
+ private_constant :ENV_VAR
37
+
38
+ ##
39
+ # @param sample_url [String, nil] optional URL for a sample scrape smoke test
40
+ # @return [Result]
41
+ def call(sample_url: nil) # rubocop:disable Metrics/AbcSize
42
+ env = env_check
43
+ checks = [env_check_record(env)]
44
+ return missing_env_failure(checks) unless checks.first.ok
45
+
46
+ checks << health_check_record(env[:base_url])
47
+ return failure(checks, 'Botasaurus health check failed.') unless checks.last.ok
48
+
49
+ checks << sample_scrape_record(sample_url) if sample_url
50
+ return failure(checks, 'Sample scrape failed.') if sample_url && !checks.last.ok
51
+
52
+ Log.info('doctor botasaurus: preflight passed')
53
+ Result.new(ok: true, checks:, message: 'Botasaurus preflight passed.')
54
+ end
55
+
56
+ ##
57
+ # @return [Hash{Symbol => Object}]
58
+ def env_check
59
+ base = ENV.fetch(ENV_VAR, '').strip
60
+ {
61
+ configured: MCP::Runtime.botasaurus_configured?,
62
+ var: ENV_VAR,
63
+ base_url: base.empty? ? nil : base
64
+ }
65
+ end
66
+
67
+ ##
68
+ # @param base_url [String]
69
+ # @return [Hash{Symbol => Object}]
70
+ def health_check(base_url)
71
+ uri = Url.for_channel(base_url)
72
+ client = Faraday.new(url: uri.to_s.chomp('/'), request: { timeout: 5 })
73
+ response = client.get('/health')
74
+ body = JSON.parse(response.body)
75
+ { ok: response.status == 200, status: response.status, version: body['version'] }
76
+ rescue StandardError => error
77
+ { ok: false, error: "#{error.class}: #{error.message}" }
78
+ end
79
+
80
+ ##
81
+ # @param url [String]
82
+ # @return [Hash{Symbol => Object}]
83
+ def sample_scrape(url)
84
+ wire = PageRecon::Diagnostics.call(url:, strategy: :botasaurus).to_wire_h
85
+ {
86
+ ok: wire[:status].to_i.between?(200, 399),
87
+ status: wire[:status],
88
+ articles_count: wire[:articles_count]
89
+ }
90
+ rescue StandardError => error
91
+ { ok: false, error: "#{error.class}: #{error.message}" }
92
+ end
93
+
94
+ def env_check_record(env = env_check)
95
+ Check.new(name: :env, ok: env[:configured], detail: { var: env[:var], base_url_set: !env[:base_url].nil? })
96
+ end
97
+ module_function :env_check_record
98
+ private_class_method :env_check_record
99
+
100
+ def health_check_record(base_url)
101
+ return Check.new(name: :health, ok: false, detail: { error: 'missing base URL' }) unless base_url
102
+
103
+ health = health_check(base_url)
104
+ Check.new(name: :health, ok: health[:ok], detail: health)
105
+ end
106
+ module_function :health_check_record
107
+ private_class_method :health_check_record
108
+
109
+ def sample_scrape_record(url)
110
+ sample = sample_scrape(url)
111
+ Check.new(name: :sample_scrape, ok: sample[:ok], detail: sample)
112
+ end
113
+ module_function :sample_scrape_record
114
+ private_class_method :sample_scrape_record
115
+
116
+ def missing_env_failure(checks)
117
+ Log.info("doctor botasaurus: #{ENV_VAR} unset")
118
+ failure(checks, "Set #{ENV_VAR} to the scrape-api base URL.")
119
+ end
120
+ module_function :missing_env_failure
121
+ private_class_method :missing_env_failure
122
+
123
+ def failure(checks, message)
124
+ Result.new(ok: false, checks:, message:)
125
+ end
126
+ module_function :failure
127
+ private_class_method :failure
128
+ end
129
+ end
130
+ end
@@ -96,7 +96,7 @@ module Html2rss
96
96
  @stylesheets.map { |style| Stylesheet.new(**style) }
97
97
  end
98
98
 
99
- # rubocop:disable Metrics/AbcSize
99
+ # rubocop:disable-next Metrics/AbcSize
100
100
  def make_channel(maker)
101
101
  channel_maker = maker.channel
102
102
  %i[language title description ttl].each do |key|
@@ -111,7 +111,6 @@ module Html2rss
111
111
 
112
112
  make_image(maker.image) if channel.image
113
113
  end
114
- # rubocop:enable Metrics/AbcSize
115
114
 
116
115
  def make_image(image_maker)
117
116
  image_maker.url = channel.image.to_s
@@ -1,6 +1,6 @@
1
1
  # FeedPipeline — `auto` request strategy
2
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`.
3
+ `:auto` is the default request plan for feed builds (`auto_source`, `auto_json_feed`, Capture, and MCP `scrape` / `capture`). `FeedPipeline::StrategyPlan` resolves it; `FeedPipeline::AutoFallback` executes `AutoFallback::CHAIN`.
4
4
 
5
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
6
 
@@ -11,14 +11,16 @@ Use `:auto` when you want Faraday first and a browser-backed hop only if that fe
11
11
  1. **Faraday** — plain HTTP (faster, cheaper).
12
12
  2. **Botasaurus** — attempted when Faraday raises a fallback-eligible error (for example `BlockedSurfaceDetected` or timeout) or extracts zero feed items.
13
13
 
14
+ Before escalating Faraday → Botasaurus on a **weak** auto-source extract (empty / below floor / high-entropy / app-shell / unsupported — not blocked), `FeedResolution` may probe up to five same-origin listing or native-feed candidates and rewrite the effective scrape URL via {ScrapeTarget}. Botasaurus then uses the resolved URL. Direct RSS/Atom entry URLs are parsed via `Syndication::Parser` without HTML AutoSource.
15
+
14
16
  There is no Browserless / Puppeteer-in-gem tier. Pin `botasaurus` when you want browser rendering without Faraday first. Botasaurus needs `BOTASAURUS_SCRAPER_URL`.
15
17
 
16
18
  ## Surfaces
17
19
 
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) |
20
+ | Surface | `:auto` behavior |
21
+ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
22
+ | Gem / CLI `apply`, MCP `scrape`, Capture | Full AutoFallback chain (`faraday` → `botasaurus`) |
23
+ | MCP `inspect` | Cheap diagnostic: `StrategyPlan.concrete_for_diagnostic` maps `auto` to Faraday (pin `botasaurus` when you need browser rendering) |
22
24
 
23
25
  ## Fallback vs abort
24
26
 
@@ -10,6 +10,9 @@ module Html2rss
10
10
  # Ordered list of concrete request strategies attempted by the :auto plan.
11
11
  CHAIN = %i[faraday botasaurus].freeze
12
12
 
13
+ # Deterministic HTTP statuses that indicate permanent failure rather than anti-bot challenges.
14
+ DETERMINISTIC_HTTP_STATUSES = Set[400, 404, 410, 422, 451].freeze
15
+
13
16
  # Error classes that should abort auto fallback immediately.
14
17
  NON_FALLBACK_ERRORS = [
15
18
  RequestService::UnknownStrategy,
@@ -18,23 +21,49 @@ module Html2rss
18
21
  RequestService::RequestBudgetExceeded,
19
22
  RequestService::PrivateNetworkDenied,
20
23
  RequestService::CrossOriginFollowUpDenied,
21
- RequestService::ResponseTooLarge
24
+ RequestService::ResponseTooLarge,
25
+ RequestService::RedirectLimitReached
22
26
  ].freeze
23
27
 
24
28
  ##
25
29
  # Mutable run state for one auto-fallback chain: attempts plus selected result.
26
30
  class AttemptState
27
- attr_reader :attempts, :result, :last_response
31
+ attr_reader :attempts, :result, :last_response, :entry_resolution
28
32
 
29
33
  def initialize
30
34
  @attempts = []
31
35
  @result = nil
32
36
  @last_response = nil
37
+ @entry_resolution = nil
38
+ @resolution_tried = false
39
+ @aborted = false
33
40
  end
34
41
 
35
42
  # @return [Boolean] true when a strategy already yielded items
36
43
  def succeeded? = !result.nil?
37
44
 
45
+ # @return [Boolean] whether attempts were aborted due to deterministic errors
46
+ def aborted? = @aborted
47
+
48
+ # @return [void]
49
+ def abort!
50
+ @aborted = true
51
+ end
52
+
53
+ # @return [Boolean] whether entry resolution already ran for this chain
54
+ def resolution_tried? = @resolution_tried
55
+
56
+ # @return [void]
57
+ def mark_resolution_tried!
58
+ @resolution_tried = true
59
+ end
60
+
61
+ # @param result [Html2rss::FeedResolution::Result]
62
+ # @return [void]
63
+ def remember_entry_resolution(result)
64
+ @entry_resolution = FeedResolution::Diag.from_result(result)
65
+ end
66
+
38
67
  # @param strategy [Symbol] strategy that raised
39
68
  # @param error [Exception] caught error
40
69
  # @return [void]
@@ -64,10 +93,11 @@ module Html2rss
64
93
  # @param selected_strategy [Symbol] concrete strategy that produced items
65
94
  # @param attempt_count [Integer] number of attempts recorded for this chain
66
95
  # @param admission_drops [Hash{String => Integer}] Cleanup drop tallies
96
+ # @param scrape_target [Html2rss::ScrapeTarget]
67
97
  # @return [void]
68
- # rubocop:disable Metrics/ParameterLists -- PipelineOutcome kwargs stay co-located
98
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- PipelineOutcome kwargs stay co-located
69
99
  def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:,
70
- admission_drops: {})
100
+ scrape_target:, admission_drops: {})
71
101
  @result = PipelineOutcome.new(
72
102
  response:,
73
103
  articles:,
@@ -75,10 +105,11 @@ module Html2rss
75
105
  selected_strategy:,
76
106
  attempt_count:,
77
107
  strategy_attempts: attempts,
78
- admission_drops:
108
+ admission_drops:,
109
+ scrape_target:,
110
+ entry_resolution:
79
111
  )
80
112
  end
81
- # rubocop:enable Metrics/ParameterLists
82
113
  end
83
114
 
84
115
  ##
@@ -94,6 +125,7 @@ module Html2rss
94
125
  @pipeline = pipeline
95
126
  @config = config
96
127
  @resources = resources
128
+ @scrape_target = ScrapeTarget.from_config(config)
97
129
  end
98
130
 
99
131
  ##
@@ -107,19 +139,21 @@ module Html2rss
107
139
 
108
140
  private
109
141
 
110
- attr_reader :strategies, :budget, :pipeline, :config, :resources
142
+ attr_reader :strategies, :budget, :pipeline, :config, :resources, :scrape_target
111
143
 
112
144
  def run_attempts
113
145
  AttemptState.new.tap do |state|
114
146
  strategies.each_with_index do |strategy, index|
115
147
  attempt(strategy:, next_strategy: strategies[index + 1], state:)
116
- break if state.succeeded?
148
+ break if state.succeeded? || state.aborted?
117
149
  end
118
150
  end
119
151
  end
120
152
 
121
153
  def attempt(strategy:, next_strategy:, state:)
122
- request_session = pipeline.request_session_for(config, strategy:, resources:)
154
+ request_session = pipeline.request_session_for(
155
+ config, strategy:, resources:, scrape_url: scrape_target.effective_url
156
+ )
123
157
  response = fetch_response(request_session:, strategy:, next_strategy:, state:)
124
158
  return unless response
125
159
 
@@ -139,7 +173,8 @@ module Html2rss
139
173
  nil
140
174
  end
141
175
 
142
- def process_response(response:, strategy:, next_strategy:, request_session:, state:) # rubocop:disable Metrics/MethodLength -- extract + success path
176
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
177
+ def process_response(response:, strategy:, next_strategy:, request_session:, state:)
143
178
  articles, dedup_dropped, admission_drops = articles_for(response:, request_session:)
144
179
  state.remember_response(response)
145
180
  items_count = articles.size
@@ -147,39 +182,64 @@ module Html2rss
147
182
  Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
148
183
  "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
149
184
  "budget_remaining=#{budget_remaining_label}")
185
+
186
+ if (outcome = FeedResolution.try_apply!(
187
+ pipeline:, config:, response:, session: request_session, strategy:, resources:,
188
+ articles:, scrape_target:, state:, budget:
189
+ ))
190
+ @scrape_target = outcome.scrape_target
191
+ return
192
+ end
193
+
150
194
  if items_count.positive?
151
195
  return record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:,
152
196
  state:)
153
197
  end
154
198
 
199
+ if deterministic_status?(response)
200
+ state.abort!
201
+ Log.debug("#{self.class}: deterministic HTTP #{response.status}; aborting auto fallback")
202
+ return
203
+ end
204
+
155
205
  log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
156
206
  end
157
207
 
208
+ def deterministic_status?(response)
209
+ DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
210
+ end
211
+
158
212
  def articles_for(response:, request_session:)
159
213
  pipeline.deduplicated_articles(config:, response:, request_session:)
160
214
  end
161
215
 
162
- # rubocop:disable Metrics/ParameterLists -- success kwargs match PipelineOutcome
216
+ # rubocop:disable-next Metrics/ParameterLists -- success kwargs match PipelineOutcome
163
217
  def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
164
218
  attempt_count = state.attempts.size
165
- state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy,
166
- attempt_count:, admission_drops:)
219
+ state.succeed!(
220
+ response:, articles:, dedup_dropped:, selected_strategy: strategy,
221
+ attempt_count:, admission_drops:,
222
+ scrape_target:
223
+ )
167
224
  return unless attempt_count > 1
168
225
 
169
226
  Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
170
227
  "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
171
228
  "budget_remaining=#{budget_remaining_label}")
172
229
  end
173
- # rubocop:enable Metrics/ParameterLists
174
230
 
175
231
  def finalize_failure(attempts:, response:)
176
- surface_category = response && AutoSource::Scraper.classify_no_scraper_surface(
177
- response.parsed_body, body: response.body
178
- )
232
+ surface_category = surface_category_for(response)
179
233
  raise NoFeedItemsExtracted.new(attempts:, surface_category:)
180
234
  end
181
235
 
182
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
236
+ def surface_category_for(response)
237
+ return unless response
238
+
239
+ PageRecon.surface_category_for(response:, url: response.url)
240
+ end
241
+
242
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
183
243
  def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
184
244
  host = request_session.url.host
185
245
  detail = "host=#{host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
@@ -193,7 +253,6 @@ module Html2rss
193
253
  end
194
254
  Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
195
255
  end
196
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
197
256
 
198
257
  def log_info_fallback_zero_items(strategy:, next_strategy:, response:)
199
258
  Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} after zero extracted items " \
@@ -60,6 +60,7 @@ module Html2rss
60
60
  1 +
61
61
  RequestSession::Pager.request_slots_for(config.selectors&.dig(:items, :pagination)) +
62
62
  AutoSource.request_slots_for(config.auto_source) +
63
+ FeedResolution.request_slots_for(config.auto_source) +
63
64
  StrategyPlan.resolve(config.strategy).request_slots
64
65
  end
65
66
  end