html2rss 0.32.3 → 0.33.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/article/enclosure.rb +2 -6
  3. data/lib/html2rss/auto_source/cleanup.rb +1 -3
  4. data/lib/html2rss/auto_source/scraper/html.rb +2 -8
  5. data/lib/html2rss/auto_source/scraper/json_document.rb +2 -3
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +128 -128
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +37 -37
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +34 -34
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -6
  11. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +32 -2
  12. data/lib/html2rss/auto_source/scraper/microdata.rb +228 -245
  13. data/lib/html2rss/auto_source/scraper/microformats2.rb +9 -1
  14. data/lib/html2rss/auto_source/scraper/native_feed.rb +1 -1
  15. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  16. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -2
  17. data/lib/html2rss/auto_source/scraper/schema/thing.rb +98 -21
  18. data/lib/html2rss/auto_source/scraper/schema.rb +49 -2
  19. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -16
  20. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  21. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -11
  22. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  23. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +4 -4
  25. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -1
  26. data/lib/html2rss/auto_source/scraper.rb +0 -11
  27. data/lib/html2rss/auto_source/segmenter/cluster.rb +5 -11
  28. data/lib/html2rss/auto_source/segmenter/list.rb +25 -57
  29. data/lib/html2rss/auto_source/segmenter/semantic.rb +6 -13
  30. data/lib/html2rss/auto_source.rb +1 -1
  31. data/lib/html2rss/batch.rb +22 -32
  32. data/lib/html2rss/capture/README.md +1 -1
  33. data/lib/html2rss/capture.rb +7 -27
  34. data/lib/html2rss/cli/render.rb +97 -96
  35. data/lib/html2rss/cli/validate.rb +2 -4
  36. data/lib/html2rss/cli.rb +33 -27
  37. data/lib/html2rss/config/request_controls.rb +10 -2
  38. data/lib/html2rss/config/schema.rb +8 -10
  39. data/lib/html2rss/config/selectors_validator.rb +1 -0
  40. data/lib/html2rss/config/validator.rb +3 -3
  41. data/lib/html2rss/config.rb +1 -1
  42. data/lib/html2rss/defaults.rb +5 -16
  43. data/lib/html2rss/doctor/botasaurus.rb +11 -19
  44. data/lib/html2rss/error.rb +54 -1
  45. data/lib/html2rss/feed_builder/rss.rb +1 -1
  46. data/lib/html2rss/feed_pipeline/README.md +1 -1
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +58 -55
  48. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  49. data/lib/html2rss/feed_resolution/options.rb +3 -7
  50. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  51. data/lib/html2rss/feed_resolution/probe.rb +3 -3
  52. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  53. data/lib/html2rss/feed_resolution.rb +5 -9
  54. data/lib/html2rss/feed_result.rb +4 -3
  55. data/lib/html2rss/hash_util.rb +91 -90
  56. data/lib/html2rss/html/article_extractor/category_extractor.rb +6 -9
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +1 -3
  59. data/lib/html2rss/html/article_extractor/id_generator.rb +1 -1
  60. data/lib/html2rss/html/probe.rb +51 -51
  61. data/lib/html2rss/link_destination/path_classifier.rb +11 -33
  62. data/lib/html2rss/mcp/config_argument.rb +9 -2
  63. data/lib/html2rss/mcp/contract.rb +7 -5
  64. data/lib/html2rss/mcp/outcome.rb +4 -5
  65. data/lib/html2rss/mcp/runtime.rb +17 -24
  66. data/lib/html2rss/mcp/server/tools.rb +9 -9
  67. data/lib/html2rss/option.rb +48 -0
  68. data/lib/html2rss/page_recon/diagnostics.rb +7 -9
  69. data/lib/html2rss/page_recon.rb +0 -4
  70. data/lib/html2rss/recon.rb +10 -12
  71. data/lib/html2rss/request_service/blocked_surface.rb +3 -7
  72. data/lib/html2rss/request_service/botasaurus_strategy.rb +2 -2
  73. data/lib/html2rss/request_service/budget.rb +3 -24
  74. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  75. data/lib/html2rss/request_service/context.rb +1 -3
  76. data/lib/html2rss/request_service/local_file_strategy.rb +3 -1
  77. data/lib/html2rss/request_service/network_guard.rb +3 -2
  78. data/lib/html2rss/request_service/policy.rb +8 -26
  79. data/lib/html2rss/request_service/response.rb +3 -6
  80. data/lib/html2rss/request_service/strategy.rb +2 -2
  81. data/lib/html2rss/request_service.rb +26 -2
  82. data/lib/html2rss/request_session/pager/base.rb +3 -3
  83. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  84. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  85. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  86. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  87. data/lib/html2rss/scoring/engine.rb +4 -32
  88. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  89. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  90. data/lib/html2rss/selectors/json_xml.rb +33 -34
  91. data/lib/html2rss/selectors/post_processors/gsub.rb +2 -3
  92. data/lib/html2rss/selectors/post_processors/parse_time.rb +4 -5
  93. data/lib/html2rss/selectors/schema_export.rb +60 -60
  94. data/lib/html2rss/selectors.rb +14 -18
  95. data/lib/html2rss/sst/node.rb +9 -3
  96. data/lib/html2rss/sst/normalizer.rb +25 -24
  97. data/lib/html2rss/sst/text.rb +53 -57
  98. data/lib/html2rss/status.rb +23 -30
  99. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  100. data/lib/html2rss/syndication/discovery.rb +117 -187
  101. data/lib/html2rss/syndication/parser.rb +92 -115
  102. data/lib/html2rss/test/enhance_audit.rb +13 -15
  103. data/lib/html2rss/test/policy.rb +9 -11
  104. data/lib/html2rss/test.rb +29 -48
  105. data/lib/html2rss/url.rb +53 -53
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +2 -1
  108. metadata +4 -2
