html2rss 0.33.0 → 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 (177) 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 -21
  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 +126 -50
  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 -39
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
  19. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
  20. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
  21. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
  22. data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
  23. data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
  25. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
  27. data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
  28. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  29. data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
  30. data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
  31. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
  32. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
  33. data/lib/html2rss/auto_source/scraper.rb +10 -2
  34. data/lib/html2rss/auto_source/segment.rb +19 -5
  35. data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
  36. data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
  37. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  38. data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
  39. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  40. data/lib/html2rss/auto_source.rb +81 -26
  41. data/lib/html2rss/batch.rb +2 -0
  42. data/lib/html2rss/capture/README.md +5 -2
  43. data/lib/html2rss/capture.rb +44 -32
  44. data/lib/html2rss/channel.rb +30 -12
  45. data/lib/html2rss/cli.rb +22 -11
  46. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  47. data/lib/html2rss/config/issue_mapper.rb +4 -1
  48. data/lib/html2rss/config/request_controls.rb +6 -3
  49. data/lib/html2rss/config/schema.rb +15 -5
  50. data/lib/html2rss/config/selectors_validator.rb +2 -2
  51. data/lib/html2rss/config/validation_issue.rb +4 -1
  52. data/lib/html2rss/config/validator.rb +11 -0
  53. data/lib/html2rss/config.rb +53 -27
  54. data/lib/html2rss/contract_error.rb +9 -0
  55. data/lib/html2rss/defaults.rb +21 -23
  56. data/lib/html2rss/doctor/botasaurus.rb +7 -3
  57. data/lib/html2rss/error.rb +10 -0
  58. data/lib/html2rss/error_kind.rb +39 -0
  59. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  60. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  61. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  62. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  63. data/lib/html2rss/feed_builder/rss.rb +49 -6
  64. data/lib/html2rss/feed_builder.rb +2 -1
  65. data/lib/html2rss/feed_pipeline/README.md +1 -1
  66. data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
  67. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  68. data/lib/html2rss/feed_pipeline.rb +40 -27
  69. data/lib/html2rss/feed_resolution/README.md +1 -1
  70. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  71. data/lib/html2rss/feed_resolution/policy.rb +1 -1
  72. data/lib/html2rss/feed_resolution/probe.rb +3 -1
  73. data/lib/html2rss/feed_resolution.rb +11 -12
  74. data/lib/html2rss/feed_result.rb +9 -5
  75. data/lib/html2rss/hash_util.rb +3 -3
  76. data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
  77. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
  78. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  79. data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
  80. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  81. data/lib/html2rss/html/article_extractor.rb +19 -47
  82. data/lib/html2rss/html/article_rules/category.rb +3 -3
  83. data/lib/html2rss/html/article_rules/description.rb +6 -15
  84. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  85. data/lib/html2rss/html/article_rules/image.rb +30 -16
  86. data/lib/html2rss/html/feed_link.rb +7 -9
  87. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  88. data/lib/html2rss/html/navigator.rb +39 -9
  89. data/lib/html2rss/html/probe.rb +4 -0
  90. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  91. data/lib/html2rss/html/rendering.rb +2 -9
  92. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  93. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  94. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  95. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  96. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  97. data/lib/html2rss/html/title_pieces.rb +169 -0
  98. data/lib/html2rss/html/visibility.rb +56 -0
  99. data/lib/html2rss/html.rb +2 -0
  100. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  101. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  102. data/lib/html2rss/link_destination/path_classifier.rb +100 -9
  103. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  104. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  105. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  106. data/lib/html2rss/mcp/README.md +13 -1
  107. data/lib/html2rss/mcp/config_argument.rb +5 -2
  108. data/lib/html2rss/mcp/contract.rb +13 -4
  109. data/lib/html2rss/mcp/outcome.rb +14 -12
  110. data/lib/html2rss/mcp/runtime.rb +39 -2
  111. data/lib/html2rss/mcp/server.rb +13 -4
  112. data/lib/html2rss/page_recon/README.md +1 -1
  113. data/lib/html2rss/page_recon/diagnostics.rb +2 -0
  114. data/lib/html2rss/recon.rb +10 -12
  115. data/lib/html2rss/request_service/blocked_surface.rb +2 -1
  116. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  117. data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
  118. data/lib/html2rss/request_service/context.rb +15 -14
  119. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  120. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  121. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  122. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  123. data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
  124. data/lib/html2rss/request_service/policy.rb +48 -11
  125. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  126. data/lib/html2rss/request_service/response.rb +30 -13
  127. data/lib/html2rss/request_service/response_cache.rb +109 -0
  128. data/lib/html2rss/request_service/response_caching.rb +95 -0
  129. data/lib/html2rss/request_service/strategy.rb +5 -11
  130. data/lib/html2rss/request_service.rb +71 -12
  131. data/lib/html2rss/request_session/pager/base.rb +0 -7
  132. data/lib/html2rss/request_session/pager.rb +1 -1
  133. data/lib/html2rss/request_session.rb +6 -2
  134. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  135. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  136. data/lib/html2rss/scoring/engine.rb +6 -6
  137. data/lib/html2rss/scoring/observation.rb +2 -2
  138. data/lib/html2rss/selector_candidates.rb +19 -8
  139. data/lib/html2rss/selectors/README.md +1 -1
  140. data/lib/html2rss/selectors/option_spec.rb +4 -1
  141. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  142. data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
  143. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  144. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  145. data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
  146. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  147. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  148. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  149. data/lib/html2rss/selectors.rb +53 -12
  150. data/lib/html2rss/sst/attrs.rb +2 -2
  151. data/lib/html2rss/sst/document.rb +3 -3
  152. data/lib/html2rss/sst/index.rb +30 -11
  153. data/lib/html2rss/sst/node.rb +33 -26
  154. data/lib/html2rss/sst/normalizer.rb +31 -19
  155. data/lib/html2rss/sst/tags.rb +113 -2
  156. data/lib/html2rss/sst/text.rb +69 -29
  157. data/lib/html2rss/sst.rb +2 -0
  158. data/lib/html2rss/status.rb +6 -3
  159. data/lib/html2rss/surface_category.rb +1 -1
  160. data/lib/html2rss/syndication/README.md +12 -6
  161. data/lib/html2rss/syndication/alternate_links.rb +1 -1
  162. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  163. data/lib/html2rss/syndication/discovery.rb +82 -28
  164. data/lib/html2rss/syndication/feed.rb +20 -0
  165. data/lib/html2rss/syndication/item_media.rb +103 -0
  166. data/lib/html2rss/syndication/parser.rb +75 -51
  167. data/lib/html2rss/test/enhance_audit.rb +2 -2
  168. data/lib/html2rss/test/item_sample.rb +43 -0
  169. data/lib/html2rss/test.rb +42 -14
  170. data/lib/html2rss/url.rb +115 -14
  171. data/lib/html2rss/version.rb +1 -1
  172. data/lib/html2rss/word_count.rb +53 -0
  173. data/lib/html2rss.rb +17 -8
  174. data/schema/html2rss-config.schema.json +56 -41
  175. metadata +31 -4
  176. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  177. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
