html2rss 0.32.4 → 0.34.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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -25
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +127 -53
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
data/lib/html2rss/test.rb CHANGED
@@ -5,8 +5,6 @@ module Html2rss
5
5
  # Service that runs schema validation and live feed extraction on a configuration,
6
6
  # enforcing minimum item thresholds and capturing sample output.
7
7
  module Test # rubocop:disable Metrics/ModuleLength -- Result + FailureKind nest with owner
8
- module_function
9
-
10
8
  FailureKind = Data.define(:name)
11
9
 
12
10
  ##
@@ -19,7 +17,7 @@ module Html2rss
19
17
  ##
20
18
  # @param name [Symbol, String]
21
19
  # @return [FailureKind]
22
- def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown failure kind: #{name.inspect}" }
20
+ def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown failure kind: #{name.inspect}" }
23
21
 
24
22
  ##
25
23
  # @return [Boolean]
@@ -80,6 +78,7 @@ module Html2rss
80
78
  :success,
81
79
  :item_count,
82
80
  :sample_items,
81
+ :items,
83
82
  :channel_title,
84
83
  :channel_url,
85
84
  :strategy_used,
@@ -102,6 +101,8 @@ module Html2rss
102
101
  # @param success [Boolean]
103
102
  # @param item_count [Integer]
104
103
  # @param sample_items [Array<Hash>]
104
+ # @param items [Array<Hash>] up to +sample_limit+ items (title, url, published_at, description, image,
105
+ # categories) built from extracted articles regardless of the quality gate
105
106
  # @param channel_title [String, nil]
106
107
  # @param channel_url [String, nil]
107
108
  # @param strategy_used [Symbol, nil]
@@ -115,7 +116,7 @@ module Html2rss
115
116
  # @param response_body [String, nil] fetched page HTML copied from the pipeline response
116
117
  def initialize(success:, item_count:, sample_items:, channel_title:, channel_url:, # rubocop:disable Metrics/ParameterLists
117
118
  strategy_used:, duration_seconds:, validation_issues:, error_message:,
118
- failure_kind:, rss:, quality_report: nil, enhance_compare: nil, response_body: nil)
119
+ failure_kind:, rss:, items: [], quality_report: nil, enhance_compare: nil, response_body: nil)
119
120
  super
120
121
  end
121
122
 
@@ -138,6 +139,7 @@ module Html2rss
138
139
  success:,
139
140
  item_count:,
140
141
  sample_items:,
142
+ items:,
141
143
  channel_title:,
142
144
  channel_url:,
143
145
  strategy_used:,
@@ -162,28 +164,31 @@ module Html2rss
162
164
  # @param strategy [Symbol, nil] optional strategy override
163
165
  # @param strict_quality [Boolean] when true, fail on ship-quality audit thresholds
164
166
  # @param compare_enhance [Boolean] diagnostic enhance off vs on comparison on cached HTML
167
+ # @param sample_limit [Integer] maximum number of {Result#items} (default 3)
165
168
  # @return [Html2rss::Test::Result]
166
- def call(config_input, feed_name = nil, min_items: 1, params: {}, strategy: nil, # rubocop:disable Metrics/ParameterLists
167
- strict_quality: false, compare_enhance: false)
169
+ def self.call(config_input, feed_name = nil, min_items: 1, params: {}, strategy: nil, # rubocop:disable Metrics/ParameterLists
170
+ strict_quality: false, compare_enhance: false, sample_limit: 3)
168
171
  raw_config, validation = Config.resolve_and_validate(config_input, feed_name:, params:)
169
172
  return validation_failure_result(validation, raw_config) unless validation.success?
170
173
 
171
174
  config = build_test_config(raw_config, strategy, params)
