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
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Shared page recon for MCP Inspect and FeedResolution probes.
6
+ #
7
+ # Owns surface class, native feed hints, segment stats, and a cheap AutoSource
8
+ # article count — not request strategy selection or MCP next-step policy.
9
+ class PageRecon # rubocop:disable Metrics/ClassLength -- recon bag stays co-located
10
+ ##
11
+ # Cheap surface + admission facts shared by AutoFallback gates and FeedResolution probes.
12
+ Assessment = Data.define(:surface_category, :articles_count, :admission_drops, :html_response) do
13
+ ##
14
+ # @return [Html2rss::SurfaceCategory]
15
+ def category = SurfaceCategory.coerce(surface_category)
16
+
17
+ ##
18
+ # @return [Boolean]
19
+ def weak? = category.weak?
20
+
21
+ ##
22
+ # @return [Boolean]
23
+ def blocked? = category.blocked?
24
+
25
+ ##
26
+ # @return [Boolean]
27
+ def listing_bonus? = category.listing_bonus?
28
+ end
29
+ ##
30
+ # Recon facts used by Inspect and FeedResolution.
31
+ Result = Data.define(
32
+ :requested_url,
33
+ :final_url,
34
+ :status,
35
+ :scheme_downgrade,
36
+ :alternate_feeds,
37
+ :surface_category,
38
+ :articles_count,
39
+ :admission_drops,
40
+ :segment_stats,
41
+ :html_response,
42
+ :content_type,
43
+ :blocked_surface,
44
+ :sst
45
+ ) do
46
+ ##
47
+ # @return [Hash{Symbol => Object}]
48
+ def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- omit-empty optional keys
49
+ {
50
+ requested_url:,
51
+ final_url:,
52
+ status:,
53
+ scheme_downgrade:,
54
+ alternate_feeds:,
55
+ surface_category:,
56
+ articles_count:,
57
+ html_response:,
58
+ content_type:,
59
+ **(admission_drops.any? ? { admission_drops: } : {}),
60
+ **(segment_stats ? { segment_stats: } : {}),
61
+ **(blocked_surface ? { blocked_surface: } : {}),
62
+ **(sst ? { sst: } : {})
63
+ }
64
+ end
65
+ end
66
+
67
+ ##
68
+ # @param response [Html2rss::RequestService::Response]
69
+ # @param url [String, Html2rss::Url] requested entry URL
70
+ # @param strategy [Symbol, nil] unused (reserved for callers that already chose a strategy)
71
+ # @return [Result]
72
+ def self.call(response:, url:, strategy: nil) # rubocop:disable Lint/UnusedMethodArgument
73
+ new(response:, url:).call
74
+ end
75
+
76
+ ##
77
+ # Cheap page assessment for policy gates and probe scoring (fixed AutoSource limit).
78
+ #
79
+ # @param response [Html2rss::RequestService::Response]
80
+ # @param url [String, Html2rss::Url]
81
+ # @return [Assessment]
82
+ def self.assess(response:, url:)
83
+ new(response:, url:).assess
84
+ end
85
+
86
+ ##
87
+ # Surface class only — no AutoSource extract (for empty-extract error labels).
88
+ #
89
+ # @param response [Html2rss::RequestService::Response]
90
+ # @param url [String, Html2rss::Url]
91
+ # @return [Symbol]
92
+ def self.surface_category_for(response:, url:)
93
+ new(response:, url:).surface_category_for
94
+ end
95
+
96
+ ##
97
+ # @param sst [Html2rss::SST::Document]
98
+ # @param url [String, Html2rss::Url]
99
+ # @return [Array]
100
+ def self.discover_segments(sst, url)
101
+ link_resolver = Scoring::LinkResolver.new(url)
102
+ AutoSource::Segmenter.call(sst, base_url: url, strategy: :list, link_resolver:)
103
+ rescue StandardError
104
+ []
105
+ end
106
+
107
+ ##
108
+ # @param response [Html2rss::RequestService::Response]
109
+ # @param url [String, Html2rss::Url]
110
+ def initialize(response:, url:)
111
+ @response = response
112
+ @url = url
113
+ end
114
+
115
+ ##
116
+ # @return [Assessment]
117
+ def assess
118
+ return feed_assessment if response.feed_response?
119
+
120
+ parsed = html_parsed_body
121
+ articles_count, admission_drops = cheap_articles
122
+ Assessment.new(
123
+ surface_category: surface_category(parsed),
124
+ articles_count:,
125
+ admission_drops:,
126
+ html_response: true
127
+ )
128
+ end
129
+
130
+ ##
131
+ # @return [Symbol]
132
+ def surface_category_for
133
+ return :unsupported_surface if response.feed_response?
134
+
135
+ surface_category(html_parsed_body)
136
+ end
137
+
138
+ ##
139
+ # @return [Result]
140
+ def call # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- assemble recon Result
141
+ requested = Url.from_absolute(url)
142
+ final = response.url
143
+ parsed = html_parsed_body
144
+ assessment = assess
145
+ sst_payload, segment_stats = sst_payload_and_segments(requested)
146
+
147
+ Result.new(
148
+ requested_url: requested.to_s,
149
+ final_url: final.to_s,
150
+ status: response.status,
151
+ scheme_downgrade: scheme_downgrade?(requested, final),
152
+ alternate_feeds: alternate_feeds_from(parsed),
153
+ surface_category: assessment.surface_category,
154
+ articles_count: assessment.articles_count,
155
+ admission_drops: assessment.admission_drops,
156
+ segment_stats:,
157
+ html_response: response.html_response?,
158
+ content_type: response.content_type,
159
+ blocked_surface: blocked_surface_key,
160
+ sst: sst_payload
161
+ )
162
+ end
163
+
164
+ private
165
+
166
+ attr_reader :response, :url
167
+
168
+ def feed_assessment
169
+ Assessment.new(
170
+ surface_category: :unsupported_surface,
171
+ articles_count: 0,
172
+ admission_drops: {},
173
+ html_response: false
174
+ )
175
+ end
176
+
177
+ def html_parsed_body
178
+ return unless response.html_response?
179
+
180
+ response.parsed_body
181
+ rescue RequestService::UnsupportedResponseContentType
182
+ nil
183
+ end
184
+
185
+ def surface_category(parsed)
186
+ return :unsupported_surface unless parsed
187
+
188
+ AutoSource::Scraper.classify_no_scraper_surface(parsed, body: response.body)
189
+ end
190
+
191
+ def cheap_articles
192
+ return [0, {}] unless response.html_response?
193
+
194
+ source = AutoSource.new(response, AutoSource::DEFAULT_CONFIG.merge(limit: 10))
195
+ [source.articles.size, source.admission_drops]
196
+ end
197
+
198
+ def sst_payload_and_segments(requested)
199
+ return [nil, nil] unless response.html_response?
200
+
201
+ sst = sst_document
202
+ return [nil, nil] unless sst
203
+
204
+ stats = segment_stats(sst, requested)
205
+ [
206
+ { node_count: sst.node_count, degraded: sst.degraded, segment_stats: stats },
207
+ stats
208
+ ]
209
+ end
210
+
211
+ def sst_document
212
+ Html2rss::SST::Normalizer.call(response.body)
213
+ rescue ArgumentError
214
+ nil
215
+ end
216
+
217
+ def segment_stats(sst, page_url)
218
+ segments = self.class.discover_segments(sst, page_url)
219
+ return { found: 0 } if segments.empty?
220
+
221
+ {
222
+ found: segments.size,
223
+ strategies: segments.map(&:strategy).uniq,
224
+ sample_paths: segments.first(5).map { |s| s.root_node.tag_path }
225
+ }
226
+ end
227
+
228
+ def scheme_downgrade?(requested, final)
229
+ requested.scheme == 'https' && final.scheme == 'http'
230
+ end
231
+
232
+ def alternate_feeds_from(parsed)
233
+ return [] unless parsed.is_a?(Nokogiri::HTML::Document)
234
+
235
+ Html::FeedLink.from_document(parsed).map { |link| { href: link.href, mime_type: link.mime_type } }
236
+ end
237
+
238
+ def blocked_surface_key
239
+ blocked = RequestService::BlockedSurface.interstitial_signature_for(response.body)
240
+ blocked[:key].to_s if blocked
241
+ end
242
+ end
243
+ end
@@ -43,7 +43,7 @@ module Html2rss
43
43
  MAX_RETRIES = 3
