html2rss 0.27.2 → 0.28.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/README.md +4 -1
  3. data/lib/html2rss/auto_source/scraper/native_feed.rb +90 -0
  4. data/lib/html2rss/auto_source/scraper.rb +4 -1
  5. data/lib/html2rss/auto_source.rb +7 -0
  6. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  7. data/lib/html2rss/config.rb +10 -1
  8. data/lib/html2rss/feed_pipeline/README.md +2 -0
  9. data/lib/html2rss/feed_pipeline/auto_fallback.rb +53 -13
  10. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  11. data/lib/html2rss/feed_pipeline.rb +24 -8
  12. data/lib/html2rss/feed_resolution/README.md +52 -0
  13. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  14. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  15. data/lib/html2rss/feed_resolution/options.rb +31 -0
  16. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  17. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  18. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  19. data/lib/html2rss/feed_resolution.rb +274 -0
  20. data/lib/html2rss/mcp/inspect.rb +18 -121
  21. data/lib/html2rss/mcp/outcome.rb +4 -2
  22. data/lib/html2rss/page_recon.rb +243 -0
  23. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  24. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  25. data/lib/html2rss/request_service/response.rb +26 -2
  26. data/lib/html2rss/request_service.rb +13 -1
  27. data/lib/html2rss/request_session.rb +5 -2
  28. data/lib/html2rss/scrape_target.rb +24 -0
  29. data/lib/html2rss/status.rb +49 -22
  30. data/lib/html2rss/surface_category.rb +63 -0
  31. data/lib/html2rss/syndication/README.md +21 -0
  32. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  33. data/lib/html2rss/syndication/discovery.rb +217 -0
  34. data/lib/html2rss/syndication/parser.rb +137 -0
  35. data/lib/html2rss/syndication.rb +10 -0
  36. data/lib/html2rss/url.rb +22 -0
  37. data/lib/html2rss/version.rb +1 -1
  38. data/schema/html2rss-config.schema.json +38 -0
  39. metadata +19 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09b4a391aa87101ab6612c07c0c434b9ebc6198fc3bed8c14c2f8b90bd61bc6a'
4
- data.tar.gz: d7fac305311719a6b131be4e89fe78919aef1b45c763a22c471b24583b41d7fa
3
+ metadata.gz: 38cdc153241c05684ee40477e4df06ca2c88f013c971387ddd0ee6d8163c178a
4
+ data.tar.gz: eda1c02f0199568f8f71d24b22480b5eaa9a83c7e48c23678e9695e98e2dab8f
5
5
  SHA512:
6
- metadata.gz: d5fb2cdfc2b95e64de5ac42ffbe1479c15fe63b01f4b922d721d3099ffdd418c9293a1672217838612d5cf4f7bbc2e153cf83ffbcc85e8f30bf8a1a17bb20a79
7
- data.tar.gz: a9f51aa30dfdf8e3e222ed1a49216e857cd056f473408273ad3781e862d743f657a67473fe691423e1fb1252fbe2935ee6eb1cef009d3b2662d03b66f7bdb90c
6
+ metadata.gz: 5e063256aa063027d53986e38703cc0065d12e7aaee45cdfa7724a724f599a0f645e836a0573c9633865d007532bb82c3c2a6632984c5385dcc6fafd5ed0409d
7
+ data.tar.gz: 146ab91ee7925fc678ecdbe16895bd830bf9d7620bfc5239ccf12e109ef0913142da27f77c1be34f839cce350f51552ebcf163a5c92448c0b836b6a9fd8d7e4f
@@ -10,9 +10,10 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
10
10
 
11
11
  ## Live flow
12
12
 
13
- 1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML).
13
+ 1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
14
14
  2. **Scraper tiers** — Enabled scrapers that claim the page (shallow `articles?` or instance `extractable?`) run in `Scraper::SCRAPER_TIERS` order. Merge within a tier, then stop when enough articles survive Cleanup:
15
15
 
16
+ 0. Native feed: NativeFeed (head alternates + path discovery → RSS/Atom parse)
16
17
  1. In-page structured: Schema, Microdata, Microformats2, JsonState, XhrArticles
17
18
  2. Follow-up IO: WordPress API, Sitemap, MetaOembed
18
19
  3. Heuristic: SemanticHtml
@@ -25,6 +26,8 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
25
26
 