172
175
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
- execute_timed_pipeline(raw_config, config, started, min_items:, strict_quality:, compare_enhance:)
174
- rescue StandardError => error
176
+ execute_timed_pipeline(raw_config, config, started, min_items:, strict_quality:, compare_enhance:, sample_limit:)
177
+ rescue Html2rss::ContractError
178
+ raise
179
+ rescue Html2rss::Error => error
175
180
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
176
181
  execution_failure_result(error, raw_config, duration)
177
182
  end
178
183
 
179
- def build_test_config(raw_config, strategy, params)
184
+ def self.build_test_config(raw_config, strategy, params)
180
185
  raw_config[:strategy] = strategy.to_sym if strategy
181
186
  raw_config[:params] = params if params&.any?
182
187
  Config.from_hash(raw_config)
183
188
  end
184
189
  private_class_method :build_test_config
185
190
 
186
- def compile_quality_report(raw_config, config, feed_result, pipeline_outcome)
191
+ def self.compile_quality_report(raw_config, config, feed_result, pipeline_outcome)
187
192
  quality_report_for(
188
193
  feed_result.to_rss.items,
189
194
  channel_url: raw_config.dig(:channel, :url).to_s,
@@ -194,20 +199,21 @@ module Html2rss
194
199
  end
195
200
  private_class_method :compile_quality_report
196
201
 
197
- def execute_timed_pipeline(raw_config, config, started, min_items:, strict_quality:, # rubocop:disable Metrics/ParameterLists
198
- compare_enhance:)
202
+ def self.execute_timed_pipeline(raw_config, config, started, min_items:, strict_quality:, # rubocop:disable Metrics/ParameterLists
203
+ compare_enhance:, sample_limit:)
199
204
  feed_result, pipeline_outcome = extract_feed(raw_config)
200
205
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
201
206
  quality_report = compile_quality_report(raw_config, config, feed_result, pipeline_outcome)
202
207
  enhance_compare = build_enhance_compare(config, pipeline_outcome) if compare_enhance
203
208
 
204
209
  build_test_result(raw_config, feed_result, duration, quality_report, enhance_compare,
205
- min_items:, strict_quality:, response_body: pipeline_outcome&.response&.body)
210
+ min_items:, strict_quality:, response_body: pipeline_outcome.response.body,
211
+ items: ItemSample.call(pipeline_outcome.articles, limit: sample_limit))
206
212
  end
207
213
  private_class_method :execute_timed_pipeline
208
214
 
209
- def build_test_result(raw_config, feed_result, duration, quality_report, enhance_compare, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
210
- min_items:, strict_quality:, response_body:)
215
+ def self.build_test_result(raw_config, feed_result, duration, quality_report, enhance_compare, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
216
+ min_items:, strict_quality:, response_body:, items:)
211
217
  rss_doc = feed_result.to_rss
212
218
  outcome = Policy.evaluate(item_count: rss_doc.items.size, min_items:, strict_quality:, quality_report:)
213
219
  strategy_used = feed_result.status.selected_strategy || raw_config[:strategy] ||
@@ -217,6 +223,7 @@ module Html2rss
217
223
  success: outcome.passed,
218
224
  item_count: rss_doc.items.size,
219
225
  sample_items: extract_samples(rss_doc.items),
226
+ items:,
220
227
  channel_title: feed_result.channel_title,
221
228
  channel_url: raw_config.dig(:channel, :url).to_s,
222
229
  strategy_used:,
@@ -232,27 +239,14 @@ module Html2rss
232
239
  end
233
240
  private_class_method :build_test_result
234
241
 
235
- def extract_samples(items, limit: 3)
242
+ def self.extract_samples(items, limit: 3)
236
243
  items.first(limit).map do |item|
237
- article = feed_item_as_article(item)
238
- {
239
- title: article.title.to_s.strip,
240
- url: article.url.to_s,
241
- published_at: article.published_at
242
- }
244
+ article = Article.new(title: item.title, url: item.link, published_at: item.pubDate)
245
+ { title: article.title.to_s.strip, url: article.url.to_s, published_at: article.published_at }
243
246
  end
244
247
  end
245
248
  private_class_method :extract_samples