44
44
 
45
45
  # Allowlisted ScrapeDiagnostics keys nested under diagnostics.
46
- DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge].freeze
46
+ DIAGNOSTICS_KEYS = %w[request_id attempts strategy_used render_ms execution_tier challenge timeout_phase].freeze
47
47
  # Allowlisted ChallengeSignal keys nested under diagnostics.challenge.
48
48
  CHALLENGE_KEYS = %w[blocked detected marker].freeze
49
49
 
@@ -185,10 +185,17 @@ module Html2rss
185
185
  'Botasaurus challenge block detected.'
186
186
  end
187
187
 
188
+ # @return [String, nil] scrape-api timeout stage when present on diagnostics
189
+ def timeout_phase
190
+ value = diagnostics['timeout_phase']
191
+ value.is_a?(String) && !value.empty? ? value : nil
192
+ end
193
+
188
194
  # @return [String] actionable upstream failure summary
189
195
  def failure_message
190
196
  details = ["status=#{transport_status}", "error_category=#{error_category}", "error=#{error}"]
191
197
  details << "request_id=#{request_id}" if request_id
198
+ details << "timeout_phase=#{timeout_phase}" if timeout_phase
192
199
  "Botasaurus scrape failed (#{details.join(', ')})."
193
200
  end
194
201
 