26
27
  5. **Cleanup** — Merge, dedupe, hard-exclude non-article destinations (via `PathClassifier` facts), drop junk titles, and trim to `limit`. Html is skipped when earlier tiers already admitted clean items.
27
28
 
29
+ 6. **Entry URL resolution** (pipeline, not this class) — when AutoFallback sees a weak homepage extract, `FeedResolution` may rewrite the scrape URL to a listing/feed before escalating strategies. See {Html2rss::FeedPipeline} and {Html2rss::FeedResolution}.
30
+
28
31
  Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (repeated tag paths), `:cluster` (class/structure grids for anchorless cards). Scoring ranks and demotes; `LinkDestination::NoisePolicy` owns content-anchor eligibility. Cleanup owns feed-item admission.
29
32
 
30
33
  ## Nokogiri vs SST boundaries
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Scraper
6
+ ##
7
+ # Promotes a page's native RSS/Atom feed via {Syndication::Discovery} + {Syndication::Parser}.
8
+ class NativeFeed
9
+ include Enumerable
10
+
11
+ # @return [Symbol] scraper config key
12
+ def self.options_key = :native_feed
13
+
14
+ ##
15
+ # @param _opts [Hash] unused options
16
+ # @return [Integer] follow-up request slots (discovery probes + feed fetch share the budget)
17
+ def self.request_slots(_opts = {})
18
+ 1
19
+ end
20
+
21
+ ##
22
+ # Shallow claim: head advertises a syndication alternate, or body looks feed-capable
23
+ # enough that path discovery is worth a follow-up slot.
24
+ #
25
+ # @param parsed_body [Nokogiri::HTML::Document, nil]
26
+ # @return [Boolean]
27
+ def self.articles?(parsed_body)
28
+ return false unless parsed_body.is_a?(Nokogiri::HTML::Document)
29
+
30
+ return true if ::Html2rss::Html::FeedLink.from_document(parsed_body).any?
31
+
32
+ parsed_body.css('head link[rel~="alternate"][href]').any? do |node|
33
+ href = node['href'].to_s
34
+ type = node['type'].to_s.downcase
35
+ type.include?('rss') || type.include?('atom') || href.match?(/rss|atom|feed|\.xml/i)
36
+ end
37
+ end
38
+
39
+ ##
40
+ # @param parsed_body [Nokogiri::HTML::Document]
41
+ # @param url [String, Html2rss::Url]
42
+ # @param request_session [Html2rss::RequestSession, nil]
43
+ # @param _opts [Hash]
44
+ # @option _opts [Object] :_reserved reserved for future scraper-specific options
45
+ def initialize(parsed_body, url:, request_session: nil, **_opts)
46
+ @parsed_body = parsed_body
47
+ @url = Html2rss::Url.from_absolute(url)
48
+ @request_session = request_session
49
+ end
50
+
51
+ ##
52
+ # @yieldparam article [Hash{Symbol => Object}]
53
+ # @return [Enumerator, void]
54
+ def each(&)
55
+ return enum_for(:each) unless block_given?
56
+
57
+ articles = fetch_articles
58
+ if articles.empty?
59
+ Log.info("#{self.class}: host=#{url.host} item_count=0 fallback=true")
60
+ return
61
+ end
62
+
63
+ Log.info("#{self.class}: host=#{url.host} item_count=#{articles.size} fallback=false")
64
+ articles.each(&)
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :parsed_body, :url, :request_session
70
+
71
+ def fetch_articles # rubocop:disable Metrics/MethodLength -- discovery + parse path
72
+ return [] unless request_session
73
+
74
+ response = Syndication::Discovery.best_feed_response(
75
+ page_url: url,
76
+ request_session:,
77
+ parsed_body:,
78
+ max_probes: self.class.request_slots
79
+ )
80
+ return [] unless response
81
+
82
+ Syndication::Parser.parse_response(response)
83
+ rescue Html2rss::Error, ArgumentError => error
84
+ Log.warn("#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})")
85
+ []
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -23,7 +23,9 @@ module Html2rss
23
23
 
24
24
  # Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
25
25
  # Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