@@ -22,7 +22,13 @@ module Html2rss
22
22
  # Default max articles to keep (also the short-circuit floor across scraper tiers).
23
23
  DEFAULT_LIMIT = 25
24
24
 
25
- # Operational scraper failures quarantined within a tier; programmer errors propagate.
25
+ # Raised when a scraper yields an item that is neither an Article nor a Hash (a scraper bug).
26
+ class ScraperContractError < Html2rss::ContractError; end
27
+
28
+ # Operational scraper failures quarantined within a tier; programmer errors (including any
29
+ # {Html2rss::ContractError}) propagate.
30
+ # +ArgumentError+ stays for stdlib raises on publisher bytes (e.g. +invalid byte sequence+ after
31
+ # +CGI.unescapeHTML+ of a surrogate entity); lib raises are {Html2rss::Error} (CONTEXT.md § Error handling).
26
32
  OPERATIONAL_ERRORS = [
27
33
  Html2rss::Error,
28
34
  ArgumentError,
@@ -71,14 +77,12 @@ module Html2rss
71
77
  enabled: true
72
78
  },
73
79
  semantic_html: {
74
- enabled: true,
75
- fallback_anchorless: true
80
+ enabled: true
76
81
  },
77
82
  html: {
78
83
  enabled: true,
79
84
  minimum_selector_frequency: Scraper::Html::DEFAULT_MINIMUM_SELECTOR_FREQUENCY,
80
- use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS,
81
- fallback_anchorless: true
85
+ use_top_selectors: Scraper::Html::DEFAULT_USE_TOP_SELECTORS
82
86
  }
83
87
  },
84
88
  cleanup: Cleanup::DEFAULT_CONFIG
@@ -120,14 +124,18 @@ module Html2rss
120
124
  @captured_responses = response.captured_responses
121
125
  @opts = opts
122
126
  @request_session = request_session
127
+ @cards_read = false
123
128
  end
124
129
 
125
130
  ##
126
131
  # Extracts articles by running scraper tiers until a sufficient set is found.
127
132
  #
128
133
  # Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