@@ -59,7 +59,7 @@ module Html2rss
59
59
  return unless error.timeout?
60
60
 
61
61
  log_timeout!(reason: 'botasaurus_upstream')
62
- raise RequestTimedOut, error.failure_message
62
+ raise RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
63
63
  end
64
64
 
65
65
  def response_url(final_url)
@@ -14,11 +14,15 @@ module Html2rss
14
14
 
15
15
  # Bodies that look like HTML even when Content-Type is missing, empty, or wrong.
16
16
  HTML_BODY_SNIFF = /\A\s*(?:<!DOCTYPE\s+html|<html)/i
17
+ # Content-Type markers for RSS/Atom syndication responses.
18
+ FEED_CT_MARKERS = %w[rss+xml atom+xml rss atom].freeze
19
+ # Body sniff for unlabeled syndication (first 800 bytes, downcased).
20
+ FEED_BODY_MARKERS = ['<rss', '<feed xmlns', '<feed '].freeze
17
21
  # Charset from Content-Type or a leading <meta charset>.
18
22
  CHARSET_PARAMETER = /charset\s*=\s*["']?([\w.:-]+)/i
19
23
  # Bytes scanned for a meta charset hint (HTML spec looks at the first 1024).
20
24
  META_CHARSET_BYTES = 2048
21
- private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES
25
+ private_constant :CHARSET_PARAMETER, :META_CHARSET_BYTES, :FEED_CT_MARKERS, :FEED_BODY_MARKERS
22
26
 
23
27
  ##
24
28
  # @param body [String] the body of the response
@@ -74,7 +78,14 @@ module Html2rss
74
78
 
75
79
  # @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
76
80
  def html_response?
77
- content_type.include?('text/html') || (!json_response? && html_looking_body?)
81
+ content_type.include?('text/html') || (!json_response? && !feed_response? && html_looking_body?)
82
+ end
83
+
84
+ # @return [Boolean] whether response content is RSS/Atom (header or sniffed body)
85
+ def feed_response?
86
+ return @feed_response if defined?(@feed_response)
87
+
88
+ @feed_response = feed_content_type? || (!json_response? && !html_looking_body? && feed_looking_body?)
78
89
  end
79
90
 
80
91
  ##
@@ -143,6 +154,19 @@ module Html2rss
143
154
  def html_looking_body?
144
155
  body.to_s.b.match?(HTML_BODY_SNIFF)
145
156
  end
157
+
158
+ def feed_content_type?
159
+ ct = content_type.downcase
160
+ return false if ct.empty? || ct.include?('html') || ct.include?('json')
161
+
162
+ # Substring match against Content-Type (not Array#intersect? on a String).
163
+ FEED_CT_MARKERS.any? { |marker| ct.include?(marker) } # rubocop:disable Style/ArrayIntersect
164
+ end
165
+
166
+ def feed_looking_body?
167
+ snippet = body.to_s[0, 800].downcase
168
+ FEED_BODY_MARKERS.any? { |marker| snippet.include?(marker) } # rubocop:disable Style/ArrayIntersect
169
+ end
146
170
  end
147
171
  end
148
172
  end
@@ -31,8 +31,20 @@ module Html2rss
31
31
  class ResponseTooLarge < Html2rss::Error; end
32
32
  # Raised when blocked content surfaces are detected.
33
33
  class BlockedSurfaceDetected < Html2rss::Error; end
34
+
34
35
  # Raised when a request times out.
35
- class RequestTimedOut < Html2rss::Error; end
36
+ class RequestTimedOut < Html2rss::Error
37
+ ##
38
+ # @param message [String, nil] timeout failure summary
39
+ # @param timeout_phase [String, nil] scrape-api stage when known (+queue+/+boot+/+work+)
40
+ def initialize(message = nil, timeout_phase: nil)
41
+ @timeout_phase = timeout_phase
42
+ super(message)
43
+ end
44
+
45
+ # @return [String, nil] scrape-api timeout stage, or nil for transport/budget timeouts
46
+ attr_reader :timeout_phase
47
+ end
36
48
 
37
49
  # Raised when Botasaurus configuration is missing or invalid.
38
50
  class BotasaurusConfigurationError < Html2rss::Error
@@ -15,14 +15,17 @@ module Html2rss
15
15
  # @param strategy [Symbol] request strategy for the session
16
16
  # @param budget [RequestService::Budget] shared request budget
17
17
  # @param policy [RequestService::Policy] request policy (from FeedPipeline::RuntimePolicy.resources_for)
18
+ # @param scrape_url [String, nil] effective fetch URL when it differs from config channel URL
18
19
  # @param logger [Logger] logger used for operational warnings
19
20
  # @return [RequestSession] configured request session
20
- def build(config:, strategy:, budget:, policy:, logger: Html2rss::Log)
21
+ # rubocop:disable Metrics/ParameterLists -- scrape_url override stays beside config
22
+ def build(config:, strategy:, budget:, policy:, scrape_url: nil, logger: Html2rss::Log)
21
23
  context = RequestService::Context.new(
22
- url: config.url, headers: config.headers, request: config.request, policy:, budget:
24
+ url: scrape_url || config.url, headers: config.headers, request: config.request, policy:, budget:
23
25
  )
24
26
  new(context:, strategy:, logger:)
25
27
  end
28
+ # rubocop:enable Metrics/ParameterLists
26
29
  end
27
30
 
28
31
  ##
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Immutable entry vs effective scrape URLs for one pipeline run.
6
+ #
7
+ # Replaces mutating {Config#scrape_url=} after {FeedResolution} rewrites the fetch URL.
8
+ ScrapeTarget = Data.define(:entry_url, :effective_url) do
9
+ ##
10
+ # @param config [Html2rss::Config]
11
+ # @return [ScrapeTarget]
12
+ def self.from_config(config)
13
+ entry = config.url
14
+ new(entry_url: entry, effective_url: entry)
15
+ end
16
+
17
+ ##
18
+ # @param url [String, Html2rss::Url]
19
+ # @return [ScrapeTarget]
20
+ def with_effective(url)
21
+ self.class.new(entry_url:, effective_url: Url.from_absolute(url).to_s)
22
+ end
23
+ end
24
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Html2rss
3
+ module Html2rss # rubocop:disable Metrics/ModuleLength -- Status Data.define + marshal stay co-located
4
4
  ##
5
5
  # Shared RSS +generator+ / JSON Feed +user_comment+ formatter string.
6
6
  #
@@ -9,7 +9,7 @@ module Html2rss
9
9
  # Tallies and counters are validated and frozen at construction (including Marshal load).
10
10
  Status = Data.define(
11
11
  :version, :scraper_tallies, :dedup_dropped, :selected_strategy, :attempt_count,
12
- :strategy_attempts, :admission_drops
12
+ :strategy_attempts, :admission_drops, :entry_url, :scrape_url, :entry_resolution
13
13
  ) do
14
14
  class << self
15
15
  ##
@@ -21,10 +21,15 @@ module Html2rss
21
21
  # @param attempt_count [Integer] auto-fallback attempt count (0 when not under +:auto+)
22
22
  # @param strategy_attempts [Array<Hash>] auto-fallback attempt hashes (empty outside +:auto+)
23
23
  # @param admission_drops [Hash{String => Integer}] Cleanup reason → count (empty when unused)
24
+ # @param scrape_target [Html2rss::ScrapeTarget, nil] domain URL pair (mapped to wire strings)
25
+ # @param entry_url [String, nil] original channel URL when entry resolution ran
26
+ # @param scrape_url [String, nil] effective scrape URL after resolution
27
+ # @param entry_resolution [Html2rss::FeedResolution::Diag, Hash, nil] resolution diagnostics
24
28
  # @return [Html2rss::Status]
25
- # rubocop:disable Metrics/ParameterLists -- Status kwargs stay co-located
29
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status kwargs stay co-located
26
30
  def build(articles:, dedup_dropped: 0, selected_strategy: nil, attempt_count: 0,
27
- strategy_attempts: [], admission_drops: {})
31
+ strategy_attempts: [], admission_drops: {}, scrape_target: nil,
32
+ entry_url: nil, scrape_url: nil, entry_resolution: nil)
28
33
  tallies = articles.filter_map(&:scraper).tally.transform_keys { |klass| scraper_name(klass) }
