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
@@ -9,24 +9,33 @@ module Html2rss
9
9
  # Tag names collected as leaf semantic containers.
10
10
  CANDIDATE_NAMES = %i[article section li tr div].to_set.freeze
11
11
 
12
- module_function
13
-
14
12
  ##
15
13
  # @param segmenter [Segmenter]
16
14
  # @return [Array<Segment>]
17
- def call(segmenter)
18
- containers = collect_containers(segmenter.index)
19
- primary = PrimaryLink.new(segmenter)
15
+ def self.call(segmenter)
16
+ picks = linked_containers(segmenter)
17
+ # A link wrapping its container is a whole card: it roots one segment, not one per div inside
18
+ # it, and none when a container already selected that link from inside.
19
+ claimed = picks.filter_map { |_, link, inside| link if inside }.to_set.compare_by_identity
20
+
21
+ picks.each_with_index.filter_map do |(container, link, inside), position|
22
+ root = inside ? container : (link if claimed.add?(link))
23
+ Segment.build(root_node: root, primary_link: link, strategy: :semantic, position:) if root
24
+ end
25
+ end
20
26
 
21
- containers.each_with_index.filter_map do |container, position|
27
+ # @return [Array<Array(SST::Node, SST::Node, Boolean)>] container, its primary link, link inside it
28
+ def self.linked_containers(segmenter)
29
+ index = segmenter.index
30
+ primary = PrimaryLink.new(segmenter)
31
+ collect_containers(index).filter_map do |container|
22
32
  link = primary.select(container)
23
- next unless link || segmenter.permit_unanchored
24
-
25
- Segment.build(root_node: container, primary_link: link, strategy: :semantic, position:)
33
+ [container, link, index.descendant_of?(link, container)] if link
26
34
  end
27
35
  end
36
+ private_class_method :linked_containers
28
37
 
29
- def collect_containers(index)
38
+ def self.collect_containers(index)
30
39
  non_leaf = mark_non_leaf_candidates(index.root)
31
40
  candidates = []
32
41
  index.each_node do |node|
@@ -40,14 +49,13 @@ module Html2rss
40
49
  candidates = filter_nested(candidates, index)
41
50
  sort_by_depth(candidates, index)
42
51
  end
43
- module_function :collect_containers
44
52
  private_class_method :collect_containers
45
53
 
46
54
  TAG_MASKS = { article: 1, section: 2, li: 4, tr: 8, div: 16 }.freeze
47
55
  private_constant :TAG_MASKS
48
56
 
49
57
  # Single O(N) post-order pass: mark candidate nodes that contain a same-name candidate.
50
- def mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
58
+ def self.mark_non_leaf_candidates(root) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
51
59
  non_leaf = Set.new.compare_by_identity
52
60
  visit = lambda do |node|
53
61
  seen_mask = 0
@@ -62,10 +70,9 @@ module Html2rss
62
70
  visit.call(root)
63
71
  non_leaf
64
72
  end
65
- module_function :mark_non_leaf_candidates
66
73
  private_class_method :mark_non_leaf_candidates
67
74
 
68
- def filter_nested(candidates, index)
75
+ def self.filter_nested(candidates, index)
69
76
  candidate_set = {}.compare_by_identity
70
77
  candidates.each { |c| candidate_set[c] = true }
71
78
  rejected = {}.compare_by_identity
@@ -78,25 +85,22 @@ module Html2rss
78
85
 
79
86
  candidates.reject { |c| rejected[c] }
80
87
  end
81
- module_function :filter_nested
82
88
  private_class_method :filter_nested
83
89
 
84
- def reject_ancestors(node, candidate_set, rejected, index)
90
+ def self.reject_ancestors(node, candidate_set, rejected, index)
85
91
  curr = index.parent_of(node)
86
92
  while curr && curr.name != :html
87
93
  rejected[curr] = true if candidate_set[curr]
88
94
  curr = index.parent_of(curr)
89
95
  end
90
96
  end
91
- module_function :reject_ancestors
92
97
  private_class_method :reject_ancestors
93
98
 
94
- def sort_by_depth(candidates, index)
99
+ def self.sort_by_depth(candidates, index)
95
100
  candidates.each_with_index
96
101
  .sort_by { |node, i| [-index.depth_of(node), i] }
97
102
  .map(&:first)
98
103
  end
99
- module_function :sort_by_depth
100
104
  private_class_method :sort_by_depth
101
105
  end
102
106
  end
@@ -14,11 +14,10 @@ module Html2rss
14
14
  # @param base_url [String, Html2rss::Url]
15
15
  # @param strategy [Symbol]
16
16
  # @param opts [Hash] segmentation options
17
- # @option opts [Boolean] :permit_unanchored keep containers without a primary link
18
17
  # @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