129
- # SST is built only when a heuristic tier runs. Later tiers are skipped once
130
- # +limit+ articles with url+title remain after Cleanup; the result is capped to +limit+.
134
+ # SST is built at most once, lazily, by the first consumer: a heuristic tier or the
135
+ # {Corroboration} page check of structured-tier items. Later tiers are skipped once
136
+ # +limit+ counting articles with url+title remain after Cleanup ({#counting}); the result
137
+ # is capped to +limit+, counting articles first. A native feed's items come first, and
138
+ # Cleanup merges a page copy of the same article into them.
131
139
  # Html is skipped when earlier tiers already admitted at least one clean article
132
140
  # below +limit+ — quality over padding with weaker heuristic junk.
133
141
  #
@@ -153,6 +161,13 @@ module Html2rss
153
161
  @admission_drops || {}
154
162
  end
155
163
 
164
+ ##
165
+ # What the native feed this page advertises states about its channel ({Scraper::NativeFeed#channel}):
166
+ # nil until {#articles} runs, or when no feed was read.
167
+ #
168
+ # @return [Html2rss::Syndication::ChannelFacts, nil]
169
+ def feed_channel = @native_feed&.channel
170
+
156
171
  private
157
172
 
158
173
  attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
@@ -163,21 +178,13 @@ module Html2rss
163
178
 
164
179
  articles = []
165
180
  matched = false
166
- document = nil
167
- link_resolver = nil
168
181
  scraper_opts = @opts.fetch(:scraper, {})
169
182
 
170
183
  Scraper::SCRAPER_TIERS.each do |tier|
171
184
  break if enough_articles?(articles)
172
185
  break if skip_html_padding?(tier, articles)
173
186
 
174
- if Scraper.heuristic_tier?(tier)
175
- document ||= SST::Normalizer.call(parsed_body)
176
- next unless document
177
-
178
- link_resolver ||= ::Html2rss::Scoring::LinkResolver.new(url)
179
- end
180
-
187
+ @cards_read = true if Scraper.heuristic_tier?(tier)
181
188
  tier.each do |scraper_class|
182
189
  instance = Scraper.build_instance(
183
190
  scraper_class,
@@ -186,7 +193,7 @@ module Html2rss
186
193
  url:,
187
194
  request_session:,
188
195
  body:,
189
- document:,
196
+ document: (page_document if Scraper.heuristic_tier?(tier)),
190
197
  link_resolver:,
191
198
  captured_responses:
192
199
  )
@@ -195,6 +202,7 @@ module Html2rss
195
202
 
196
203
  matched = true
197
204
  articles.concat(run_scraper(instance))
205
+ @native_feed = instance if instance.is_a?(Scraper::NativeFeed)
198
206
  end
199
207
  end
200
208
 
@@ -231,10 +239,11 @@ module Html2rss
231
239
 
232
240
  def finish_articles(articles)
233
241
  captured_count = articles.size
234
- result = Cleanup.call(articles, url:, **cleanup_options)
242
+ result = cleanup_result(articles)
235
243
  @admission_drops = result.drop_tallies
236
244
  log_admission_emptied(captured_count, result) if captured_count.positive? && result.articles.empty?
237
- result.articles.first(article_limit)
245
+ counting = counting(result.articles)
246
+ (counting + (result.articles - counting)).first(article_limit)
238
247
  end
239
248
 
240
249
  def log_admission_emptied(captured_count, result)
@@ -252,7 +261,19 @@ module Html2rss
252
261
  def enough_articles?(articles)
253
262
  return false if articles.size < article_limit
254
263
 
255
- admitted_articles(articles).size >= article_limit
264
+ counting(admitted_articles(articles)).size >= article_limit
265
+ end
266
+
267
+ # The articles that count toward +limit+, in order: those the page shows in its content
268
+ # ({Corroboration#shown?}), media items (audio, video) only on a media page
269
+ # ({Corroboration#media_page?}). The others stay admitted after the counting ones. A page is
270
+ # judged a media page only once a heuristic tier has read its cards: structured items alone
271
+ # cannot tell a podcast page from a news page with an audio list. A media page's counting
272
+ # items are in page order ({Corroboration#page_order}), not tier order.
273
+ def counting(articles)
274
+ media_page = @cards_read && corroboration.media_page?(articles)
275
+ counted = articles.select { (media_page || !_1.media?) && corroboration.shown?(_1) }
276
+ media_page ? corroboration.page_order(counted) : counted
256
277
  end
257
278
 
258
279
  # Prefer fewer clean items over refilling +limit+ from the Html heuristic tier.
@@ -264,8 +285,39 @@ module Html2rss
264
285
  tier == [Scraper::Html]
265
286
  end
266
287
 