29
34
  new(
30
35
  version: Html2rss::VERSION,
@@ -33,10 +38,13 @@ module Html2rss
33
38
  selected_strategy:,
34
39
  attempt_count:,
35
40
  strategy_attempts:,
36
- admission_drops:
41
+ admission_drops:,
42
+ entry_url: entry_url || scrape_target&.entry_url,
43
+ scrape_url: scrape_url || scrape_target&.effective_url,
44
+ entry_resolution:
37
45
  )
38
46
  end
39
- # rubocop:enable Metrics/ParameterLists
47
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
40
48
 
41
49
  ##
42
50
  # @param klass [Class, #to_s] scraper class
@@ -54,10 +62,13 @@ module Html2rss
54
62
  # @param attempt_count [Integer]
55
63
  # @param strategy_attempts [Array<Hash>]
56
64
  # @param admission_drops [Hash{String => Integer}]
65
+ # @param entry_url [String, nil]
66
+ # @param scrape_url [String, nil]
67
+ # @param entry_resolution [Hash, nil]
57
68
  # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- Status Data.define members
58
69
  def initialize(
59
70
  version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
60
- strategy_attempts: [], admission_drops: {}
71
+ strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
61
72
  )
62
73
  dedup = Integer(dedup_dropped)
63
74
  attempts = Integer(attempt_count)