19
18
  # @option opts [Integer] :use_top_selectors list strategy selector budget
20
19
  # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
21
- # @return [Array<Segment>]
20
+ # @return [Array<Segment>] linked segments only
22
21
  # rubocop:disable-next Style/ArgumentsForwarding -- keep named opts for YARD @option
23
22
  def self.call(document, base_url:, strategy:, **opts)
24
23
  new(document, base_url:, strategy:, **opts).call
@@ -28,18 +27,15 @@ module Html2rss
28
27
  # @param base_url [String, Html2rss::Url]
29
28
  # @param strategy [Symbol]
30
29
  # @param opts [Hash] segmentation options
31
- # @option opts [Boolean] :permit_unanchored keep containers without a primary link
32
30
  # @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
33
31
  # @option opts [Integer] :use_top_selectors list strategy selector budget
34
32
  # @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
35
33
  def initialize(document, base_url:, strategy:, **opts)
36
- raise ArgumentError, 'document must be SST::Document' unless document.is_a?(SST::Document)
37
- raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
34
+ raise Segment::InvalidError, 'document must be SST::Document' unless document.is_a?(SST::Document)
38
35
 
39
36
  @document = document
40
37
  @base_url = base_url
41
- @strategy = strategy
42
- @permit_unanchored = opts.fetch(:permit_unanchored, false)
38
+ @strategy = Segment.strategy!(strategy)
43
39
  @minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
44
40
  @use_top_selectors = opts.fetch(:use_top_selectors, 5)
45
41
  @link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
@@ -55,7 +51,7 @@ module Html2rss
55
51
  end
56
52
  end
57
53
 
58
- attr_reader :document, :base_url, :permit_unanchored, :minimum_selector_frequency,
54
+ attr_reader :document, :base_url, :minimum_selector_frequency,
59
55
  :use_top_selectors, :link_resolver, :noise_policy
60
56
 
61
57
  # @return [SST::Index]