288
+ # Cleanup is deterministic and +articles+ only grows, so one result per size is reused
289
+ # by the tier checks and by {#finish_articles}.
290
+ def cleanup_result(articles)
291
+ return @cleanup_memo.last if @cleanup_memo&.first == articles.size
292
+
293
+ Cleanup.call(articles.dup, url:, corroboration:, page_links: -> { page_links },
294
+ feed_url: @native_feed&.feed_url, **cleanup_options)
295
+ .tap { @cleanup_memo = [articles.size, _1] }
296
+ end
297
+
298
+ # Page SST shared by the heuristic tiers and the page check; nil for a JSON response
299
+ # or a page without SST nodes.
300
+ def page_document
301
+ return @page_document if defined?(@page_document)
302
+
303
+ @page_document = (SST::Normalizer.call(parsed_body) unless json_response?)
304
+ end
305
+
306
+ def link_resolver
307
+ @link_resolver ||= Scoring::LinkResolver.new(url)
308
+ end
309
+
310
+ def corroboration
311
+ @corroboration ||= Corroboration.new { page_links }
312
+ end
313
+
314
+ # Rendered links of the page, built once for the page check and sponsorship.
315
+ def page_links
316
+ @page_links ||= PageLinks.build(page_document, link_resolver:)
317
+ end
318
+
267
319
  def admitted_articles(articles)
268
- Cleanup.call(articles.dup, url:, **cleanup_options).articles.select do |article|
320
+ cleanup_result(articles).articles.select do |article|
269
321
  article.url && !article.title.to_s.empty?
270
322
  end
271
323
  end
@@ -276,11 +328,11 @@ module Html2rss
276
328
 
277
329
  def run_scraper(instance)
278
330
  instance.map { |item| article_from_scraper_item(item, instance) }
331
+ rescue ContractError
332
+ raise
279
333
  rescue *OPERATIONAL_ERRORS => error
280
334
  Log.warn({
281
- event_name: 'auto_source.quarantined',
282
- host: url.host,
283
- error_class: error.class.name,
335
+ event_name: 'auto_source.quarantined', host: url.host, error_class: error.class.name,
284
336
  message: "#{self.class}: #{instance.class} quarantined #{error.class}: #{error.message}"
285
337
  }.to_json)
286
338
  []
@@ -291,9 +343,12 @@ module Html2rss
291
343
  when Article
292
344
  item
293
345
  when Hash
294
- Article.new(**item, scraper: instance.class)
346
+ # Hash items carry publisher-declared or structured titles: verbatim (collapse only), except an item
347
+ # with an explicit +kicker+, which composes +Kicker: Headline+. Heuristic scrapers yield Articles
348
+ # composed by Html::SstArticleExtractor.
349
+ Article.new(**item, compose_title: item.key?(:kicker), scraper: instance.class)
295
350
  else
296
- raise TypeError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
351
+ raise ScraperContractError, "#{instance.class} yielded #{item.class}; expected Article or Hash"
297
352
  end
298
353
  end
299
354
 
@@ -125,6 +125,8 @@ module Html2rss
125
125
  url: url.to_s, ok: true, items_count: wire[:items].size,
126
126
  items: wire[:items], channel_title: wire[:channel_title]
127
127
  }
128
+ rescue Html2rss::ContractError
129
+ raise
128
130
  rescue Html2rss::Error => error
129
131
  { url: url.to_s, ok: false, error: error.message }
130
132
  end
@@ -29,19 +29,22 @@ rss = Html2rss.apply(result.config)
29
29
 
30
30
  `Html2rss.capture` and `Capture.build` both return a `CaptureResult` with YAML (`#yaml`, includes the schema modeline) and quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`, `native_feed`, `candidates`).
31
31
 
32
- `#candidates` is ranked discovery evidence: `{ items:, title:, link:, published: }` arrays (best-first, deduplicated). Items entries are `{ selector:, enhance:, sample: }`; field entries are `{ selector:, sample: }`. `sample` stays on `#candidates` only — emitted YAML is still `items` + `enhance`. Buckets may be empty when evidence is missing. The default `a[href]` YAML fallback is not listed as discovered evidence.
32
+ `#candidates` is ranked discovery evidence: `{ items:, title:, link:, published: }` arrays (best-first, deduplicated). Items entries are `{ selector:, enhance:, sample: }`; field entries are `{ selector:, sample: }`. `sample` stays on `#candidates` only — emitted YAML is still `items` + `enhance`. `#candidates[:candidates_by_item]` is additive: for each of the top 5 item candidates (same order as `items`) it holds `{ selector:, title:, link:, published: }`, where the field buckets are computed from that item candidate's own matched nodes (reusing the discovery evidence, no refetch). Use it when the top-level `title` / `link` / `published` (which describe the first item candidate only) do not match the item selector you choose. Its first entry equals the top-level buckets. Buckets may be empty when evidence is missing. The default `a[href]` YAML fallback is not listed as discovered evidence.
33
33
 