@@ -70,21 +81,19 @@ module Html2rss
70
81
  selected_strategy:,
71
82
  attempt_count: attempts,
72
83
  strategy_attempts: freeze_attempts(strategy_attempts),
73
- admission_drops: freeze_tallies(admission_drops)
84
+ admission_drops: freeze_tallies(admission_drops),
85
+ entry_url: freeze_optional_string(entry_url),
86
+ scrape_url: freeze_optional_string(scrape_url),
87
+ entry_resolution: freeze_entry_resolution(entry_resolution)
74
88
  )
75
89
  end
76
90
  # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
77
91
 
78
92
  ##
79
- # Observability hash for web (+scraper_status+). Omits empty/absent optional keys:
80
- # +:scraper_tallies+ when empty, +:selected_strategy+ when +nil+, +:attempt_count+ when zero,
81
- # +:strategy_attempts+ / +:admission_drops+ when empty.
82
- # Data members remain available via readers even when omitted here.
93
+ # Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
83
94
  #
84
- # @return [Hash{Symbol => Object}] always +:version+ (String), +:dedup_dropped+ (Integer);
85
- # optionally +:scraper_tallies+, +:selected_strategy+, +:attempt_count+,
86
- # +:strategy_attempts+, +:admission_drops+
87
- # rubocop:disable Metrics/AbcSize -- omit-empty optional keys stay explicit
95
+ # @return [Hash{Symbol => Object}]
96
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
88
97
  def to_h