26
+ # NativeFeed is tier 0 so syndication wins before structured HTML scrapers.
26
27
  SCRAPER_TIERS = [
28
+ [NativeFeed].freeze,
27
29
  [Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
28
30
  [WordpressApi, Sitemap, MetaOembed].freeze,
29
31
  [SemanticHtml].freeze,
@@ -36,7 +38,7 @@ module Html2rss
36
38
  # Heuristic scrapers that share one memoized SST::Document per page.
37
39
  HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
38
40
  # Scrapers that accept a shared follow-up +request_session+.
39
- REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
41
+ REQUEST_SESSION_SCRAPERS = [NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
40
42
  # Scrapers that consume browser-captured XHR/fetch JSON bodies.
41
43
  CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
42
44
 
@@ -83,6 +85,7 @@ module Html2rss
83
85
  # Returns an array of scraper classes that claim to find articles in the parsed body.
84
86
  # @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
85
87
  # @param opts [Hash] The options hash.
88
+ # @option opts [Hash] :native_feed scraper toggle and configuration
86
89
  # @option opts [Hash] :wordpress_api scraper toggle and configuration
87
90
  # @option opts [Hash] :schema scraper toggle and configuration
88
91
  # @option opts [Hash] :microdata scraper toggle and configuration
@@ -23,7 +23,14 @@ module Html2rss
23
23
  # Default auto-source configuration shipped for scraper and cleanup behavior.
24
24
  DEFAULT_CONFIG = {
25
25
  limit: DEFAULT_LIMIT,
26
+ entry_resolution: {
27
+ enabled: true,
28
+ max_probes: 5
29
+ }.freeze,
26
30
  scraper: {
31
+ native_feed: {
32
+ enabled: true
33
+ },
27
34
  wordpress_api: {
28
35
  enabled: true
29
36
  },
@@ -7,8 +7,15 @@ module Html2rss
7
7
  # Runtime source of truth for validating auto-source config values.
8
8
  AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
9
9
  optional(:limit).filled(:integer, gt?: 0)
10
+ optional(:entry_resolution).hash do
11
+ optional(:enabled).filled(:bool)
12
+ optional(:max_probes).filled(:integer, gt?: 0)
13
+ end
10
14
 
11
15
  optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
16
+ optional(:native_feed).hash do
17
+ optional(:enabled).filled(:bool)
18
+ end
12
19
  optional(:wordpress_api).hash do
13
20
  optional(:enabled).filled(:bool)
14
21
  end
@@ -264,8 +264,17 @@ module Html2rss
264
264
  def headers = config[:headers]
265
265
  # @return [Hash{Symbol => Object}] channel configuration
266
266
  def channel = config[:channel]
267
- # @return [String] source channel URL
267
+ ##
268
+ # Source channel URL (also the default scrape URL).
269
+ #
270
+ # @return [String]
268
271
  def url = config.dig(:channel, :url)
272
+
273
+ ##
274
+ # @deprecated Use {ScrapeTarget} for entry vs effective URLs after resolution.
275
+ # @return [String]
276
+ def scrape_url = url
277
+
269
278
  # @return [String, nil] configured channel time zone
270
279
  def time_zone = config.dig(:channel, :time_zone)
271
280
 
@@ -11,6 +11,8 @@ 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
@@ -24,17 +24,33 @@ module Html2rss
24
24
  ##
25
25
  # Mutable run state for one auto-fallback chain: attempts plus selected result.
26
26
  class AttemptState
27
- attr_reader :attempts, :result, :last_response
27
+ attr_reader :attempts, :result, :last_response, :entry_resolution
28
28
 
29
29
  def initialize
30
30
  @attempts = []
31
31
  @result = nil
32
32
  @last_response = nil
33
+ @entry_resolution = nil
34
+ @resolution_tried = false
33
35
  end
34
36
 
35
37
  # @return [Boolean] true when a strategy already yielded items
36
38
  def succeeded? = !result.nil?
37
39
 
40
+ # @return [Boolean] whether entry resolution already ran for this chain
41
+ def resolution_tried? = @resolution_tried
42
+
43
+ # @return [void]
44
+ def mark_resolution_tried!
45
+ @resolution_tried = true
46
+ end
47
+
48
+ # @param result [Html2rss::FeedResolution::Result]
49
+ # @return [void]
50
+ def remember_entry_resolution(result)
51
+ @entry_resolution = FeedResolution::Diag.from_result(result)
52
+ end
53
+
38
54
  # @param strategy [Symbol] strategy that raised
39
55
  # @param error [Exception] caught error
40
56
  # @return [void]
@@ -64,10 +80,11 @@ module Html2rss
64
80
  # @param selected_strategy [Symbol] concrete strategy that produced items
65
81
  # @param attempt_count [Integer] number of attempts recorded for this chain
66
82
  # @param admission_drops [Hash{String => Integer}] Cleanup drop tallies
83
+ # @param scrape_target [Html2rss::ScrapeTarget]
67
84
  # @return [void]
68
- # rubocop:disable Metrics/ParameterLists -- PipelineOutcome kwargs stay co-located
85
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- PipelineOutcome kwargs stay co-located
69
86
  def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:,
70
- admission_drops: {})
87
+ scrape_target:, admission_drops: {})
71
88
  @result = PipelineOutcome.new(
72
89
  response:,
73
90
  articles:,
@@ -75,10 +92,12 @@ module Html2rss
75
92
  selected_strategy:,
76
93
  attempt_count:,
77
94
  strategy_attempts: attempts,
78
- admission_drops:
95
+ admission_drops:,
96
+ scrape_target:,
97
+ entry_resolution:
79
98
  )
80
99
  end
81
- # rubocop:enable Metrics/ParameterLists
100
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
82
101
  end
83
102
 
84
103
  ##
@@ -94,6 +113,7 @@ module Html2rss
94
113
  @pipeline = pipeline
95
114
  @config = config
96
115
  @resources = resources
116
+ @scrape_target = ScrapeTarget.from_config(config)
97
117
  end
98
118
 
99
119
  ##
@@ -107,7 +127,7 @@ module Html2rss
107
127
 
108
128
  private
109
129
 
110
- attr_reader :strategies, :budget, :pipeline, :config, :resources
130
+ attr_reader :strategies, :budget, :pipeline, :config, :resources, :scrape_target
111
131
 
112
132
  def run_attempts
113
133
  AttemptState.new.tap do |state|
@@ -119,7 +139,9 @@ module Html2rss
119
139
  end
120
140
 
121
141
  def attempt(strategy:, next_strategy:, state:)
122
- request_session = pipeline.request_session_for(config, strategy:, resources:)
142
+ request_session = pipeline.request_session_for(
143
+ config, strategy:, resources:, scrape_url: scrape_target.effective_url
144
+ )
123
145
  response = fetch_response(request_session:, strategy:, next_strategy:, state:)
124
146
  return unless response
125
147
 
@@ -139,7 +161,8 @@ module Html2rss
139
161
  nil
140
162
  end
141
163
 
142
- def process_response(response:, strategy:, next_strategy:, request_session:, state:) # rubocop:disable Metrics/MethodLength -- extract + success path
164
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
165
+ def process_response(response:, strategy:, next_strategy:, request_session:, state:)
143
166
  articles, dedup_dropped, admission_drops = articles_for(response:, request_session:)
144
167
  state.remember_response(response)
145
168
  items_count = articles.size
@@ -147,6 +170,15 @@ module Html2rss
147
170
  Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
148
171
  "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
149
172
  "budget_remaining=#{budget_remaining_label}")
173
+
174
+ if (outcome = FeedResolution.try_apply!(
175
+ pipeline:, config:, response:, session: request_session, strategy:, resources:,
176
+ articles:, scrape_target:, state:, budget:
177
+ ))
178
+ @scrape_target = outcome.scrape_target
179
+ return
180
+ end
181
+
150
182
  if items_count.positive?
151
183
  return record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:,
152
184
  state:)
@@ -154,6 +186,7 @@ module Html2rss
154
186
 
155
187
  log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
156
188
  end
189
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
157
190
 
158
191
  def articles_for(response:, request_session:)
159
192
  pipeline.deduplicated_articles(config:, response:, request_session:)
@@ -162,8 +195,11 @@ module Html2rss
162
195
  # rubocop:disable Metrics/ParameterLists -- success kwargs match PipelineOutcome
163
196
  def record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:, state:)