34
34
  **Wire vs internal:** user-facing **`Html2rss.apply`** ships RSS from a config Hash. Pipeline internals **`Html2rss.feed` / `feed_result`** stay unchanged — use `apply` in CLI/MCP/docs examples, not `feed`.
35
35
 
36
36
  ### Options
37
37
 
38
38
  ```ruby
39
- Html2rss.capture('https://spa-site.com', strategy: :botasaurus)
39
+ Html2rss.capture('https://example.com', strategy: :botasaurus)
40
40
  Html2rss.capture('https://example.com', items_selector: '.article-card')
41
41
  Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
42
42
  Html2rss.capture('https://example.com', max_redirects: 8, max_requests: 4)
43
+ Html2rss.capture('https://example.com', total_timeout_seconds: 20)
43
44
  ```
44
45
 
46
+ `total_timeout_seconds:` (positive Integer, default 30) bounds the **whole** request chain: the `:auto` fallback hops (`default` → `botasaurus`) share one budget, so the bound is not per hop. When it elapses, capture raises `RequestService::RequestTimedOut`. Use it to stay under a host's own request deadline.
47
+
45
48
  `strategy: :auto` uses the same AutoFallback chain as scrape (`default` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.apply(config)` replays the same transport.
46
49
 
47
50
  ## CLI
@@ -89,6 +89,8 @@ module Html2rss
89
89
  # @option options [Boolean, nil] :enhance whether to force enhance: true
90
90
  # @option options [Integer, nil] :max_redirects optional redirect limit override
91
91
  # @option options [Integer, nil] :max_requests optional request budget override
92
+ # @option options [Integer, nil] :total_timeout_seconds optional wall-clock bound (seconds) for the whole
93
+ # request chain, including every +:auto+ fallback hop (shared budget, not per hop)
92
94
  # @option options [Integer, nil] :limit max articles to keep
93
95
  # @option options [String, nil] :local_file_path optional local HTML file path
94
96
  # @return [CaptureResult]
@@ -108,6 +110,8 @@ module Html2rss
108
110
  # @option options [Boolean, nil] :enhance whether to force enhance: true
109
111
  # @option options [Integer, nil] :max_redirects optional redirect limit override
110
112
  # @option options [Integer, nil] :max_requests optional request budget override
113
+ # @option options [Integer, nil] :total_timeout_seconds optional wall-clock bound (seconds) for the whole
114
+ # request chain, including every +:auto+ fallback hop (shared budget, not per hop)
111
115
  # @option options [Integer, nil] :limit max articles to keep
112
116
  # @option options [String, nil] :local_file_path optional local HTML file path
113
117
  def initialize(url, strategy: :auto, **options) # rubocop:disable Metrics/MethodLength
@@ -119,8 +123,7 @@ module Html2rss
119
123
  @summary = options.delete(:summary)
120
124
  @force = options.delete(:force) || false
121
125
  @enhance_option = options.delete(:enhance)
122
- @max_redirects = options.delete(:max_redirects)
123
- @max_requests = options.delete(:max_requests)
126
+ @request_options = options.slice(:max_redirects, :max_requests, :total_timeout_seconds)
124
127
  @limit = options.delete(:limit)
125
128
  @local_file_path = options.delete(:local_file_path)
126
129
  end
@@ -172,14 +175,9 @@ module Html2rss
172
175
  end
173
176
 
174
177
  def probe_native_feed(response)
175
- return nil unless response && @url
176
-
177
- Syndication::Discovery.best_feed_url(
178
- page_url: @url,
179
- request_session: discovery_session,
180
- parsed_body: (response.parsed_body if response.html_response?),
181
- html: response.body
182
- )&.to_s
178
+ Syndication::Discovery.native_feed_url(response:, request_session: discovery_session)&.to_s
179
+ rescue Html2rss::ContractError
180
+ raise
183
181
  rescue Html2rss::Error
184
182
  nil
185
183
  end
@@ -236,11 +234,7 @@ module Html2rss
236
234
  Config.auto_source_config(
237
235
  url: @url,
238
236
  items_selector: @items_selector_hint,
239
- request_controls: Config::RequestControls.from_shortcut(
240
- strategy: @strategy,
241
- max_redirects: @max_redirects,
242
- max_requests: @max_requests
243
- ),
237
+ request_controls: Config::RequestControls.from_shortcut(strategy: @strategy, **@request_options),
244
238
  limit: @limit
245
239
  ).tap { |config| apply_local_file_path!(config) }
246
240
  end
@@ -296,7 +290,7 @@ module Html2rss
296
290
 
297
291
  SEGMENT_STRATEGIES.flat_map do |strategy|
298
292
  segments = AutoSource::Segmenter.call(
299
- sst, base_url: @url, strategy:, permit_unanchored: false, link_resolver:
293
+ sst, base_url: @url, strategy:, link_resolver:
300
294
  )
301
295
  matched = match_segments_to_articles(segments, articles)
302
296
  next [] if matched.size < MIN_SELECTOR_MATCHES
@@ -344,23 +338,24 @@ module Html2rss
344
338
  end
345
339
 
346
340
  def lift_heading_link_roots(roots)
347
- roots.map { |root| lift_heading_link_root(root, roots) }
341
+ enclosed = enclosed_root_counts(roots)
342
+ roots.map { |root| lift_heading_link_root(root, enclosed) }
348
343
  end
349
344
 
350
- def lift_heading_link_root(root, all_roots)
345
+ def lift_heading_link_root(root, enclosed)
351
346
  return root unless heading_or_inner_title_link?(root)
352
347
 
353
348
  index = SST::Index.for_node(root)
354
349
  return root unless index
355
350
 
356
- walk_usable_card(root, index, all_roots)
351
+ walk_usable_card(root, index, enclosed)
357
352
  end
358
353
 
359
- def walk_usable_card(root, index, all_roots)
354
+ def walk_usable_card(root, index, enclosed)
360
355
  candidate = root
361
356
  parent = index.parent_of(root)
362
- while parent && Html::Navigator.usable_card_parent?(parent)
363
- break if contains_other_root?(parent, root, all_roots)
357
+ while Html::Navigator.usable_card_parent?(parent)
358
+ break if contains_other_root?(parent, enclosed)
364
359
 
365
360
  candidate = parent
366
361
  parent = index.parent_of(parent)
@@ -368,6 +363,28 @@ module Html2rss
368
363
  candidate
369
364
  end
370
365
 
366
+ # Identity-keyed tally of how many distinct roots each node is, or strictly encloses, so the
367
+ # card walk answers "does this ancestor hold another root?" without rescanning all roots.
368
+ def enclosed_root_counts(roots)
369
+ tally = Hash.new(0).compare_by_identity
370
+ roots.uniq(&:object_id).each do |root|
371
+ tally[root] += 1
372
+ count_enclosing_ancestors(root, tally)
373
+ end
374
+ tally
375
+ end
376
+
377
+ def count_enclosing_ancestors(root, tally)
378
+ index = SST::Index.for_node(root)
379
+ return unless index
380
+
381
+ ancestor = index.parent_of(root)
382
+ while ancestor
383
+ tally[ancestor] += 1
384
+ ancestor = index.parent_of(ancestor)
385
+ end
386
+ end
387
+
371
388
  def heading_or_inner_title_link?(node)
372
389
  name = node.name.to_s
373
390
  return false if wrapping_anchor_root?(node)
@@ -382,16 +399,9 @@ module Html2rss
382
399
  node.find { |child| !child.equal?(node) && tags.include?(child.name.to_s) }
383
400
  end
384
401
 
385
- def contains_other_root?(parent, root, all_roots)
386
- all_roots.any? do |other|
387
- next if other.equal?(root)
388
-
389
- other.equal?(parent) || sst_descendant?(other, parent)
390
- end
391
- end
392
-
393
- def sst_descendant?(child, ancestor)
394
- SST::Index.for_node(child)&.descendant_of?(child, ancestor)
402
+ # The walked-from root is itself tallied under every ancestor, so more than one means another root.
403
+ def contains_other_root?(parent, enclosed)
404
+ enclosed[parent] > 1
395
405
  end
396
406
 
397
407
  def shared_class_items_selector(roots)
@@ -446,6 +456,8 @@ module Html2rss
446
456
 
447
457
  def channel_title_from(response)
448
458
  Channel.from_response(response).title
459
+ rescue Html2rss::ContractError
460
+ raise
449
461
  rescue Html2rss::Error
450
462
  nil
451
463
  end
@@ -3,7 +3,9 @@
3
3
  require 'time'
4
4
 
5
5
  module Html2rss
6
- Channel = Data.define(:title, :url, :description, :language, :ttl, :last_build_date, :image, :author)
6
+ Channel = Data.define(
7
+ :title, :url, :description, :language, :ttl, :last_build_date, :image, :author, :categories, :explicit
8
+ )
7
9
 
8
10
  ##
9
11
  # Materialized channel metadata for feed builders and {FeedResult}.
@@ -12,6 +14,10 @@ module Html2rss
12
14
  # do not own channel extraction. Safe to Marshal (no Response / Nokogiri retained).
13
15
  # Strings and image URL are normalized and frozen at construction (including Marshal load).
14
16
  #
17
+ # The author is the config +channel.author+, else the followed native feed's author
18
+ # ({Syndication::ChannelFacts}), else the page's +<meta name="author">+. Categories and the explicit
19
+ # flag come only from that feed, when it states them.
20
+ #
15
21
  # @!attribute [r] title
16
22
  # @return [String]
17
23
  # @!attribute [r] url
@@ -28,6 +34,10 @@ module Html2rss
28
34
  # @return [Html2rss::Url, nil]
29
35
  # @!attribute [r] author
30
36
  # @return [String, nil]
37
+ # @!attribute [r] categories
38
+ # @return [Array<Array<String>>] each category followed by its subcategories (empty when unstated)
39
+ # @!attribute [r] explicit
40
+ # @return [Boolean, nil] whether the content is declared explicit; nil when unstated
31
41
  class Channel # rubocop:disable Metrics/ClassLength -- Channel value object owns extract + freeze + Marshal
32
42
  class << self
33
43
  ##
@@ -35,8 +45,9 @@ module Html2rss
35
45
  #
36
46
  # @param response [Html2rss::RequestService::Response]
37
47
  # @param overrides [Hash{Symbol => Object}] optional overrides for channel attributes
48
+ # @param feed [Html2rss::Syndication::ChannelFacts, nil] what the followed native feed states
38
49
  # @return [Html2rss::Channel]
39
- def from_response(response, overrides: {})
50
+ def from_response(response, overrides: {}, feed: nil) # rubocop:disable Metrics/MethodLength -- one line per member
40
51
  new(
41
52
  title: title_for(response, overrides),
42
53
  url: url_for(response),
@@ -45,7 +56,9 @@ module Html2rss
45
56
  ttl: ttl_for(response, overrides),
46
57
  last_build_date: last_build_date_for(response),
47
58
  image: image_for(response, overrides),
48
- author: author_for(response, overrides)
59
+ author: overrides[:author] || feed&.author || meta_author(response),
60
+ categories: feed&.categories || [],
61
+ explicit: feed&.explicit
49
62
  )
50
63
  end
51
64
 
@@ -117,8 +130,7 @@ module Html2rss
117
130
  parsed_body(response)['lang'] || parsed_body(response).at_css('[lang]')&.[]('lang')
118
131
  end
119
132
 
120
- def author_for(response, overrides)
121
- return overrides[:author] if overrides[:author]
133
+ def meta_author(response)
122
134
  return unless html_response?(response)
123
135
 
124
136
  parsed_body(response).at_css('meta[name="author"]')&.[]('content')
@@ -156,8 +168,11 @@ module Html2rss
156
168
  # @param last_build_date [Time, String]
157
169
  # @param image [Html2rss::Url, String, nil]
158
170
  # @param author [String, nil]
159
- # rubocop:disable-next Metrics/ParameterLists -- Data.define members are the channel contract
160
- def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
171
+ # @param categories [Array<Array<String>>] each category followed by its subcategories
172
+ # @param explicit [Boolean, nil]
173
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- Data.define members are the channel contract
174
+ def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:,
175
+ categories: [], explicit: nil)
161
176
  super(
162
177
  title: freeze_string(title),
163
178
  url: url.is_a?(Url) ? url : Url.from_absolute(url),
@@ -166,16 +181,19 @@ module Html2rss
166
181
  ttl: Integer(ttl),
167
182
  last_build_date: normalize_last_build_date(last_build_date),
168
183
  image: normalize_image(image),
169
- author: author && freeze_string(author)
184
+ author: author && freeze_string(author),
185
+ categories: categories.map { |path| path.map { freeze_string(_1) }.freeze }.freeze,
186
+ explicit:
170
187
  )
171
188
  end
172
189
 
173
190
  private
174
191
 
175
- def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author]
192
+ def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author, categories, explicit]
176
193
 
177
- def marshal_load((title, url, description, language, ttl, last_build_date, image, author))
178
- initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
194
+ def marshal_load((title, url, description, language, ttl, last_build_date, image, author, categories, explicit))
195
+ initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:, categories:,
196
+ explicit:)
179
197
  end
180
198
 
181
199
  def freeze_string(value) = value.to_s.dup.freeze
@@ -189,7 +207,7 @@ module Html2rss
189
207
 
190
208
  def normalize_last_build_date(value)
191
209
  return value if value.is_a?(Time)
192
- raise ArgumentError, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
210
+ raise InvalidOption, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
193
211
 
194
212
  Time.httpdate(value)
195
213
  end
data/lib/html2rss/cli.rb CHANGED
@@ -136,7 +136,7 @@ module Html2rss
136
136
  end
137
137
 
138
138
  if result.native_feed? && !options[:force]
139
- $stderr.puts "First-party RSS/Atom feed detected at #{result.native_feed}. Use --force to capture anyway." # rubocop:disable Style/StderrPuts
139
+ $stderr.puts "First-party RSS/Atom/RDF feed detected at #{result.native_feed}. Use --force to capture anyway." # rubocop:disable Style/StderrPuts
140
140
  exit(3)
141
141
  end
142
142
 
@@ -198,13 +198,8 @@ module Html2rss
198
198
  # @param source [String, nil] YAML file path or '-'
199
199
  # @param feed_name [String, nil] optional named feed
200
200
  # @return [void]
201
- def apply(source = nil, feed_name = nil) # rubocop:disable Metrics/AbcSize
202
- config = if File.file?(source.to_s)
203
- Html2rss.config_from_yaml_file(source.to_s, feed_name)
204
- else
205
- raw_content, = read_config_input(source)
206
- Config.from_yaml(raw_content)
207
- end
201
+ def apply(source = nil, feed_name = nil)
202
+ config = execute_feed { load_apply_config(source, feed_name) }
208
203
  config[:params] = options[:params] || {}
209
204
  execute_feed { apply_runtime_request_overrides!(config) }
210
205
  apply_local_file_input!(config, options[:input]) if options[:input]
@@ -220,6 +215,8 @@ module Html2rss
220
215
  method_option :keep_different_domain, type: :boolean, default: false,
221
216
  desc: 'Allow published items to link to any domain ' \
222
217
  'supplied by the source (no affiliation/safety check)'
218
+ method_option :keep_sponsored, type: :boolean, default: false,
219
+ desc: 'Keep items the page marks as sponsored (rel="sponsored" or ad labels)'
223
220
  # @param url [String, nil]
224
221
  # @return [void]
225
222
  def scrape(url = nil)
@@ -257,6 +254,8 @@ module Html2rss
257
254
  # @return [void]
258
255
  def mcp
259
256
  Html2rss::MCP.start(transport: options[:transport].to_sym, port: options[:port])
257
+ rescue Html2rss::MCP::Server::HttpTransportUnavailable => error
258
+ raise Thor::Error, error.message
260
259
  end
261
260
 
262
261
  desc 'doctor [TYPE]', 'Runtime preflight checks (botasaurus)'
@@ -309,7 +308,7 @@ module Html2rss
309
308
 
310
309
  expected = Recon::Verdict[verdict_filter.downcase]
311
310
  results.select { |r| r.verdict == expected }
312
- rescue ArgumentError => error
311
+ rescue InvalidOption => error
313
312
  raise Thor::Error, error.message
314
313
  end
315
314
 
@@ -332,7 +331,7 @@ module Html2rss
332
331
 
333
332
  def handle_capture_output(result, url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
334
333
  if options[:output_dir]
335
- dir = File.join(options[:output_dir], URI.parse(url).host.to_s.delete_prefix('www.'))
334
+ dir = File.join(options[:output_dir], Html2rss::Url.from_absolute(url).host.to_s.delete_prefix('www.'))
336
335
  FileUtils.mkdir_p(dir)
337
336
  file_path = File.join(dir, 'index.yml')
338
337
  File.write(file_path, result.yaml)
@@ -412,6 +411,13 @@ module Html2rss
412
411
  options[:strategy]&.to_sym || :auto
413
412
  end
414
413
 
414
+ def load_apply_config(source, feed_name)
415
+ return Html2rss.config_from_yaml_file(source.to_s, feed_name) if File.file?(source.to_s)
416
+
417
+ raw_content, = read_config_input(source)
418
+ Config.from_yaml(raw_content)
419
+ end
420
+
415
421
  def execute_feed # rubocop:disable Metrics/MethodLength
416
422
  yield
417
423
  rescue Html2rss::RequestService::RedirectLimitReached => error
@@ -427,6 +433,7 @@ module Html2rss
427
433
  Html2rss::RequestService::BlockedSurfaceDetected,
428
434
  Html2rss::NoFeedItemsExtracted,
429
435
  Html2rss::InvalidOption,
436
+ Html2rss::Config::InvalidConfig,
430
437
  Html2rss::Url::InvalidUrlError => error
431
438
  raise Thor::Error, error.message
432
439
  end
@@ -440,10 +447,14 @@ module Html2rss
440
447
  max_requests: options[:max_requests],
441
448
  local_file_path:,
442
449
  limit: options[:limit],
443
- keep_different_domain: options[:keep_different_domain]
450
+ **cleanup_overrides
444
451
  )
445
452
  end
446
453
 
454
+ def cleanup_overrides
455
+ { keep_different_domain: options[:keep_different_domain], keep_sponsored: options[:keep_sponsored] }
456
+ end
457
+
447
458
  def explain_json!(payload)
448
459
  $stderr.puts JSON.pretty_generate(payload) # rubocop:disable Style/StderrPuts
449
460
  end