@@ -3,13 +3,65 @@
3
3
  module Html2rss
4
4
  # The Html2rss::Error base class.
5
5
  class Error < StandardError
6
+ # Upstream transport_meta keys promoted into error diagnostics.
7
+ TRANSPORT_META_KEYS = %i[request_id strategy_used render_ms error_category timeout_phase].freeze
8
+
9
+ class << self
10
+ ##
11
+ # Builds a sparse diagnostics hash from attempt hashes and optional error metadata.
12
+ #
13
+ # @param attempts [Array<Hash>, nil] tier attempt diagnostics
14
+ # @param transport_meta [Hash, nil] direct error transport metadata
15
+ # @param timeout_phase [String, nil] timeout stage when known
16
+ # @param surface_category [Symbol, nil] page surface classification when known
17
+ # @return [Hash{Symbol => Object}]
18
+ def diagnostics_from_attempts(attempts, transport_meta: nil, timeout_phase: nil, surface_category: nil)
19
+ list = Array(attempts)
20
+ meta = list.filter_map { |item| item[:transport_meta] || item['transport_meta'] }.last || transport_meta
21
+ fields = extract_transport_fields(meta)
22
+ fields[:timeout_phase] ||= timeout_phase
23
+ fields[:surface_category] = surface_category
24
+ { strategy_attempts: (list if list.any?), **fields }.compact
25
+ end
26
+
27
+ private
28
+
29
+ def extract_transport_fields(meta)
30
+ return {} unless meta
31
+
32
+ TRANSPORT_META_KEYS.to_h { |key| [key, meta[key] || meta[key.to_s]] }
33
+ end
34
+ end
35
+
6
36
  # @return [Array<Hash{Symbol => Object}>, nil] tier attempt diagnostics when set by AutoFallback
7
37
  attr_accessor :attempts
8
38
 
9
39
  # @return [Hash{String => Object}, nil] allowlisted upstream transport telemetry when attached
10
40
  attr_accessor :transport_meta