164
197
  attempt_count = state.attempts.size
165
- state.succeed!(response:, articles:, dedup_dropped:, selected_strategy: strategy,
166
- attempt_count:, admission_drops:)
198
+ state.succeed!(
199
+ response:, articles:, dedup_dropped:, selected_strategy: strategy,
200
+ attempt_count:, admission_drops:,
201
+ scrape_target:
202
+ )
167
203
  return unless attempt_count > 1
168
204
 
169
205
  Log.info("#{self.class}: auto selected strategy=#{strategy} after attempts=#{attempt_count} " \
@@ -173,12 +209,16 @@ module Html2rss
173
209
  # rubocop:enable Metrics/ParameterLists
174
210
 
175
211
  def finalize_failure(attempts:, response:)
176
- surface_category = response && AutoSource::Scraper.classify_no_scraper_surface(
177
- response.parsed_body, body: response.body
178
- )
212
+ surface_category = surface_category_for(response)
179
213
  raise NoFeedItemsExtracted.new(attempts:, surface_category:)
180
214
  end
181
215
 
216
+ def surface_category_for(response)
217
+ return unless response
218
+
219
+ PageRecon.surface_category_for(response:, url: response.url)
220
+ end
221
+
182
222
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
183
223
  def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
184
224
  host = request_session.url.host
@@ -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
@@ -12,7 +12,7 @@ module Html2rss
12
12
  # strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
13
13
  PipelineOutcome = Data.define(
14
14
  :response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts,
15
- :admission_drops
15
+ :admission_drops, :scrape_target, :entry_resolution
16
16
  )
17
17
 
18
18
  ##
@@ -46,7 +46,9 @@ module Html2rss
46
46
  selected_strategy: outcome.selected_strategy,
47
47
  attempt_count: outcome.attempt_count,
48
48
  strategy_attempts: outcome.strategy_attempts,
49
- admission_drops: outcome.admission_drops
49
+ admission_drops: outcome.admission_drops,
50
+ scrape_target: outcome.scrape_target,
51
+ entry_resolution: outcome.entry_resolution
50
52
  )