89
98
  {
90
99
  version:,
@@ -93,10 +102,13 @@ module Html2rss
93
102
  **(selected_strategy.nil? ? {} : { selected_strategy: }),
94
103
  **(attempt_count.positive? ? { attempt_count: } : {}),
95
104
  **(strategy_attempts.any? ? { strategy_attempts: } : {}),
96
- **(admission_drops.any? ? { admission_drops: } : {})
105
+ **(admission_drops.any? ? { admission_drops: } : {}),
106
+ **(entry_url ? { entry_url: } : {}),
107
+ **(scrape_url ? { scrape_url: } : {}),
108
+ **(entry_resolution ? { entry_resolution: } : {})
97
109
  }
98
110
  end
99
- # rubocop:enable Metrics/AbcSize
111
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
100
112
 
101
113
  ##
102
114
  # Formats the RSS +generator+ string and JSON Feed +user_comment+.
@@ -115,13 +127,15 @@ module Html2rss
115
127
 
116
128
  def marshal_dump
117
129
  [version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
118
- admission_drops]
130
+ admission_drops, entry_url, scrape_url, entry_resolution]
119
131
  end
120
132
 
121
- def marshal_load((version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
122
- strategy_attempts, admission_drops))
133
+ def marshal_load(payload)
134
+ version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
135
+ strategy_attempts, admission_drops, entry_url, scrape_url, entry_resolution = payload
123
136
  initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
124
- strategy_attempts:, admission_drops: admission_drops || {})
137
+ strategy_attempts:, admission_drops: admission_drops || {}, entry_url:, scrape_url:,
138
+ entry_resolution:)
125
139
  end
126
140
 
127
141
  def freeze_tallies(tallies)
@@ -132,6 +146,19 @@ module Html2rss
132
146
  Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
133
147
  end
134
148
 
149
+ def freeze_optional_string(value)
150
+ return if value.nil?
151
+
152
+ value.to_s.dup.freeze
153
+ end
154
+
155
+ def freeze_entry_resolution(value)
156
+ return if value.nil?
157
+
158
+ hash = value.respond_to?(:to_h) ? value.to_h : value
159
+ hash.to_h.transform_keys(&:to_sym).freeze
160
+ end
161
+
135
162
  def validate_counters!(dedup:, attempts:, selected_strategy:)
