html2rss 0.27.1 → 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 +24 -47
  24. data/lib/html2rss/request_service/botasaurus_strategy.rb +34 -12
  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 +39 -1
  39. metadata +19 -2
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Wire-safe diagnostics for one entry-resolution attempt (Status / Marshal edge).
7
+ Diag = Data.define(:applied, :probe_count, :reason, :winner_score) do
8
+ ##
9
+ # @param result [Html2rss::FeedResolution::Result]
10
+ # @return [Diag]
11
+ def self.from_result(result)
12
+ new(
13
+ applied: result.applied,
14
+ probe_count: result.probe_count,
15
+ reason: result.reason,
16
+ winner_score: result.winner_score
17
+ )
18
+ end
19
+
20
+ ##
21
+ # @return [Hash{Symbol => Object}]
22
+ def to_h
23
+ {
24
+ applied:,
25
+ probe_count:,
26
+ reason:,
27
+ **(winner_score.nil? ? {} : { winner_score: })
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
7
+ Options = Data.define(:enabled, :max_probes) do
8
+ ##
9
+ # @param auto_source [Hash, nil]
10
+ # @return [Options]
11
+ def self.from_auto_source(auto_source)
12
+ raw = auto_source.is_a?(Hash) ? auto_source[:entry_resolution] : nil
13
+ raw = {} unless raw.is_a?(Hash)
14
+ new(
15
+ enabled: raw.fetch(:enabled, FeedResolution::DEFAULT_CONFIG[:enabled]) != false,
16
+ max_probes: Integer(raw.fetch(:max_probes, FeedResolution::DEFAULT_CONFIG[:max_probes]))
17
+ )
18
+ end
19
+
20
+ ##
21
+ # @return [Boolean]
22
+ def enabled? = enabled
23
+
24
+ ##
25
+ # Probe slots plus one Orchestrator retry GET when enabled.
26
+ #
27
+ # @return [Integer]
28
+ def request_slots = enabled? ? max_probes + 1 : 0
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Decides whether an entry URL tournament should run after a weak extract.
7
+ module Policy
8
+ # Minimum admitted articles before the entry URL is considered strong enough.
9
+ ARTICLE_FLOOR = 3
10
+
11
+ module_function
12
+
13
+ ##
14
+ # @param config [Html2rss::Config]
15
+ # @param articles [Array<Html2rss::Article>] typed extract results (count derived)
16
+ # @param surface_category [Html2rss::SurfaceCategory, Symbol, nil]
17
+ # @return [Boolean]
18
+ def resolve?(config:, articles:, surface_category:)
19
+ raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
20
+ return false unless eligible_config?(config)
21
+
22
+ category = SurfaceCategory.coerce(surface_category)
23
+ return false if category.blocked?
24
+
25
+ articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
26
+ end
27
+
28
+ def eligible_config?(config)
29
+ return false unless config.auto_source
30
+ return false if config.selectors
31
+
32
+ Options.from_auto_source(config.auto_source).enabled?
33
+ end
34
+ module_function :eligible_config?
35
+ private_class_method :eligible_config?
36
+
37
+ def native_feed_majority?(articles)
38
+ return false if articles.empty?
39
+
40
+ native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
41
+ native * 2 >= articles.size
42
+ end
43
+ module_function :native_feed_majority?
44
+ private_class_method :native_feed_majority?
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Cheap follow-up probe that scores a candidate via {PageRecon.assess} or syndication parse.
7
+ class Probe
8
+ ##
9
+ # @param url [Html2rss::Url]
10
+ # @param score [Numeric]
11
+ # @param articles_count [Integer]
12
+ Scored = Data.define(:url, :score, :articles_count)
13
+
14
+ ##
15
+ # @param request_session [Html2rss::RequestSession]
16
+ # @param origin_url [String, Html2rss::Url]
17
+ def initialize(request_session:, origin_url:)
18
+ @request_session = request_session
19
+ @origin_url = Html2rss::Url.from_absolute(origin_url)
20
+ end
21
+
22
+ ##
23
+ # @param url [Html2rss::Url]
24
+ # @return [Scored, nil]
25
+ def call(url)
26
+ response = request_session.follow_up(
27
+ url:,
28
+ relation: :entry_resolution,
29
+ origin_url:
30
+ )
31
+ score_response(url, response)
32
+ rescue Html2rss::Error
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :request_session, :origin_url
39
+
40
+ def score_response(_url, response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
41
+ if response.feed_response?
42
+ count = Syndication::Parser.parse_response(response).size
43
+ return Scored.new(
44
+ url: response.url,
45
+ score: Scorer.score_feed(articles_count: count),
46
+ articles_count: count
47
+ )
48
+ end
49
+
50
+ assessment = PageRecon.assess(response:, url: response.url)
51
+ Scored.new(
52
+ url: response.url,
53
+ score: Scorer.score_assessment(assessment),
54
+ articles_count: assessment.articles_count
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedResolution
5
+ ##
6
+ # Scores probe targets and picks winners from cheap {PageRecon::Assessment} facts
7
+ # or syndication item counts.
8
+ module Scorer
9
+ # Weight for admitted article counts on HTML probes.
10
+ ARTICLE_WEIGHT = 10
11
+ # Bonus when the surface looks like a listing rather than a hub/shell.
12
+ LISTING_SURFACE_BONUS = 5
13
+
14
+ module_function
15
+
16
+ ##
17
+ # @param articles_count [Integer]
18
+ # @return [Integer]
19
+ def score_feed(articles_count:)
20
+ articles_count.to_i * ARTICLE_WEIGHT
21
+ end
22
+
23
+ ##
24
+ # @param assessment [Html2rss::PageRecon::Assessment]
25
+ # @return [Integer]
26
+ def score_assessment(assessment)
27
+ score = assessment.articles_count * ARTICLE_WEIGHT
28
+ score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
29
+ drops = assessment.admission_drops.values.sum
30
+ total = assessment.articles_count + drops
31
+ score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
32
+ score
33
+ end
34
+
35
+ ##
36
+ # @param scored [Array<Probe::Scored>]
37
+ # @param entry_articles_count [Integer]
38
+ # @return [Probe::Scored, nil]
39
+ def pick_winner(scored:, entry_articles_count:)
40
+ winner = scored.max_by(&:score)
41
+ return unless winner
42
+ return if winner.articles_count <= entry_articles_count.to_i
43
+ return if winner.score <= 0
44
+
45
+ winner
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Entry URL tournament for weak homepage/hub extracts.
6
+ #
7
+ # {include:file:lib/html2rss/feed_resolution/README.md}
8
+ module FeedResolution
9
+ # Default entry-resolution options merged into AutoSource::DEFAULT_CONFIG.
10
+ DEFAULT_CONFIG = {
11
+ enabled: true,
12
+ max_probes: 5
13
+ }.freeze
14
+
15
+ ##
16
+ # Tournament outcome for one entry-resolution attempt.
17
+ Result = Data.define(:entry_url, :scrape_url, :applied, :reason, :probe_count, :winner_score)
18
+
19
+ ##
20
+ # @param auto_source [Hash, nil]
21
+ # @return [Integer] entry-resolution budget slots (probes + retry GET when enabled)
22
+ def self.request_slots_for(auto_source)
23
+ return 0 unless auto_source
24
+
25
+ Options.from_auto_source(auto_source).request_slots
26
+ end
27
+
28
+ ##
29
+ # @param entry_url [String, Html2rss::Url]
30
+ # @param response [Html2rss::RequestService::Response]
31
+ # @param session [Html2rss::RequestSession]
32
+ # @param config [Html2rss::Config]
33
+ # @param articles [Array<Html2rss::Article>]
34
+ # @param surface_category [Symbol, nil]
35
+ # @return [Result]
36
+ # rubocop:disable Metrics/ParameterLists -- tournament kwargs stay co-located
37
+ def self.call(entry_url:, response:, session:, config:, articles:, surface_category: nil)
38
+ Runner.new(
39
+ entry_url:, response:, session:, config:, articles:, surface_category:
40
+ ).call
41
+ end
42
+ # rubocop:enable Metrics/ParameterLists
43
+
44
+ ##
45
+ # Outcome of {FeedResolution.try_apply!} for the auto-fallback chain.
46
+ ApplyOutcome = Data.define(:scrape_target, :status)
47
+
48
+ ##
49
+ # Runs the tournament and, when a winner applies, re-fetches and re-extracts articles.
50
+ #
51
+ # @param pipeline [Html2rss::FeedPipeline]
52
+ # @param config [Html2rss::Config]
53
+ # @param response [Html2rss::RequestService::Response]
54
+ # @param session [Html2rss::RequestSession]
55
+ # @param strategy [Symbol]
56
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
57
+ # @param articles [Array]
58
+ # @param scrape_target [Html2rss::ScrapeTarget]
59
+ # @param state [Html2rss::FeedPipeline::AutoFallback::AttemptState]
60
+ # @param budget [Html2rss::RequestService::Budget]
61
+ # @return [ApplyOutcome, nil] `:succeeded` sticky scrape target when retry extract yielded items;
62
+ # `nil` when no winner or retry was empty (entry scrape target kept; Diag.applied may still be true)
63
+ # rubocop:disable Metrics/ParameterLists -- orchestration kwargs stay co-located
64
+ def self.try_apply!(pipeline:, config:, response:, session:, strategy:, resources:, articles:,
65
+ scrape_target:, state:, budget:)
66
+ Orchestrator.new(
67
+ pipeline:, config:, response:, session:, strategy:, resources:, articles:,
68
+ scrape_target:, state:, budget:
69
+ ).call
70
+ end
71
+ # rubocop:enable Metrics/ParameterLists
72
+
73
+ # Tournament + optional retry orchestration for {FeedPipeline::AutoFallback}.
74
+ class Orchestrator
75
+ # Minimum remaining request budget required before probing candidates.
76
+ MIN_BUDGET_REMAINING = 3
77
+
78
+ # Error classes that abort resolution instead of falling through (mirrors AutoFallback).
79
+ NON_FALLBACK_ERRORS = FeedPipeline::AutoFallback::NON_FALLBACK_ERRORS
80
+
81
+ ##
82
+ # @param pipeline [Html2rss::FeedPipeline]
83
+ # @param config [Html2rss::Config]
84
+ # @param response [Html2rss::RequestService::Response]
85
+ # @param session [Html2rss::RequestSession]
86
+ # @param strategy [Symbol]
87
+ # @param resources [Html2rss::FeedPipeline::RuntimePolicy::Resources]
88
+ # @param articles [Array]
89
+ # @param scrape_target [Html2rss::ScrapeTarget]
90
+ # @param state [Html2rss::FeedPipeline::AutoFallback::AttemptState]
91
+ # @param budget [Html2rss::RequestService::Budget]
92
+ # rubocop:disable Metrics/ParameterLists -- orchestration context stays co-located
93
+ def initialize(pipeline:, config:, response:, session:, strategy:, resources:, articles:,
94
+ scrape_target:, state:, budget:)
95
+ @pipeline = pipeline
96
+ @config = config
97
+ @response = response
98
+ @session = session
99
+ @strategy = strategy
100
+ @resources = resources
101
+ @articles = articles
102
+ @scrape_target = scrape_target
103
+ @state = state
104
+ @budget = budget
105
+ end
106
+ # rubocop:enable Metrics/ParameterLists
107
+
108
+ ##
109
+ # @return [ApplyOutcome, nil]
110
+ # rubocop:disable Metrics/MethodLength -- eligibility + tournament + retry path
111
+ def call
112
+ return unless eligible?
113
+
114
+ state.mark_resolution_tried!
115
+ resolution = run_tournament
116
+ state.remember_entry_resolution(resolution)
117
+ return unless resolution.applied
118
+
119
+ effective = scrape_target.with_effective(resolution.scrape_url)
120
+ return ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(
121
+ resolution, effective:
122
+ )
123
+
124
+ nil
125
+ rescue RequestService::RequestBudgetExceeded
126
+ Log.warn('FeedResolution: entry resolution skipped (request budget exhausted)')
127
+ nil
128
+ rescue *NON_FALLBACK_ERRORS
129
+ raise
130
+ rescue StandardError => error
131
+ Log.warn("FeedResolution: entry resolution retry failed (#{error.class})")
132
+ nil
133
+ end
134
+ # rubocop:enable Metrics/MethodLength
135
+
136
+ private
137
+
138
+ attr_reader :pipeline, :config, :response, :session, :strategy, :resources, :articles,
139
+ :scrape_target, :state, :budget, :assessment
140
+
141
+ def eligible?
142
+ return false if state.resolution_tried?
143
+ return false if response.feed_response?
144
+ return false if budget.remaining_requests < MIN_BUDGET_REMAINING
145
+
146
+ @assessment = PageRecon.assess(response:, url: config.url)
147
+ Policy.resolve?(
148
+ config:, articles:, surface_category: assessment.surface_category
149
+ )
150
+ end
151
+
152
+ def run_tournament
153
+ FeedResolution.call(
154
+ entry_url: config.url,
155
+ response:,
156
+ session:,
157
+ config:,
158
+ articles:,
159
+ surface_category: assessment.surface_category
160
+ )
161
+ end
162
+
163
+ def retry_with(_resolution, effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
164
+ retry_session = pipeline.request_session_for(
165
+ config, strategy:, resources:, scrape_url: effective.effective_url
166
+ )
167
+ retry_response = retry_session.fetch_initial_response
168
+ retry_articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
169
+ config:, response: retry_response, request_session: retry_session
170
+ )
171
+ state.remember_response(retry_response)
172
+ state.record_items(
173
+ strategy:, items_count: retry_articles.size, transport_meta: retry_response.transport_meta
174
+ )
175
+ return unless retry_articles.size.positive?
176
+
177
+ state.succeed!(
178
+ response: retry_response, articles: retry_articles, dedup_dropped:,
179
+ selected_strategy: strategy, attempt_count: state.attempts.size, admission_drops:,
180
+ scrape_target: effective
181
+ )
182
+ true
183
+ end
184
+ end
185
+
186
+ # Internal tournament runner (keeps {FeedResolution} a Zeitwerk namespace module).
187
+ class Runner
188
+ ##
189
+ # @param entry_url [String, Html2rss::Url]
190
+ # @param response [Html2rss::RequestService::Response]
191
+ # @param session [Html2rss::RequestSession]
192
+ # @param config [Html2rss::Config]
193
+ # @param articles [Array<Html2rss::Article>]
194
+ # @param surface_category [Symbol, nil]
195
+ # rubocop:disable Metrics/ParameterLists -- tournament context stays co-located
196
+ def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
197
+ raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
198
+
199
+ @entry_url = Html2rss::Url.from_absolute(entry_url)
200
+ @response = response
201
+ @session = session
202
+ @config = config
203
+ @articles = articles
204
+ @surface_category = surface_category
205
+ end
206
+ # rubocop:enable Metrics/ParameterLists
207
+
208
+ ##
209
+ # @return [Result]
210
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- policy → candidates → probe → select
211
+ def call
212
+ return skip(:policy_skip) unless Policy.resolve?(
213
+ config:, articles:, surface_category:
214
+ )
215
+
216
+ candidates = CandidateGenerator.call(
217
+ entry_url:,
218
+ response:,
219
+ max: max_probes
220
+ )
221
+ return skip(:no_candidates) if candidates.empty?
222
+
223
+ scored = probe_candidates(candidates)
224
+ winner = Scorer.pick_winner(scored:, entry_articles_count: articles.size)
225
+ return skip(:no_winner, probe_count: scored.size) unless winner
226
+
227
+ apply(winner, probe_count: scored.size)
228
+ end
229
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
230
+
231
+ private
232
+
233
+ attr_reader :entry_url, :response, :session, :config, :articles, :surface_category
234
+
235
+ def max_probes
236
+ Options.from_auto_source(config.auto_source).max_probes
237
+ end
238
+
239
+ def probe_candidates(candidates)
240
+ probe = Probe.new(request_session: session, origin_url: entry_url)
241
+ candidates.filter_map { |url| probe.call(url) }
242
+ end
243
+
244
+ def apply(winner, probe_count:)
245
+ result(
246
+ applied: true,
247
+ reason: :winner,
248
+ scrape_url: winner.url.to_s,
249
+ probe_count:,
250
+ winner_score: winner.score
251
+ )
252
+ end
253
+
254
+ def skip(reason, probe_count: 0)
255
+ result(
256
+ applied: false,
257
+ reason:,
258
+ scrape_url: entry_url.to_s,
259
+ probe_count:,
260
+ winner_score: nil
261
+ )
262
+ end
263
+
264
+ def result(applied:, reason:, scrape_url:, probe_count:, winner_score:)
265
+ scrape_host = Html2rss::Url.from_absolute(scrape_url).host
266
+ Log.info(
267
+ "FeedResolution: entry_host=#{entry_url.host} scrape_host=#{scrape_host} " \
268
+ "applied=#{applied} probe_count=#{probe_count} winner_score=#{winner_score.inspect} reason=#{reason}"
269
+ )
270
+ Result.new(entry_url: entry_url.to_s, scrape_url:, applied:, reason:, probe_count:, winner_score:)
271
+ end
272
+ end
273
+ end
274
+ end
@@ -3,65 +3,28 @@
3
3
  module Html2rss
4
4
  module MCP
5
5
  ##
6
- # Diagnostic inspect path (not Capture ownership). Fetches once for SST/scraper stats
7
- # and recon facts (final URL, status, scheme downgrade, native feed hints).
8
- module Inspect # rubocop:disable Metrics/ModuleLength -- diagnostic helpers stay co-located
6
+ # Diagnostic inspect path (not Capture ownership). Fetches once, then delegates
7
+ # shared recon to {Html2rss::PageRecon}; adds MCP-only scraper/XHR diagnostics.
8
+ module Inspect
9
9
  module_function
10
10
 
11
11
  ##
12
12
  # @param url [String]
13
13
  # @param strategy [String, Symbol]
14
14
  # @return [Hash]
15
- def call(url:, strategy: :auto) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
15
+ def call(url:, strategy: :auto)
16
16
  resolved = FeedPipeline::StrategyPlan.concrete_for_diagnostic(strategy)
17
17
  response = fetch_response(url, resolved)
18
- parsed = response.parsed_body
18
+ recon = PageRecon.call(response:, url:, strategy: resolved)
19
19
 
20
- result = recon_fields(url, response, parsed).merge(
20
+ result = recon.to_h.merge(
21
21
  strategy: resolved,
22
- content_type: response.content_type,
23
- html_response: response.html_response?,
24
- scraper_eligibility: scraper_info(parsed),
25
- sst_stats: sst_stats_from(response)
22
+ scraper_eligibility: scraper_info(safe_parsed_body(response))
26
23
  )
27
-
28
- if response.html_response?
29
- sst = sst_document(response)
30
- if sst
31
- result[:sst] = {
32
- node_count: sst.node_count,
33
- degraded: sst.degraded,
34
- segment_stats: segment_stats(sst, url)
35
- }
36
- end
37
- end
38
-
39
- blocked = Html2rss::RequestService::BlockedSurface.interstitial_signature_for(response.body)
40
- result[:blocked_surface] = blocked[:key].to_s if blocked
41
24
  result[:xhr_capture] = xhr_capture_info(response) if resolved == :botasaurus
42
- merge_admission_diagnostics!(result, response)
43
-
44
25
  result
45
26
  end
46
27
 
47
- ##
48
- # Surfaces Cleanup admission_drops without re-running full AutoSource discovery.
49
- # Uses articles already extractable from a cheap AutoSource pass only when HTML.
50
- #
51
- # @param result [Hash]
52
- # @param response [Html2rss::RequestService::Response]
53
- # @return [void]
54
- def merge_admission_diagnostics!(result, response)
55
- return unless response.html_response?
56
-
57
- source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
58
- articles = source.articles
59
- result[:articles_count] = articles.size
60
- drops = source.admission_drops
61
- result[:admission_drops] = drops if drops.any?
62
- end
63
- module_function :merge_admission_diagnostics!
64
-
65
28
  ##
66
29
  # @param response [Html2rss::RequestService::Response]
67
30
  # @return [Hash] redacted XHR capture diagnostics (no query strings)
@@ -147,95 +110,29 @@ module Html2rss
147
110
  def sst_stats_from(response)
148
111
  return nil unless response.html_response?
149
112
 
150
- doc = sst_document(response)
151
- return nil unless doc
113
+ recon = PageRecon.call(response:, url: response.url)
114
+ return nil unless recon.sst
152
115
 
153
- { node_count: doc.node_count, degraded: doc.degraded }
154
- rescue StandardError
155
- nil
116
+ { node_count: recon.sst[:node_count], degraded: recon.sst[:degraded] }
156
117
  end
157
118
  module_function :sst_stats_from
158
119
 
159
- ##
160
- # @param response [Html2rss::RequestService::Response]
161
- # @return [Html2rss::SST::Document, nil]
162
- def sst_document(response)
163
- Html2rss::SST::Normalizer.call(response.body)
164
- rescue ArgumentError
165
- nil
166
- end
167
- module_function :sst_document
168
-
169
- ##
170
- # @param sst [Html2rss::SST::Document]
171
- # @param url [String]
172
- # @return [Hash]
173
- def segment_stats(sst, url)
174
- segments = discover_segments(sst, url)
175
- return { found: 0 } if segments.empty?
176
-
177
- {
178
- found: segments.size,
179
- strategies: segments.map(&:strategy).uniq,
180
- sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
181
- }
182
- end
183
- module_function :segment_stats
184
-
185
120
  ##
186
121
  # @param sst [Html2rss::SST::Document]
187
122
  # @param url [String]
188
123
  # @return [Array]
189
- def discover_segments(sst, url)
190
- link_resolver = Scoring::LinkResolver.new(url)
191
- AutoSource::Segmenter.call(
192
- sst,
193
- base_url: url,
194
- strategy: :list,
195
- link_resolver:
196
- )
197
- rescue StandardError
198
- []
199
- end
124
+ def discover_segments(sst, url) = PageRecon.discover_segments(sst, url)
200
125
  module_function :discover_segments
201
126
 
202
- ##
203
- # @param requested_url [String]
204
- # @param response [Html2rss::RequestService::Response]
205
- # @param parsed [Object]
206
- # @return [Hash]
207
- def recon_fields(requested_url, response, parsed)
208
- requested = Url.from_absolute(requested_url)
209
- final = response.url
210
-
211
- {
212
- requested_url: requested.to_s,
213
- final_url: final.to_s,
214
- status: response.status,
215
- scheme_downgrade: scheme_downgrade?(requested, final),
216
- alternate_feeds: alternate_feeds_from(parsed)
217
- }
218
- end
219
- module_function :recon_fields
220
-
221
- ##
222
- # @param requested [Html2rss::Url]
223
- # @param final [Html2rss::Url]
224
- # @return [Boolean] true when the fetch downgraded https to http
225
- def scheme_downgrade?(requested, final)
226
- requested.scheme == 'https' && final.scheme == 'http'
227
- end
228
- module_function :scheme_downgrade?
229
-
230
- ##
231
- # @param parsed [Object]
232
- # @return [Array<Hash{Symbol => String}>]
233
- def alternate_feeds_from(parsed)
234
- return [] unless parsed.is_a?(Nokogiri::HTML::Document)
127
+ def safe_parsed_body(response)
128
+ return unless response.html_response?
235
129
 
236
- Html::FeedLink.from_document(parsed).map { |link| { href: link.href, mime_type: link.mime_type } }
130
+ response.parsed_body
131
+ rescue RequestService::UnsupportedResponseContentType
132
+ nil
237
133
  end
238
- module_function :alternate_feeds_from
134
+ module_function :safe_parsed_body
135
+ private_class_method :safe_parsed_body
239
136
  end
240
137
  end
241
138
  end
@@ -24,7 +24,8 @@ module Html2rss
24
24
  'scheme_downgrade, alternate_feeds).',
25
25
  validate_config: 'Call validate_config with payload.yaml or a config hash (XOR, not both).',
26
26
  apply_config: 'Call apply_config next. Confirm payload.item_count before shipping.',
27
- scrape_url: 'Call scrape_url for articles now. strategy auto already runs Faraday then Botasaurus.',
27
+ scrape_url: 'Call scrape_url for articles now. strategy auto already runs Faraday then Botasaurus ' \
28
+ 'and promotes native RSS/Atom when present.',
28
29
  capture_config: 'Call capture_config for a reusable YAML draft, then follow next_step.',
29
30
  read_runtime: 'Read html2rss://runtime. Set BOTASAURUS_SCRAPER_URL on the MCP process ' \
30
31
  'if botasaurus_configured is false.'
@@ -150,7 +151,8 @@ module Html2rss
150
151
  end
151
152
 
152
153
  def inspect_next_step(payload)
153
- return NextStep.done if Array(payload[:alternate_feeds]).any?
154
+ # Runtime scrape_url now consumes native alternates (NativeFeed / direct feed parse).
155
+ return NextStep.scrape_url if Array(payload[:alternate_feeds]).any?
154
156
  return NextStep.capture_config if payload[:articles_count].to_i.positive?
155
157
 
156
158
  NextStep.scrape_url