51
53
  FeedResult.new(channel:, articles: outcome.articles, status:, stylesheets: config.stylesheets)
52
54
  end
@@ -56,13 +58,15 @@ module Html2rss
56
58
  # @param config [Html2rss::Config]
57
59
  # @param strategy [Symbol]
58
60
  # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
61
+ # @param scrape_url [String, nil] effective fetch URL override for resolved targets
59
62
  # @return [Html2rss::RequestSession]
60
- def request_session_for(config, strategy:, resources:)
63
+ def request_session_for(config, strategy:, resources:, scrape_url: nil)
61
64
  RequestSession.build(
62
65
  config:,
63
66
  strategy:,
64
67
  budget: resources.budget,
65
- policy: resources.policy
68
+ policy: resources.policy,
69
+ scrape_url:
66
70
  )
67
71
  end
68
72
 
@@ -99,19 +103,22 @@ module Html2rss
99
103
 
100
104
  PipelineOutcome.new(
101
105
  response:, articles:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
102
- strategy_attempts: [], admission_drops:
106
+ strategy_attempts: [], admission_drops:,
107
+ scrape_target: ScrapeTarget.from_config(config), entry_resolution: nil
103
108
  )
104
109
  end
105
110
 
106
111
  def raise_empty_auto_source!(strategy:, response:)
107
112
  raise NoFeedItemsExtracted.new(
108
113
  attempts: [{ strategy:, items_count: 0, error_class: nil }],
109
- surface_category: AutoSource::Scraper.classify_no_scraper_surface(
110
- response.parsed_body, body: response.body
111
- )
114
+ surface_category: empty_auto_source_surface(response)
112
115
  )
113
116
  end
114
117
 
118
+ def empty_auto_source_surface(response)
119
+ PageRecon.surface_category_for(response:, url: response.url)
120
+ end
121
+
115
122
  def run_auto_pipeline(config, resources:)