136
163
  raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
137
164
  raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Closed surface class for no-scraper / page-assessment gates.
6
+ #
7
+ # Construction: {AutoSource::Scraper.classify_no_scraper_surface} and {PageRecon.assess}.
8
+ # Predicates own weak/blocked/listing-bonus decisions — do not re-list WEAK sets in Policy/Scorer.
9
+ class SurfaceCategory
10
+ # Surfaces that warrant listing/feed resolution (hubs / shells — not blocked).
11
+ WEAK = Set[:high_entropy_surface, :app_shell, :unsupported_surface].freeze
12
+
13
+ class << self
14
+ ##
15
+ # @param value [SurfaceCategory, Symbol, String, nil]
16
+ # @return [SurfaceCategory]
17
+ def coerce(value)
18
+ return value if value.is_a?(self)
19
+ return new(name: nil) if value.nil?
20
+
21
+ new(name: value.to_sym)
22
+ end
23
+ end
24
+
25
+ ##
26
+ # @return [Symbol, nil]
27
+ attr_reader :name
28
+
29
+ ##
30
+ # @param name [Symbol, nil]
31
+ def initialize(name:)
32
+ @name = name
33
+ end
34
+
35
+ ##
36
+ # @return [Boolean]
37
+ def weak? = WEAK.include?(name)
38
+
39
+ ##
40
+ # @return [Boolean]
41
+ def blocked? = name == :blocked_surface
42
+
43
+ ##
44
+ # @return [Boolean] non-weak, non-blocked surface eligible for listing bonus
45
+ def listing_bonus? = !name.nil? && !weak? && !blocked?
46
+
47
+ ##
48
+ # @return [Symbol, nil]
49
+ def to_sym = name
50
+
51
+ ##
52
+ # @param other [Object]
53
+ # @return [Boolean]
54
+ def ==(other)
55
+ other.is_a?(self.class) && name == other.name
56
+ end
57
+ alias eql? ==
58
+
59
+ ##
60
+ # @return [Integer]
61
+ def hash = [self.class, name].hash
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # Syndication
2
+
3
+ Native RSS/Atom discovery and parse for auto-source promotion and direct feed URLs.
4
+
5
+ ## Ownership
6
+
7
+ | Concern | Owner |
8
+ | --- | --- |
9
+ | Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery` |
10
+ | RSS 2.0 / Atom → article hashes | `Syndication::Parser` |
11
+ | Strict head-only link parse (no path guessing) | `Html::FeedLink` |
12
+ | Response Content-Type / body feed sniff | `RequestService::Response#feed_response?` (sole sniff owner; Discovery only adds HTTP status gate) |
13
+
14
+ ## Non-goals
15
+
16
+ - Entry-URL tournament / listing resolution (`FeedResolution`)
17
+ - Page recon / surface classification (`PageRecon`)
18
+ - Feed channel metadata for output (`Channel` / `FeedBuilder`)
19
+ - Ranking or quality scoring of competing feeds beyond first feedish hit
20
+
21
+ Discovery stops at the first same-origin feedish URL (lazy probe). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Syndication
5
+ ##
6
+ # Shared path lexicon for syndication discovery and entry-resolution candidates.
7
+ module CandidateCatalog
8
+ # Common feed path suffixes probed after head +rel=alternate+ hints.
9
+ FEED_PATHS = %w[
10
+ /feed
11
+ /feed.xml
12
+ /rss
13
+ /rss.xml
14
+ /atom.xml
15
+ /index.xml
16
+ /news/rss
17
+ /news/feed
18
+ /blog/feed
19
+ /blog/rss.xml
20
+ ].freeze
21
+
22
+ # HTML listing paths probed only by {FeedResolution::CandidateGenerator}.
23
+ LISTING_PATHS = %w[/news /blog /releases /changelog /updates].freeze
24
+ end
25
+ end
26
+ end