41
+
42
+ # @return [String, nil] scrape-api timeout stage when known (overridden by RequestTimedOut)
43
+ def timeout_phase; end
44
+
45
+ # @return [Symbol, nil] surface classification when known (overridden by NoFeedItemsExtracted)
46
+ def surface_category; end
47
+
48
+ ##
49
+ # Sparse telemetry hash for observability consumers.
50
+ #
51
+ # @return [Hash{Symbol => Object}]
52
+ def diagnostics
53
+ self.class.diagnostics_from_attempts(
54
+ attempts,
55
+ transport_meta:,
56
+ timeout_phase:,
57
+ surface_category:
58
+ )
59
+ end
11
60
  end
12
61
 
62
+ # Raised when a caller-supplied option value is malformed or out of range.
63
+ class InvalidOption < Error; end
64
+
13
65
  # Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
14
66
  class NoFeedItemsExtracted < Error
15
67
  # Categories that append shared surface guidance to the empty-feed message.
@@ -38,7 +90,8 @@ module Html2rss
38
90
 
39
91
  def base_message
40
92
  summaries = attempts.map do |attempt|
41
- details = attempt[:items_count].nil? ? "#{attempt[:error_class]} error" : "#{attempt[:items_count]} items"
93
+ count = attempt[:items_count]
94
+ details = count.nil? ? "#{attempt[:error_class]} error" : "#{count} items"
42
95
  "#{attempt[:strategy]} (#{details})"
43
96
  end.join(', ')
44
97
 
@@ -37,7 +37,7 @@ module Html2rss
37
37
  item_maker.title = article.title
38
38
  item_maker.author = article.author
39
39
  description = ItemPresentation.description_for(article)
40
- item_maker.description = description if description && !description.empty?
40
+ item_maker.description = description if description
41
41
  item_maker.link = article.url.to_s if article.url
42
42
  item_maker.pubDate = article.published_at&.rfc2822
43
43
  end
@@ -37,7 +37,7 @@ Retries share the feed's request/session policy. Pin a concrete strategy when yo
37
37
 
38
38
  ### Terminal fallback outcome
39
39
 