116
123
  AutoFallback.new(
117
124
  strategies: AutoFallback::CHAIN,
@@ -131,6 +138,7 @@ module Html2rss
131
138
  # rubocop:disable Metrics/MethodLength
132
139
  def selector_articles(config:, response:, request_session:)
133
140
  return [] unless (selectors = config.selectors)
141
+ return [] if response.feed_response?
134
142
 
135
143
  page_responses = request_session.page_responses(
136
144
  response,
@@ -151,9 +159,17 @@ module Html2rss
151
159
  # @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
152
160
  def auto_source_articles(config:, response:, request_session:)
153
161
  return [[], {}] unless (auto_source = config.auto_source)
162
+ return syndication_articles(response) if response.feed_response?
154
163
 
155
164
  source = AutoSource.new(response, auto_source, request_session:)
156
165
  [source.articles, source.admission_drops]
157
166
  end
167
+
168
+ def syndication_articles(response)
169
+ articles = Syndication::Parser.parse_response(response).map do |hash|
170
+ Article.new(**hash, scraper: AutoSource::Scraper::NativeFeed)
171
+ end
172
+ [articles, {}]
173
+ end
158
174
  end
159
175
  end
@@ -0,0 +1,52 @@
1
+ # FeedResolution
2
+
3
+ Backend-only entry URL tournament: when the pasted URL is a weak homepage/hub
4
+ (or NativeFeed-majority extract), probe same-origin listing/feed candidates and
5
+ sticky-rewrite the scrape URL only when the retry extract yields items
6
+ (`try_apply!` `:succeeded`) before AutoFallback escalates strategies.
7
+
8
+ Goal: best **article listing** for AutoSource. A native feed may still win the
9
+ tournament on item count (feed-as-means) — intentional.
10
+
11
+ ## Ownership
12
+
13
+ | Concern | Owner |
14
+ | --- | --- |
15
+ | When to resolve (`articles:` required; floor / weak / NativeFeed ≥50%) | `FeedResolution::Policy` |
16
+ | Candidate mix (1 feed + up to 4 listing; taxonomy nav; segment first-wins) | `FeedResolution::CandidateGenerator` |
17
+ | Listing path lexicon (`LISTING_PATHS`) | `Syndication::CandidateCatalog` (consumed, not owned here) |
18
+ | Cheap probe + score + winner pick | `FeedResolution::Probe` + `FeedResolution::Scorer` |
19
+ | Typed entry_resolution options | `FeedResolution::Options` |
20
+ | Wire-safe resolution diag (`applied` = tournament win, not sticky URL) | `FeedResolution::Diag` |
21
+ | Public `call` → `Result`; retry orchestration (sticky `ScrapeTarget` only on `:succeeded`) | `FeedResolution` (`try_apply!`) |
22
+ | Page surface for policy/scoring | `PageRecon::Assessment` + `Html2rss::SurfaceCategory` |
23
+ | Native feed discover/parse | `Syndication` (not this module) |
24
+
25
+ ### CandidateGenerator mix
26
+
27
+ - **Feed slot:** first `Syndication::Discovery.candidate_urls` only (alternates precede path guesses).
28
+ - **Listing fill** (cap = `max - 1` when `max > 1`; `max == 1` → feed only): nav (`taxonomy_path +3`, `content_path +1`) → segment first-wins (`:list` → `:cluster` → `:semantic`, ≥2 same-origin primary links) → `LISTING_PATHS`.
29
+ - Same-page / same-registrable-domain filters; feed never pads into listing slots.
30
+
31
+ ### Policy gates
32
+
33
+ Resolve when eligible config **and** not blocked **and** any of:
34
+
35
+ - `articles.size < ARTICLE_FLOOR` (3), or
36
+ - surface `weak?`, or
37
+ - ≥50% of articles have `scraper == AutoSource::Scraper::NativeFeed` (`native * 2 >= size`; empty → false).
38
+
39
+ `FeedResolution.call` / `Runner` take **`articles:`** (Array) — not `articles_count:`.
40
+
41
+ ## Non-goals
42
+
43
+ - Scorer weight overhaul / ranked multi-feed bake-off
44
+ - Cross-origin probes
45
+ - JSON Feed ingest; Sitemap/WP claim-gates
46
+ - Create UI / Site Explorer
47
+ - Changing `PageRecon` `:list`-only `discover_segments` (CandidateGenerator owns its own first-wins)
48
+ - Syndication parse inside `FeedBuilder`
49
+
50
+ ## Config
51
+
52
+ `auto_source.entry_resolution: { enabled: true, max_probes: 5 }`
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Builds same-origin entry-resolution candidates: one feed slot plus listing seeds.
7
+ #
8
+ # Mix is 1 feed + up to +listing_cap+ listing URLs (nav → segment first-wins →
9
+ # {Syndication::CandidateCatalog::LISTING_PATHS}). Feed never pads into listing slots.
10
+ class CandidateGenerator
11
+ # Max candidates returned to the probe stage.
12
+ DEFAULT_MAX = 5
13
+ # Nav/header/footer anchor sources for DestinationFacts ranking.
14
+ NAV_SELECTORS = 'header a[href], nav a[href], footer a[href]'
15
+ # Segment strategies tried first-wins (≥2 same-origin primary links).
16
+ SEGMENT_STRATEGIES = %i[list cluster semantic].freeze
17
+
18
+ ##
19
+ # @param entry_url [String, Html2rss::Url]
20
+ # @param response [Html2rss::RequestService::Response]
21
+ # @param max [Integer]
22
+ # @return [Array<Html2rss::Url>]
23
+ def self.call(entry_url:, response:, max: DEFAULT_MAX)
24
+ new(entry_url:, response:, max:).call
25
+ end
26
+
27
+ ##
28
+ # @param entry_url [String, Html2rss::Url]
29
+ # @param response [Html2rss::RequestService::Response]
30
+ # @param max [Integer]
31
+ def initialize(entry_url:, response:, max: DEFAULT_MAX)
32
+ @entry_url = Html2rss::Url.from_absolute(entry_url)
33
+ @response = response
34
+ @max = max
35
+ end
36
+
37
+ ##
38
+ # @return [Array<Html2rss::Url>]
39
+ def call
40
+ [feed_slot, *listing_urls.first(listing_cap)].compact.uniq
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :entry_url, :response, :max
46
+
47
+ def listing_cap = max > 1 ? max - 1 : 0
48
+
49
+ def feed_slot
50
+ return unless parsed_html
51
+
52
+ url = Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).first
53
+ return unless url
54
+ return unless eligible_url?(url)
55
+
56
+ url
57
+ end
58
+
59
+ def listing_urls
60
+ (nav_candidates + segment_seed_urls + listing_path_candidates)
61
+ .uniq
62
+ .select { eligible_url?(_1) }
63
+ end
64
+
65
+ def nav_candidates
66
+ return [] unless parsed_html
67
+
68
+ parsed_html.css(NAV_SELECTORS)
69
+ .filter_map { |anchor| ranked_anchor_url(anchor) }
70
+ .sort_by { |(_url, rank)| -rank }
71
+ .map(&:first)
72
+ end
73
+
74
+ def ranked_anchor_url(anchor)
75
+ url = absolute_same_origin(anchor['href'])
76
+ return unless url
77
+
78
+ facts = LinkDestination::DestinationFacts.build(url)
79
+ rank = 0
80
+ rank += 3 if facts.taxonomy_path
81
+ rank += 1 if facts.content_path
82
+ [url, rank] if rank.positive?
83
+ end
84
+
85
+ def segment_seed_urls
86
+ return [] unless parsed_html
87
+
88
+ sst = SST::Normalizer.call(parsed_html)
89
+ link_resolver = Scoring::LinkResolver.new(entry_url)
90
+
91
+ SEGMENT_STRATEGIES.each do |strategy|
92
+ urls = segment_urls_for(sst, strategy, link_resolver)
93
+ return urls if urls.size >= 2
94
+ end
95
+ []
96
+ rescue StandardError
97
+ []
98
+ end
99
+
100
+ def segment_urls_for(sst, strategy, link_resolver)
101
+ segmenter = AutoSource::Segmenter.new(sst, base_url: entry_url, strategy:, link_resolver:)
102
+ primary = AutoSource::Segmenter::PrimaryLink.new(segmenter)
103
+
104
+ segmenter.call.filter_map do |segment|
105
+ link = segment.primary_link || primary.select(segment.root_node)
106
+ absolute_same_origin(link&.attrs&.href)
107
+ end.uniq
108
+ end
109
+
110
+ def listing_path_candidates
111
+ Syndication::CandidateCatalog::LISTING_PATHS.filter_map { |path| absolute_same_origin(path) }
112
+ end
113
+
114
+ def parsed_html
115
+ return @parsed_html if defined?(@parsed_html)
116
+
117
+ @parsed_html = response.html_response? ? response.parsed_body : nil
118
+ rescue RequestService::UnsupportedResponseContentType
119
+ @parsed_html = nil
120
+ end
121
+
122
+ def absolute_same_origin(href)
123
+ return if href.nil? || href.empty? || href.start_with?('#', 'mailto:', 'javascript:')
124
+
125
+ Html2rss::Url.from_relative(href, entry_url)
126
+ rescue ArgumentError
127
+ nil
128
+ end
129
+
130
+ def eligible_url?(url)
131
+ !entry_url.same_document?(url) && same_registrable_domain?(url)
132
+ end
133
+
134
+ def same_registrable_domain?(url)
135
+ url.domain == entry_url.domain
136
+ end
137
+ end
138
+ end
139
+ end