@@ -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 ||= Scraper.normalize_sst(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
 
@@ -10,17 +10,9 @@ module Html2rss
10
10
  # Default worker threads for batch operations.
11
11
  DEFAULT_CONCURRENCY = 5
12
12
 
13
+ # Outcome of a batch operation; +to_h+ is provided by Data.
13
14
  BatchResult = Data.define(:total, :successful, :results)
14
15
 
15
- # Value object representing the outcome of a batch operation.
16
- class BatchResult
17
- ##
18
- # @return [Hash{Symbol => Object}]
19
- def to_h
20
- { total:, successful:, results: }
21
- end
22
- end
23
-
24
16
  class << self
25
17
  ##
26
18
  # Executes a block concurrently across items using a bounded worker pool.
@@ -31,11 +23,8 @@ module Html2rss
31
23
  # @yieldparam item [Object]
32
24
  # @return [Array<Object>]
33
25
  def map(items, concurrency: DEFAULT_CONCURRENCY, &)
34
- list = Array(items)
35
- return [] if list.empty?
36
-
37
- worker_count = worker_pool_size(list.size, concurrency)
38
- process_queue(list, worker_count, &)
26
+ Option.integer(:concurrency, concurrency, min: 1, max: MAX_CONCURRENCY)
27
+ process_queue(Option.list(:items, items), [concurrency, items.size].min, &)
39
28
  end
40
29
 
41
30
  ##
@@ -46,11 +35,10 @@ module Html2rss
46
35
  # @yieldparam item [Object]
47
36
  # @return [BatchResult]
48
37
  def run(items, concurrency: DEFAULT_CONCURRENCY, &)
49
- list = Array(items)
50
- results = map(list, concurrency:, &)
38
+ results = map(items, concurrency:, &)
51
39
  successful = results.count { |entry| entry.is_a?(Hash) ? entry[:ok] : !entry.nil? }
52
40
 
53
- BatchResult.new(total: list.size, successful:, results:)
41
+ BatchResult.new(total: items.size, successful:, results:)
54
42
  end
55
43
 
56
44
  ##
@@ -62,7 +50,7 @@ module Html2rss
62
50
  # @param items_selector [String, nil]
63
51
  # @return [Hash{Symbol => Object}] +:strategy+, +:items+, +:channel_title+, +:admission_drops+
64
52
  def scrape_wire(url:, strategy:, limit:, items_selector: nil) # rubocop:disable Metrics/MethodLength
65
- plan = (strategy || :auto).to_sym
53
+ plan = Option.strategy(strategy)
66
54
  feed_args = { strategy: plan, limit: }
67
55
  feed_args[:items_selector] = items_selector unless items_selector.nil?
68
56
  feed_result = Html2rss.auto_feed_result(url, **feed_args)
@@ -84,7 +72,9 @@ module Html2rss
84
72
  # @param concurrency [Integer] number of worker threads (default 5, max 10)
85
73
  # @return [BatchResult]
86
74
  def batch_scrape(urls:, strategy: :auto, limit: 10, concurrency: DEFAULT_CONCURRENCY)
87
- run(urls, concurrency:) { |url| scrape_single_url(url:, strategy:, limit:) }
75
+ strategy = Option.strategy(strategy)
76
+ Option.integer(:limit, limit, min: 1)
77
+ run(Option.list(:urls, urls), concurrency:) { |url| scrape_single_url(url:, strategy:, limit:) }
88
78
  end
89
79
 
90
80
  ##
@@ -95,7 +85,8 @@ module Html2rss
95
85
  # @param concurrency [Integer] number of worker threads (default 5, max 10)
96
86
  # @return [BatchResult]
97
87
  def batch_inspect(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY)
98
- reports = PageRecon::Diagnostics.batch(urls:, strategy:, concurrency:)
88
+ reports = PageRecon::Diagnostics.batch(urls: Option.list(:urls, urls), strategy: Option.strategy(strategy),
89
+ concurrency:)
99
90
  results = reports.map(&:to_wire_h)
100
91
  successful = reports.count { |report| diagnostic_success?(report) }
101
92
 
@@ -111,16 +102,21 @@ module Html2rss
111
102
  # @option options [String, nil] :cache_dir optional HTML cache directory
112
103
  # @return [BatchResult]
113
104
  def batch_recon(urls:, strategy: :auto, concurrency: DEFAULT_CONCURRENCY, **)
114
- results = Recon.batch(urls, strategy: (strategy || :auto).to_sym, max_threads: concurrency, **)
115
- successful = results.count { |r| r.status ? r.status < 400 : false }
105
+ Option.integer(:concurrency, concurrency, min: 1, max: MAX_CONCURRENCY)
106
+ results = Recon.batch(Option.list(:urls, urls), strategy: Option.strategy(strategy),
107
+ max_threads: concurrency, **)
108
+ successful = results.count { |result| http_success?(result.status) }
116
109
  BatchResult.new(total: results.size, successful:, results: results.map(&:to_h))
117
110
  end
118
111
 
119
112
  private
120
113
 
121
114
  def diagnostic_success?(report)
122
- status = report.to_wire_h[:status]
123
- status ? status < 400 : false
115
+ http_success?(report.to_wire_h[:status])
116
+ end
117
+
118
+ def http_success?(status)
119
+ !status.nil? && status < 400
124
120
  end
125
121
 
126
122
  def scrape_single_url(url:, strategy:, limit:)
@@ -129,16 +125,12 @@ module Html2rss
129
125
  url: url.to_s, ok: true, items_count: wire[:items].size,
130
126
  items: wire[:items], channel_title: wire[:channel_title]
131
127
  }
132
- rescue StandardError => error
128
+ rescue Html2rss::ContractError
129
+ raise
130
+ rescue Html2rss::Error => error
133
131
  { url: url.to_s, ok: false, error: error.message }
134
132
  end
135
133
 
136
- def worker_pool_size(item_count, requested_concurrency)
137
- return 1 if item_count <= 1
138
-
139
- [[1, requested_concurrency.to_i].max, item_count, MAX_CONCURRENCY].min
140
- end
141
-
142
134
  def process_queue(items, worker_count, &)
143
135
  queue = build_work_queue(items)
144
136
  results = Array.new(items.size)
@@ -157,7 +149,7 @@ module Html2rss
157
149
  end
158
150
 
159
151
  def drain_queue(queue, results)
160
- until queue.empty?
152
+ loop do
161
153
  begin
162
154
  item, index = queue.pop(true)
163
155
  rescue ThreadError
@@ -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
@@ -66,7 +69,7 @@ html2rss capture https://example.com --explain # quality JSON on stderr; YAML
66
69
  ## Constraints
67
70
 
68
71
  - Selector quality depends on page structure; treat output as a first draft.
69
- - When HTML derivation misses (no articles, no SST document, fewer than `MIN_SELECTOR_MATCHES` pairs, or a rescued `ArgumentError`), Capture emits the default items selector with `segment_strategy: :default`. `enhance` still follows admission evidence (false when chrome drops are high). Non-HTML responses still omit selectors.
72
+ - When HTML derivation misses (no articles, no SST document, fewer than `MIN_SELECTOR_MATCHES` pairs), Capture emits the default items selector with `segment_strategy: :default`. `enhance` still follows admission evidence (false when chrome drops are high). Non-HTML responses still omit selectors.
70
73
  - Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
71
74
 
72
75
  See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.