246
249
 
247
- def feed_item_as_article(item)
248
- case item
249
- when Article then item
250
- else
251
- Article.new(title: item.title, url: item.link, published_at: item.pubDate)
252
- end
253
- end
254
- private_class_method :feed_item_as_article
255
-
256
250
  ##
257
251
  # Builds a ship-quality report for RSS items (shared by test and MCP apply).
258
252
  #
@@ -261,32 +255,33 @@ module Html2rss
261
255
  # @param config [Html2rss::Config]
262
256
  # @param feed_result [Html2rss::FeedResult]
263
257
  # @param pipeline_outcome [Html2rss::FeedPipeline::PipelineOutcome]
264
- # @param probe_native_feed [Boolean] when false, skip syndication discovery (apply ship gate)
258
+ # @param probe_native_feed [Boolean] when false, skip syndication discovery (apply ship gate);
259
+ # when true, discovery reads the page in +pipeline_outcome.response+ (no extra page fetch)
265
260
  # @return [QualityReport]
266
- def quality_report_for(items, channel_url:, config:, feed_result:, pipeline_outcome:, # rubocop:disable Metrics/ParameterLists
267
- probe_native_feed: true)
261
+ def self.quality_report_for(items, channel_url:, config:, feed_result:, pipeline_outcome:, # rubocop:disable Metrics/ParameterLists
262
+ probe_native_feed: true)
268
263
  audit = AutoSource::Cleanup.audit_feed_items(items)
269
- native_feed = probe_native_feed ? probe_native_feed_url(channel_url, config)&.to_s : nil
264
+ native_feed = probe_native_feed_url(channel_url, config, pipeline_outcome.response)&.to_s if probe_native_feed
270
265
  report = QualityReport.from_audit(audit, native_feed:)
271
266
  report = append_url_mismatch_warning(report, channel_url, feed_result)
267
+ report = append_items_without_url_warning(report, pipeline_outcome)
272
268
  merge_enhance_audit(report, config, pipeline_outcome)
273
269
  end
274
270
 
275
- def extract_feed(raw_config)
271
+ def self.extract_feed(raw_config)
276
272
  outcome, feed_result = FeedPipeline.new(raw_config).to_outcome_and_result
277
273
  [feed_result, outcome]
278
274
  end
279
275
  private_class_method :extract_feed
280
276
 
281
- def enhance_enabled?(config)
277
+ def self.enhance_enabled?(config)
282
278
  return false unless config.selectors
283
279
 
284
280
  !!config.selectors.dig(:items, :enhance)
285
281
  end
286
282
  private_class_method :enhance_enabled?
287
283
 
288
- def build_enhance_compare(config, pipeline_outcome)
289
- return nil unless pipeline_outcome
284
+ def self.build_enhance_compare(config, pipeline_outcome)
290
285
  return nil unless config.selectors
291
286
 