40
- A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut` or `BlockedSurfaceDetected`). `AutoFallback` remembers that error across later hops and, when no later hop yields items, re-raises it with `attempts` attached. A later hop that extracts items still succeeds. Two zero-item hops with no terminal transport error still raise `NoFeedItemsExtracted`. `html2rss-web` `ErrorClassifier` maps the raised error to HTTP/cache policy; it does not invent a second cache rule for empty extraction.
40
+ A hop that returns zero items does **not** erase an earlier terminal transport failure (`RequestTimedOut`, `BlockedSurfaceDetected`, or `ConnectionFailed`). `AutoFallback` remembers that error across later hops and, when no later hop yields items, re-raises it with `attempts` attached. A later hop that extracts items still succeeds. Two zero-item hops with no terminal transport error still raise `NoFeedItemsExtracted`. True empty stays the web policy already in force: listing `EXTRACTION_EMPTY` is cacheable; `app_shell` and `high_entropy_surface` are not. `html2rss-web` `ErrorClassifier` maps the raised error to HTTP/cache policy; it does not invent a second cache rule for empty extraction.
41
41
 
42
42
  ## Success signals
43
43
 
@@ -32,7 +32,8 @@ module Html2rss
32
32
  # decisions instead of EXTRACTION_EMPTY.
33
33
  TERMINAL_TRANSPORT_ERRORS = [
34
34
  RequestService::RequestTimedOut,
35
- RequestService::BlockedSurfaceDetected
35
+ RequestService::BlockedSurfaceDetected,
36
+ RequestService::ConnectionFailed
36
37
  ].freeze
37
38
 
38
39
  ##
@@ -79,12 +80,11 @@ module Html2rss
79
80
 
80
81
  # @param strategy [Symbol] strategy that raised
81
82
  # @param error [Exception] caught error
83
+ # @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
82
84
  # @return [void]
83
- def record_error(strategy:, error:)
84
- attempt = { strategy:, items_count: nil, error_class: error.class.name }
85
- meta = error.is_a?(Html2rss::Error) ? error.transport_meta : nil
86
- attempt[:transport_meta] = meta if meta&.any?
87
- @attempts << attempt
85
+ def record_error(strategy:, error:, elapsed_ms: nil)
86
+ meta = error.transport_meta if error.is_a?(Error)
87
+ append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
88
88
  @last_error = error
89
89
  return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
90
90
 
@@ -94,12 +94,11 @@ module Html2rss
94
94
  # @param strategy [Symbol] strategy that returned a response
95
95
  # @param items_count [Integer] extracted article count
96
96
  # @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
97
+ # @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
97
98
  # @return [void]
98
- def record_items(strategy:, items_count:, transport_meta: nil)
99
+ def record_items(strategy:, items_count:, transport_meta: nil, elapsed_ms: nil)
99
100
  @last_error = nil
100
- attempt = { strategy:, items_count:, error_class: nil }
101
- attempt[:transport_meta] = transport_meta if transport_meta&.any?
102
- @attempts << attempt
101
+ append_attempt(strategy:, items_count:, transport_meta:, elapsed_ms:)
103
102
  end
104
103
 
105
104
  # @param response [RequestService::Response] last response that reached extraction
@@ -131,6 +130,37 @@ module Html2rss
131
130
  entry_resolution:
132
131
  )
133
132
  end
133
+
134
+ # Raises the terminal transport error, last error, or NoFeedItemsExtracted with attempts attached.
135
+ #
136
+ # @return [void] always raises
137
+ # @raise [Html2rss::Error, StandardError]
138
+ def raise_failure!
139
+ if (error = failure_error)
140
+ error.attempts = attempts if error.is_a?(Error)
141
+ raise error
142
+ end
143
+
144
+ response = @last_response
145
+ surface_category = PageRecon.surface_category_for(response:, url: response.url) if response
146
+ raise NoFeedItemsExtracted.new(attempts:, surface_category:)
147
+ end
148
+
149
+ private
150
+
151
+ def append_attempt(strategy:, items_count: nil, error_class: nil, transport_meta: nil, elapsed_ms: nil)
152
+ attempt = { strategy:, items_count:, error_class: }
153
+ attempt[:elapsed_ms] = elapsed_ms unless elapsed_ms.nil?
154
+ attempt[:transport_meta] = transport_meta if transport_meta&.any?
155
+ @attempts << attempt
156
+ end
157
+
158
+ def failure_error
159
+ return @terminal_transport_error if @terminal_transport_error
160
+ return if @last_error.nil? || @last_error.is_a?(RequestService::BotasaurusConfigurationError)
161
+
162
+ @last_error
163
+ end
134
164
  end
135
165
 
136
166
  ##
@@ -155,7 +185,7 @@ module Html2rss
155
185
  state = run_attempts
156
186
  return state.result if state.succeeded?
157
187
 
158
- finalize_failure(state)
188
+ state.raise_failure!
159
189
  end
160
190
 
161
191
  private
@@ -171,37 +201,33 @@ module Html2rss
171
201
  end
172
202
  end
173
203
 
204
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- hop timing + NON_FALLBACK rescue stay co-located
174
205
  def attempt(strategy:, next_strategy:, state:)
206
+ started_ms = monotonic_ms
175
207
  request_session = pipeline.request_session_for(
176
208
  config, strategy:, resources:, scrape_url: scrape_target.effective_url
177
209
  )
178
- response = fetch_response(request_session:, strategy:, next_strategy:, state:)
179
- return unless response
180
-
181
- process_response(response:, strategy:, next_strategy:, request_session:, state:)
182
- rescue RequestService::UnsupportedResponseContentType => error
183
- state.record_error(strategy:, error:)
184
- log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
185
- end
186
-
187
- def fetch_response(request_session:, strategy:, next_strategy:, state:)
188
- request_session.fetch_initial_response
189
- rescue *NON_FALLBACK_ERRORS
190
- raise
191
- rescue StandardError => error
192
- state.record_error(strategy:, error:)
193
- log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy
194
- nil
210
+ response = request_session.fetch_initial_response
211
+ process_response(
212
+ response:, strategy:, next_strategy:, request_session:, state:,
213
+ elapsed_ms: monotonic_ms - started_ms
214
+ )
215
+ rescue *NON_FALLBACK_ERRORS => error
216
+ state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
217
+ state.abort!
218
+ rescue Html2rss::Error => error
219
+ state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
220
+ log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy && request_session
195
221
  end
196
222
 
197
- # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize -- extract + resolution delegate
198
- def process_response(response:, strategy:, next_strategy:, request_session:, state:)
223
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists -- extract + resolution delegate
224
+ def process_response(response:, strategy:, next_strategy:, request_session:, state:, elapsed_ms: nil)
199
225
  articles, dedup_dropped, admission_drops = pipeline.deduplicated_articles(
200
226
  config:, response:, request_session:
201
227
  )
202
228
  state.remember_response(response)
203
229
  items_count = articles.size
204
- state.record_items(strategy:, items_count:, transport_meta: response.transport_meta)
230
+ state.record_items(strategy:, items_count:, transport_meta: response.transport_meta, elapsed_ms:)
205
231
  Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
206
232
  "host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
207
233
  "budget_remaining=#{budget_remaining_label}")
@@ -249,30 +275,7 @@ module Html2rss
249
275
  }.to_json)
250
276
  end
251
277
 
252
- def finalize_failure(state)
253
- error = failure_error(state)
254
- if error
255
- error.attempts = state.attempts if error.is_a?(Html2rss::Error)
256
- raise error
257
- end
258
-
259
- response = state.last_response
260
- raise NoFeedItemsExtracted.new(
261
- attempts: state.attempts,
262
- surface_category: (PageRecon.surface_category_for(response:, url: response.url) if response)
263
- )
264
- end
265
-
266
- # Prefer a remembered timeout/blocked-surface over a later zero-item hop so
267
- # callers do not cache EXTRACTION_EMPTY for a transport failure.
268
- def failure_error(state)
269
- return state.terminal_transport_error if state.terminal_transport_error
270
-
271
- error = state.last_error
272
- return if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
273
-
274
- error
275
- end
278
+ def monotonic_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
276
279
 
277
280
  # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- shared payload then info/warn dispatch
278
281
  def log_fallback_error(strategy:, next_strategy:, error:, request_session:)
@@ -50,7 +50,6 @@ module Html2rss
50
50
  return unless parsed_html
51
51
 
52
52
  url = Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).first
53
- return unless url
54
53
  return unless eligible_url?(url)
55
54
 
56
55
  url
@@ -86,6 +85,8 @@ module Html2rss
86
85
  return [] unless parsed_html
87
86
 
88
87
  sst = SST::Normalizer.call(parsed_html)
88
+ return [] unless sst
89
+
89
90
  link_resolver = Scoring::LinkResolver.new(entry_url)
90
91
 
91
92
  SEGMENT_STRATEGIES.each do |strategy|
@@ -93,8 +94,6 @@ module Html2rss
93
94
  return urls if urls.size >= 2
94
95
  end
95
96
  []
96
- rescue StandardError
97
- []
98
97
  end
99
98
 
100
99
  def segment_urls_for(sst, strategy, link_resolver)
@@ -120,7 +119,7 @@ module Html2rss
120
119
  return if href.nil? || href.empty? || href.start_with?('#', 'mailto:', 'javascript:')
121
120
 
122
121
  Html2rss::Url.from_relative(href, entry_url)
123
- rescue ArgumentError
122
+ rescue Html2rss::Url::InvalidUrlError
124
123
  nil
125
124
  end
126
125
 
@@ -8,15 +8,11 @@ module Html2rss
8
8
  # Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
9
9
  class Options
10
10
  ##
11
- # @param auto_source [Hash, nil]
11
+ # @param auto_source [Hash, nil] validated +auto_source+ config
12
12
  # @return [Options]
13
13
  def self.from_auto_source(auto_source)
14
- raw = auto_source.is_a?(Hash) ? auto_source[:entry_resolution] : nil
15
- raw = {} unless raw.is_a?(Hash)
16
- new(
17
- enabled: raw.fetch(:enabled, FeedResolution::DEFAULT_CONFIG[:enabled]) != false,
18
- max_probes: Integer(raw.fetch(:max_probes, FeedResolution::DEFAULT_CONFIG[:max_probes]))
19
- )
14
+ raw = auto_source&.dig(:entry_resolution) || {}
15
+ new(enabled: raw.fetch(:enabled, true), max_probes: raw.fetch(:max_probes, DEFAULT_CONFIG[:max_probes]))
20
16
  end
21
17
 
22
18
  ##
@@ -8,40 +8,36 @@ module Html2rss
8
8
  # Minimum admitted articles before the entry URL is considered strong enough.
9
9
  ARTICLE_FLOOR = 3
10
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]
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[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
11
+ class << self
12
+ ##
13
+ # @param config [Html2rss::Config]
14
+ # @param articles [Array<Html2rss::Article>] typed extract results (count derived)
15
+ # @param surface_category [Html2rss::SurfaceCategory, Symbol]
16
+ # @return [Boolean]
17
+ def resolve?(config:, articles:, surface_category:)
18
+ raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
19
+ return false unless eligible_config?(config)
20
+
21
+ category = SurfaceCategory[surface_category]
22
+ return false if category.blocked?
23
+
24
+ articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
25
+ end
26
+
27
+ private
28
+
29
+ def eligible_config?(config)
30
+ return false unless config.auto_source
31
+ return false if config.selectors
32
+
33
+ Options.from_auto_source(config.auto_source).enabled?
34
+ end
35
+
36
+ def native_feed_majority?(articles)
37
+ native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
38
+ native * 2 >= articles.size
39
+ end
42
40
  end
43
- module_function :native_feed_majority?
44
- private_class_method :native_feed_majority?
45
41
  end
46
42
  end
47
43
  end
@@ -16,7 +16,7 @@ module Html2rss
16
16
  # @param origin_url [String, Html2rss::Url]
17
17
  def initialize(request_session:, origin_url:)
18
18
  @request_session = request_session
19
- @origin_url = Html2rss::Url.from_absolute(origin_url)
19
+ @origin_url = Url.from_absolute(origin_url)
20
20
  end
21
21
 
22
22
  ##
@@ -28,7 +28,7 @@ module Html2rss
28
28
  relation: :entry_resolution,
29
29
  origin_url:
30
30
  )
31
- score_response(url, response)
31
+ score_response(response)
32
32
  rescue Html2rss::Error
33
33
  nil
34
34
  end
@@ -37,7 +37,7 @@ module Html2rss
37
37
 
38
38
  attr_reader :request_session, :origin_url
39
39
 
40
- def score_response(_url, response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
40
+ def score_response(response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
41
41
  if response.feed_response?
42
42
  count = Syndication::Parser.parse_response(response).size
43
43
  return Scored.new(
@@ -11,38 +11,38 @@ module Html2rss
11
11
  # Bonus when the surface looks like a listing rather than a hub/shell.
12
12
  LISTING_SURFACE_BONUS = 5
13
13
 
14
- module_function
14
+ class << self
15
+ ##
16
+ # @param articles_count [Integer]
17
+ # @return [Integer]
18
+ def score_feed(articles_count:)
19
+ articles_count * ARTICLE_WEIGHT
20
+ end
15
21
 
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
22
+ ##
23
+ # @param assessment [Html2rss::PageRecon::Assessment]
24
+ # @return [Integer]
25
+ def score_assessment(assessment)
26
+ score = assessment.articles_count * ARTICLE_WEIGHT
27
+ score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
28
+ drops = assessment.admission_drops.values.sum
29
+ total = assessment.articles_count + drops
30
+ score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
31
+ score
32
+ end
34
33
 
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
34
+ ##
35
+ # @param scored [Array<Probe::Scored>]
36
+ # @param entry_articles_count [Integer]
37
+ # @return [Probe::Scored, nil]
38
+ def pick_winner(scored:, entry_articles_count:)
39
+ winner = scored.max_by(&:score)
40
+ return unless winner
41
+ return if winner.articles_count <= entry_articles_count.to_i
42
+ return if winner.score <= 0
44
43
 
45
- winner
44
+ winner
45
+ end
46
46
  end
47
47
  end
48
48
  end
@@ -116,17 +116,13 @@ module Html2rss
116
116
  return unless resolution.applied
117
117
 
118
118
  effective = scrape_target.with_effective(resolution.scrape_url)
119
- return ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(
120
- resolution, effective:
121
- )
122
-
123
- nil
119
+ ApplyOutcome.new(scrape_target: effective, status: :succeeded) if retry_with(effective:)
124
120
  rescue RequestService::RequestBudgetExceeded
125
121
  log_budget_exhausted
126
122
  nil
127
123
  rescue *NON_FALLBACK_ERRORS
128
124
  raise
129
- rescue StandardError => error
125
+ rescue Html2rss::Error => error
130
126
  log_retry_failed(error)
131
127
  nil
132
128
  end
@@ -158,7 +154,7 @@ module Html2rss
158
154
  # @return [String, nil]
159
155
  def channel_host
160
156
  Url.from_absolute(config.url.to_s).host
161
- rescue ArgumentError
157
+ rescue Html2rss::Url::InvalidUrlError
162
158
  nil
163
159
  end
164
160
 
@@ -184,7 +180,7 @@ module Html2rss
184
180
  )
185
181
  end
186
182
 
187
- def retry_with(_resolution, effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
183
+ def retry_with(effective:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- retry extract path
188
184
  retry_session = pipeline.request_session_for(
189
185
  config, strategy:, resources:, scrape_url: effective.effective_url
190
186
  )
@@ -220,7 +216,7 @@ module Html2rss
220
216
  def initialize(entry_url:, response:, session:, config:, articles:, surface_category: nil)
221
217
  raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
222
218
 
223
- @entry_url = Html2rss::Url.from_absolute(entry_url)
219
+ @entry_url = Url.from_absolute(entry_url)
224
220
  @response = response
225
221
  @session = session
226
222
  @config = config
@@ -23,8 +23,7 @@ module Html2rss
23
23
  def initialize(channel:, articles:, status:, stylesheets: [])
24
24
  raise ArgumentError, 'channel must be a Html2rss::Channel' unless channel.is_a?(Channel)
25
25
  raise ArgumentError, 'status must be a Html2rss::Status' unless status.is_a?(Status)
26
-
27
- articles = Array(articles)
26
+ raise ArgumentError, 'articles must be an Array' unless articles.is_a?(Array)
28
27
  raise ArgumentError, 'articles must all be Html2rss::Article' unless articles.all?(Article)
29
28
 
30
29
  @channel = channel
@@ -82,7 +81,9 @@ module Html2rss
82
81
  end
83
82
 
84
83
  def freeze_stylesheets(stylesheets)
85
- Array(stylesheets).map do |sheet|
84
+ raise ArgumentError, 'stylesheets must be an Array' unless stylesheets.is_a?(Array)
85
+
86
+ stylesheets.map do |sheet|
86
87
  sheet.to_h.transform_keys(&:to_sym).transform_values do |value|
87
88
  value.is_a?(String) ? value.dup.freeze : value
88
89
  end.freeze