292
287
  EnhanceAudit.compare(
@@ -297,9 +292,8 @@ module Html2rss
297
292
  end
298
293
  private_class_method :build_enhance_compare
299
294
 
300
- def merge_enhance_audit(report, config, pipeline_outcome) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- warning merge + metrics
301
- return report unless pipeline_outcome && enhance_enabled?(config)
302
- return report unless config.selectors
295
+ def self.merge_enhance_audit(report, config, pipeline_outcome) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- warning merge + metrics
296
+ return report unless enhance_enabled?(config)
303
297
 
304
298
  slice = EnhanceAudit.probe(
305
299
  response: pipeline_outcome.response,
@@ -320,7 +314,7 @@ module Html2rss
320
314
  end
321
315
  private_class_method :merge_enhance_audit
322
316
 
323
- def enhance_gains_to_h(enhance_gains)
317
+ def self.enhance_gains_to_h(enhance_gains)
324
318
  {
325
319
  items_probed: enhance_gains.items_probed,
326
320
  keys_added: enhance_gains.keys_added,
@@ -330,7 +324,22 @@ module Html2rss
330
324
  end
331
325
  private_class_method :enhance_gains_to_h
332
326
 
333
- def append_url_mismatch_warning(report, channel_url, feed_result)
327
+ # Selector items dropped for having no URL ({Selectors#admission_drops}) leave nothing for the audit to
328
+ # see, so the warning names the +url+ selector as the place to look.
329
+ def self.append_items_without_url_warning(report, pipeline_outcome)
330
+ dropped = pipeline_outcome.admission_drops.fetch(Selectors::MISSING_URL_REASON, 0)
331
+ return report unless dropped.positive?
332
+
333
+ QualityReport.new(
334
+ warnings: (report.warnings + [:items_without_url]).freeze,
335
+ metrics: report.metrics.merge(items_without_url: dropped),
336
+ native_feed: report.native_feed,
337
+ defer_reason: report.defer_reason
338
+ )
339
+ end
340
+ private_class_method :append_items_without_url_warning
341
+
342
+ def self.append_url_mismatch_warning(report, channel_url, feed_result)
334
343
  return report unless url_mismatch?(channel_url, feed_result)
335
344
 
336
345
  warnings = report.warnings.dup
@@ -344,36 +353,36 @@ module Html2rss
344
353
  end
345
354
  private_class_method :append_url_mismatch_warning
346
355
 
347
- def url_mismatch?(channel_url, feed_result)
348
- status = feed_result&.status
349
- return false unless status
350
-
356
+ def self.url_mismatch?(channel_url, feed_result)
357
+ status = feed_result.status
351
358
  configured = channel_url.to_s
352
359
  final = status.scrape_url.to_s
353
360
  final = status.entry_url.to_s if final.empty?
354
361
  return false if configured.empty? || final.empty?
355
362
 
356
363
  !Url.from_absolute(configured).same_document?(Url.from_absolute(final))
357
- rescue ArgumentError
364
+ rescue Html2rss::Url::InvalidUrlError
358
365
  false
359
366
  end
360
367
  private_class_method :url_mismatch?
361
368
 
362
- def probe_native_feed_url(channel_url, config)
369
+ # The page the pipeline fetched (where any redirect landed) is the page probed, as in Recon and Capture: no
370
+ # extra page fetch.
371
+ def self.probe_native_feed_url(channel_url, config, response)
363
372
  return nil if channel_url.to_s.empty?
364
373
 
365
374
  resources = FeedPipeline::RuntimePolicy.resources_for(config)
366
375
  strategy = FeedPipeline::StrategyPlan.concrete_for_diagnostic(config.strategy)
367
- session = RequestSession.build(
368
- config:, strategy:, budget: resources.budget, policy: resources.policy
369
- )
370
- Syndication::Discovery.best_feed_url(page_url: channel_url, request_session: session)
371
- rescue StandardError
376
+ session = RequestSession.build(config:, strategy:, budget: resources.budget, policy: resources.policy)
377
+ Syndication::Discovery.native_feed_url(response:, request_session: session)
378
+ rescue Html2rss::ContractError
379
+ raise
380
+ rescue Html2rss::Error
372
381
  nil
373
382
  end
374
383
  private_class_method :probe_native_feed_url
375
384
 
376
- def validation_failure_result(report, raw_config) # rubocop:disable Metrics/MethodLength
385
+ def self.validation_failure_result(report, raw_config) # rubocop:disable Metrics/MethodLength
377
386
  Result.new(
378
387
  success: false,
379
388
  item_count: 0,
@@ -391,7 +400,7 @@ module Html2rss
391
400
  end
392
401
  private_class_method :validation_failure_result
393
402
 
394
- def execution_failure_result(error, raw_config, duration) # rubocop:disable Metrics/MethodLength
403
+ def self.execution_failure_result(error, raw_config, duration) # rubocop:disable Metrics/MethodLength
395
404
  Result.new(
396
405
  success: false,
397
406
  item_count: 0,