html2rss 0.30.0 → 0.32.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -3
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +34 -7
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_document.rb +135 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +74 -11
  10. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +4 -8
  11. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  12. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  13. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  14. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  16. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  17. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  18. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  19. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  20. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  21. data/lib/html2rss/auto_source/scraper.rb +12 -17
  22. data/lib/html2rss/auto_source/segment.rb +7 -4
  23. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  24. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  25. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  26. data/lib/html2rss/auto_source.rb +58 -3
  27. data/lib/html2rss/batch.rb +3 -1
  28. data/lib/html2rss/capture/README.md +6 -4
  29. data/lib/html2rss/capture.rb +67 -35
  30. data/lib/html2rss/channel.rb +3 -3
  31. data/lib/html2rss/cli/probe_view.rb +5 -3
  32. data/lib/html2rss/cli/render.rb +6 -5
  33. data/lib/html2rss/cli/validate.rb +28 -27
  34. data/lib/html2rss/cli.rb +6 -2
  35. data/lib/html2rss/config/auto_source_contract.rb +3 -0
  36. data/lib/html2rss/config/issue_mapper.rb +108 -0
  37. data/lib/html2rss/config/request_headers.rb +2 -3
  38. data/lib/html2rss/config/schema.rb +16 -1
  39. data/lib/html2rss/config/selectors_validator.rb +92 -70
  40. data/lib/html2rss/config/validation_issue.rb +60 -0
  41. data/lib/html2rss/config/validation_report.rb +55 -0
  42. data/lib/html2rss/config/validator.rb +10 -6
  43. data/lib/html2rss/config.rb +44 -114
  44. data/lib/html2rss/defaults.rb +42 -30
  45. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  46. data/lib/html2rss/doctor.rb +8 -0
  47. data/lib/html2rss/error.rb +1 -1
  48. data/lib/html2rss/feed_builder/rss.rb +9 -14
  49. data/lib/html2rss/feed_pipeline/auto_fallback.rb +11 -2
  50. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  51. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  52. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  53. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  54. data/lib/html2rss/feed_resolution/options.rb +3 -1
  55. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  56. data/lib/html2rss/feed_resolution.rb +30 -2
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor.rb +2 -4
  59. data/lib/html2rss/html/feed_link.rb +3 -1
  60. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  61. data/lib/html2rss/html/navigator.rb +10 -10
  62. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  63. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  64. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  65. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  66. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  67. data/lib/html2rss/mcp/README.md +2 -0
  68. data/lib/html2rss/mcp/config_argument.rb +3 -1
  69. data/lib/html2rss/mcp/contract.rb +7 -21
  70. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  71. data/lib/html2rss/mcp/outcome.rb +36 -43
  72. data/lib/html2rss/mcp/server/tools.rb +111 -131
  73. data/lib/html2rss/mcp/server.rb +26 -18
  74. data/lib/html2rss/page_recon/README.md +5 -5
  75. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  76. data/lib/html2rss/page_recon.rb +9 -5
  77. data/lib/html2rss/recon.rb +19 -30
  78. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  79. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  80. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  81. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  82. data/lib/html2rss/request_service/network_guard.rb +7 -5
  83. data/lib/html2rss/request_service/response.rb +6 -4
  84. data/lib/html2rss/request_service/strategy.rb +3 -8
  85. data/lib/html2rss/request_service.rb +11 -2
  86. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  87. data/lib/html2rss/scoring/engine.rb +8 -8
  88. data/lib/html2rss/scoring/observation.rb +5 -3
  89. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  90. data/lib/html2rss/scrape_target.rb +3 -1
  91. data/lib/html2rss/selector_candidates.rb +234 -0
  92. data/lib/html2rss/selectors/README.md +64 -0
  93. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  94. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  95. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  96. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  97. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  98. data/lib/html2rss/selectors/extractors.rb +33 -20
  99. data/lib/html2rss/selectors/item_env.rb +31 -0
  100. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  101. data/lib/html2rss/selectors/option_spec.rb +140 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  103. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  104. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  105. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  106. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  107. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  108. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  109. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  110. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  111. data/lib/html2rss/selectors/post_processors.rb +6 -5
  112. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  113. data/lib/html2rss/selectors/step_env.rb +24 -0
  114. data/lib/html2rss/selectors.rb +231 -142
  115. data/lib/html2rss/sst/attrs.rb +5 -3
  116. data/lib/html2rss/sst/document.rb +3 -1
  117. data/lib/html2rss/sst/index.rb +3 -1
  118. data/lib/html2rss/sst/node.rb +29 -10
  119. data/lib/html2rss/sst/normalizer.rb +12 -16
  120. data/lib/html2rss/sst/tags.rb +7 -7
  121. data/lib/html2rss/status.rb +13 -8
  122. data/lib/html2rss/surface_category.rb +14 -32
  123. data/lib/html2rss/syndication/parser.rb +2 -2
  124. data/lib/html2rss/test/enhance_audit.rb +1 -3
  125. data/lib/html2rss/test/policy.rb +106 -0
  126. data/lib/html2rss/test.rb +126 -173
  127. data/lib/html2rss/url.rb +25 -4
  128. data/lib/html2rss/version.rb +1 -1
  129. data/lib/html2rss.rb +32 -19
  130. data/lib/tasks/config_schema.rake +2 -2
  131. data/schema/html2rss-config.schema.json +26 -8
  132. metadata +16 -6
  133. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -17,6 +17,11 @@ module Html2rss
17
17
  # @return [Symbol] scraper config key
18
18
  def self.options_key = :microdata
19
19
 
20
+ ##
21
+ # @param _opts [Hash] unused options
22
+ # @return [Integer]
23
+ def self.request_slots(_opts = {}) = 0
24
+
20
25
  class << self
21
26
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
22
27
  def articles?(parsed_body)
@@ -81,6 +86,9 @@ module Html2rss
81
86
  @url = url
82
87
  end
83
88
 
89
+ # @return [Boolean]
90
+ def extractable? = self.class.articles?(parsed_body)
91
+
84
92
  ##
85
93
  # Iterates over normalized article hashes extracted from supported Microdata roots.
86
94
  #
@@ -18,6 +18,11 @@ module Html2rss
18
18
  # @return [Symbol] scraper config key
19
19
  def self.options_key = :microformats2
20
20
 
21
+ ##
22
+ # @param _opts [Hash] unused options
23
+ # @return [Integer]
24
+ def self.request_slots(_opts = {}) = 0
25
+
21
26
  class << self
22
27
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
23
28
  # @return [Boolean] whether Microformats2 h-entry elements exist
@@ -38,6 +43,9 @@ module Html2rss
38
43
  @url = Html2rss::Url.from_absolute(url)
39
44
  end
40
45
 
46
+ # @return [Boolean]
47
+ def extractable? = self.class.articles?(parsed_body)
48
+
41
49
  ##
42
50
  # Yields normalized article hashes extracted from h-entry elements.
43
51
  #
@@ -51,6 +51,9 @@ module Html2rss
51
51
  @request_session = request_session
52
52
  end
53
53
 
54
+ # @return [Boolean]
55
+ def extractable? = self.class.articles?(parsed_body)
56
+
54
57
  ##
55
58
  # @yieldparam article [Hash{Symbol => Object}]
56
59
  # @return [Enumerator, void]
@@ -19,9 +19,6 @@ module Html2rss
19
19
  ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
20
20
  ].to_set.freeze
21
21
 
22
- # Attributes exposed by `#call` in generated article hashes.
23
- DEFAULT_ATTRIBUTES = %i[id title description author url image published_at categories].freeze
24
-
25
22
  # @param schema_object [Hash{Symbol => Object}] parsed schema.org object
26
23
  # @param url [String, Html2rss::Url, nil] base URL used for relative normalization
27
24
  def initialize(schema_object, url:)
@@ -29,8 +26,10 @@ module Html2rss
29
26
  @base_url = normalized_base_url(url)
30
27
  end
31
28
 
32
- # @return [Hash] the scraped article hash with DEFAULT_ATTRIBUTES
33
- def call = DEFAULT_ATTRIBUTES.to_h { [_1, public_send(_1)] }
29
+ # @return [Hash{Symbol => Object}] scraped article attributes
30
+ def call
31
+ { id:, title:, description:, author:, url:, image:, published_at:, categories: }
32
+ end
34
33
 
35
34
  # @return [String, nil] stable schema object identifier
36
35
  def id
@@ -46,6 +46,11 @@ module Html2rss
46
46
  # @return [Symbol] scraper config key
47
47
  def self.options_key = :schema
48
48
 
49
+ ##
50
+ # @param _opts [Hash] unused options
51
+ # @return [Integer]
52
+ def self.request_slots(_opts = {}) = 0
53
+
49
54
  class << self
50
55
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
51
56
  # @return [Boolean] whether the page includes supported schema types
@@ -166,6 +171,9 @@ module Html2rss
166
171
  @opts = opts
167
172
  end
168
173
 
174
+ # @return [Boolean]
175
+ def extractable? = self.class.articles?(parsed_body)
176
+
169
177
  ##
170
178
  # @yield [Hash] Each scraped article_hash
171
179
  # @return [Array<Hash>] the scraped article_hashes
@@ -15,6 +15,11 @@ module Html2rss
15
15
  # @return [Symbol] config key used to enable or configure this scraper
16
16
  def self.options_key = :semantic_html
17
17
 
18
+ ##
19
+ # @param _opts [Hash] unused options
20
+ # @return [Integer]
21
+ def self.request_slots(_opts = {}) = 0
22
+
18
23
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
19
24
  # @return [Boolean] true when at least one semantic container has an eligible anchor
20
25
  def self.articles?(parsed_body)
@@ -54,6 +54,9 @@ module Html2rss
54
54
  @max_age_days = opts.fetch(:max_age_days, Scraper::Sitemap::Parser::DEFAULT_MAX_AGE_DAYS)
55
55
  end
56
56
 
57
+ # @return [Boolean]
58
+ def extractable? = self.class.articles?(parsed_body)
59
+
57
60
  ##
58
61
  # Yields article hashes from the sitemap.
59
62
  #
@@ -56,6 +56,9 @@ module Html2rss
56
56
  @page_scope = PageScope.from(parsed_body:, url: @url)
57
57
  end
58
58
 
59
+ # @return [Boolean]
60
+ def extractable? = self.class.articles?(parsed_body)
61
+
59
62
  ##
60
63
  # Yields article hashes from the WordPress posts API.
61
64
  #
@@ -32,15 +32,18 @@ module Html2rss
32
32
  [Html].freeze
33
33
  ].freeze
34
34
 
35
+ # JSON response body scrapers (routed by AutoSource before SCRAPER_TIERS).
36
+ DOCUMENT_SCRAPERS = [JsonDocument].freeze
37
+
35
38
  # Flat ordered list (request slot accounting, detection helpers).
36
- SCRAPERS = SCRAPER_TIERS.flatten.freeze
39
+ SCRAPERS = (DOCUMENT_SCRAPERS + SCRAPER_TIERS.flatten).freeze
37
40
 
38
41
  # Heuristic scrapers that share one memoized SST::Document per page.
39
- HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
42
+ HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
40
43
  # Scrapers that accept a shared follow-up +request_session+.
41
- REQUEST_SESSION_SCRAPERS = [NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
44
+ REQUEST_SESSION_SCRAPERS = Set[NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
42
45
  # Scrapers that consume browser-captured XHR/fetch JSON bodies.
43
- CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
46
+ CAPTURED_RESPONSE_SCRAPERS = Set[XhrArticles].freeze
44
47
 
45
48
  ##
46
49
  # Error raised when no suitable scraper is found.
@@ -51,8 +54,8 @@ module Html2rss
51
54
  'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
52
55
  'or run from an environment that can complete anti-bot checks.',
53
56
  app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
54
- 'server-rendered article HTML). Configure BOTASAURUS_SCRAPER_URL or target a direct ' \
55
- 'listing/update URL instead of a homepage or shell entrypoint.',
57
+ 'server-rendered article HTML). Target a direct listing/update URL instead of a ' \
58
+ 'homepage or shell entrypoint; a browser strategy does not add extractable markup here.',
56
59
  high_entropy_surface: 'high-entropy surface (e.g. news homepage) with few or no durable ' \
57
60
  'article destinations after admission. Target a section, topic, or ' \
58
61
  'listing/update URL instead of the site homepage.',
@@ -91,6 +94,7 @@ module Html2rss
91
94
  # @option opts [Hash] :microdata scraper toggle and configuration
92
95
  # @option opts [Hash] :microformats2 scraper toggle and configuration
93
96
  # @option opts [Hash] :json_state scraper toggle and configuration
97
+ # @option opts [Hash] :json_document scraper toggle and configuration
94
98
  # @option opts [Hash] :xhr_articles scraper toggle and configuration
95
99
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
96
100
  # @option opts [Hash] :semantic_html scraper toggle and configuration
@@ -110,7 +114,7 @@ module Html2rss
110
114
  # @param tier [Array<Class>]
111
115
  # @return [Boolean]
112
116
  def self.heuristic_tier?(tier)
113
- tier.intersect?(HEURISTIC_SCRAPERS)
117
+ HEURISTIC_SCRAPERS.intersect?(tier)
114
118
  end
115
119
 
116
120
  ##
@@ -139,6 +143,7 @@ module Html2rss
139
143
  # @option opts [Hash] :microdata scraper toggle and configuration
140
144
  # @option opts [Hash] :microformats2 scraper toggle and configuration
141
145
  # @option opts [Hash] :json_state scraper toggle and configuration
146
+ # @option opts [Hash] :json_document scraper toggle and configuration
142
147
  # @option opts [Hash] :xhr_articles scraper toggle and configuration
143
148
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
144
149
  # @option opts [Hash] :semantic_html scraper toggle and configuration
@@ -163,16 +168,6 @@ module Html2rss
163
168
  scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
164
169
  end
165
170
 
166
- ##
167
- # @param instance [Object]
168
- # @param parsed_body [Nokogiri::HTML::Document]
169
- # @return [Boolean]
170
- def self.extractable_instance?(instance, parsed_body)
171
- return instance.extractable? if instance.respond_to?(:extractable?)
172
-
173
- instance.class.articles?(parsed_body)
174
- end
175
-
176
171
  ##
177
172
  # @param parsed_body [Nokogiri::HTML::Document]
178
173
  # @param body [String, nil] raw response body (preferred for blocked-surface checks)
@@ -2,9 +2,14 @@
2
2
 
3
3
  module Html2rss
4
4
  class AutoSource
5
+ Segment = Data.define(:root_node, :primary_link, :strategy, :position)
6
+
5
7
  ##
6
8
  # One candidate content block discovered by {Segmenter}.
7
- Segment = Data.define(:root_node, :primary_link, :strategy, :position) do
9
+ class Segment
10
+ # Allowed Segmenter strategies.
11
+ STRATEGIES = Set[:semantic, :list, :cluster].freeze
12
+
8
13
  ##
9
14
  # @param root_node [SST::Node]
10
15
  # @param primary_link [SST::Node, nil]
@@ -17,13 +22,11 @@ module Html2rss
17
22
  unless primary_link.nil? || primary_link.is_a?(SST::Node)
18
23
  raise ArgumentError, 'primary_link must be SST::Node or nil'
19
24
  end
20
- raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
25
+ raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless STRATEGIES.include?(strategy)
21
26
  raise ArgumentError, 'position must be Integer' unless position.is_a?(Integer)
22
27
 
23
28
  new(root_node:, primary_link:, strategy:, position:)
24
29
  end
25
30
  end
26
- # Allowed Segmenter strategies.
27
- Segment::STRATEGIES = %i[semantic list cluster].to_set.freeze
28
31
  end
29
32
  end
@@ -13,15 +13,18 @@ module Html2rss
13
13
  # @param segmenter [Segmenter]
14
14
  # @return [Array<Segment>]
15
15
  def call(segmenter)
16
+ primary = PrimaryLink.new(segmenter)
16
17
  nodes = best_group_nodes(segmenter)
17
- nodes.each_with_index.map do |node, position|
18
- Segment.build(root_node: node, primary_link: nil, strategy: :cluster, position:)
18
+ nodes.each_with_index.filter_map do |node, position|
19
+ link = primary.select(node)
20
+ next unless link || segmenter.permit_unanchored
21
+
22
+ Segment.build(root_node: node, primary_link: link, strategy: :cluster, position:)
19
23
  end
20
24
  end
21
25
 
22
26
  def best_group_nodes(segmenter)
23
- class_groups = collect_class_groups(segmenter)
24
- groups = class_groups.empty? ? collect_structure_groups(segmenter) : class_groups
27
+ groups = collect_groups(segmenter)
25
28
  return [] if groups.empty?
26
29
 
27
30
  resolver = OverlapResolver.new(index: segmenter.index)
@@ -33,33 +36,29 @@ module Html2rss
33
36
  module_function :best_group_nodes
34
37
  private_class_method :best_group_nodes
35
38
 
36
- def collect_class_groups(segmenter) # rubocop:disable Metrics/AbcSize
37
- groups = Hash.new { |h, k| h[k] = [] }
39
+ def collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
40
+ class_groups = Hash.new { |hash, key| hash[key] = [] }
41
+ structure_groups = Hash.new { |hash, key| hash[key] = [] }
42
+ min = segmenter.minimum_selector_frequency
43
+
38
44
  segmenter.index.each_node do |node|
39
45
  next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
40
46
  next if segmenter.index.ignored_chrome?(node)
41
47
 
42
48
  cls = normalize_class(node.attrs.class_names)
43
- groups[cls] << node unless cls.empty?
44
- end
45
- groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
46
- end
47
- module_function :collect_class_groups
48
- private_class_method :collect_class_groups
49
-
50
- def collect_structure_groups(segmenter) # rubocop:disable Metrics/AbcSize
51
- groups = Hash.new { |h, k| h[k] = [] }
52
- segmenter.index.each_node do |node|
53
- next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
54
- next if segmenter.index.ignored_chrome?(node)
49
+ class_groups[cls] << node unless cls.empty?
55
50
 
56
51
  sig = structure_signature(node)
57
- groups[sig] << node unless sig.empty?
52
+ structure_groups[sig] << node unless sig.empty?
58
53
  end
59
- groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
54
+
55
+ frequent_class = class_groups.select { |_, nodes| nodes.size >= min }
56
+ return frequent_class unless frequent_class.empty?
57
+
58
+ structure_groups.select { |_, nodes| nodes.size >= min }
60
59
  end
61
- module_function :collect_structure_groups
62
- private_class_method :collect_structure_groups
60
+ module_function :collect_groups
61
+ private_class_method :collect_groups
63
62
 
64
63
  def normalize_class(class_names)
65
64
  return '' if class_names.empty?
@@ -73,7 +72,7 @@ module Html2rss
73
72
  kids = node.children
74
73
  return '' if kids.empty?
75
74
 
76
- kids.map { |c| c.name.to_s }.join('>')
75
+ kids.map { |child| child.name.to_s }.join('>')
77
76
  end
78
77
  module_function :structure_signature
79
78
  private_class_method :structure_signature
@@ -4,8 +4,8 @@ module Html2rss
4
4
  class AutoSource
5
5
  class Segmenter
6
6
  ##
7
- # Builds repeated-list article segments from tag_path frequency
8
- # (port of Discovery::ListCandidates using tag_path instead of xpath).
7
+ # Builds repeated-list article segments by grouping cards under a shared parent.
8
+ # When the document has a +main+ or +[role=main]+, anchors outside it are dropped.
9
9
  module List
10
10
  module_function
11
11
 
@@ -30,40 +30,55 @@ module Html2rss
30
30
  end
31
31
 
32
32
  def article_pairs(segmenter)
33
- by_path = relevant_links_by_path(segmenter)
34
- top_paths(segmenter, by_path).flat_map do |path|
35
- by_path.fetch(path, []).filter_map do |node|
36
- article_tag = parent_until_boundary(segmenter, node)
37
- next unless article_tag
38
-
39
- [article_tag, node]
40
- end
41
- end
33
+ top_groups(segmenter).flatten(1)
42
34
  end
43
35
  module_function :article_pairs
44
36
  private_class_method :article_pairs
45
37
 
46
- def relevant_links_by_path(segmenter)
47
- by_path = Hash.new { |hash, path| hash[path] = [] }
48
- segmenter.index.each_node do |node|
38
+ def top_groups(segmenter)
39
+ cards_by_parent(segmenter)
40
+ .select { |_parent, cards| cards.size >= segmenter.minimum_selector_frequency }
41
+ .max_by(segmenter.use_top_selectors) { |_parent, cards| cards.size }
42
+ .map(&:last)
43
+ end
44
+ module_function :top_groups
45
+ private_class_method :top_groups
46
+
47
+ def cards_by_parent(segmenter) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
48
+ index = segmenter.index
49
+ main = main_landmark(index)
50
+ groups = {}.compare_by_identity
51
+
52
+ index.each_node do |node|
49
53
  next unless node.link?
50
- next if segmenter.index.ignored_chrome?(node)
54
+ next if index.ignored_chrome?(node)
51
55
  next unless relevant_anchor?(segmenter, node)
56
+ next if main && !inside_main?(index, node, main)
57
+
58
+ card = parent_until_boundary(segmenter, node)
59
+ next unless card
52
60
 
53
- by_path[node.tag_path] << node
61
+ parent = index.parent_of(card)
62
+ next unless parent
63
+
64
+ (groups[parent] ||= []) << [card, node]
54
65
  end
55
- by_path
66
+ groups
67
+ end
68
+ module_function :cards_by_parent
69
+ private_class_method :cards_by_parent
70
+
71
+ def main_landmark(index)
72
+ index.each_node.find { |node| node.name == :main || node.attrs.raw['role'] == 'main' }
56
73
  end
57
- module_function :relevant_links_by_path
58
- private_class_method :relevant_links_by_path
74
+ module_function :main_landmark
75
+ private_class_method :main_landmark
59
76
 
60
- def top_paths(segmenter, by_path)
61
- by_path.select { |_path, nodes| nodes.size >= segmenter.minimum_selector_frequency }
62
- .max_by(segmenter.use_top_selectors) { |_path, nodes| nodes.size }
63
- .map(&:first)
77
+ def inside_main?(index, node, main)
78
+ node.equal?(main) || index.descendant_of?(node, main)
64
79
  end
65
- module_function :top_paths
66
- private_class_method :top_paths
80
+ module_function :inside_main?
81
+ private_class_method :inside_main?
67
82
 
68
83
  def relevant_anchor?(segmenter, node)
69
84
  facts = segmenter.link_resolver.destination_facts(node)
@@ -77,12 +92,15 @@ module Html2rss
77
92
  module_function :relevant_anchor?
78
93
  private_class_method :relevant_anchor?
79
94
 
95
+ BOUNDARY_TAGS = Set[:body, :html].freeze
96
+ private_constant :BOUNDARY_TAGS
97
+
80
98
  def parent_until_boundary(segmenter, node)
81
99
  index = segmenter.index
82
- link_counts = Hash.new { |hash, curr| hash[curr] = count_links(curr) }
100
+ link_counts = Hash.new { |hash, curr| hash[curr] = count_relevant_links(segmenter, curr) }
83
101
 
84
102
  index.parent_until(node, lambda { |curr|
85
- return true if %i[body html].include?(curr.name)
103
+ return true if BOUNDARY_TAGS.include?(curr.name)
86
104
  return false if index.ignored_chrome?(curr)
87
105
 
88
106
  parent = index.parent_of(curr)
@@ -92,11 +110,16 @@ module Html2rss
92
110
  module_function :parent_until_boundary
93
111
  private_class_method :parent_until_boundary
94
112
 
95
- def count_links(node)
96
- node.link? ? 1 : node.descendants.count(&:link?)
113
+ def count_relevant_links(segmenter, node)
114
+ # Chrome inside a card must not stop the walk before the shared list parent.
115
+ if node.link?
116
+ relevant_anchor?(segmenter, node) ? 1 : 0
117
+ else
118
+ node.count_descendants { |child| child.link? && relevant_anchor?(segmenter, child) }
119
+ end
97
120
  end
98
- module_function :count_links
99
- private_class_method :count_links
121
+ module_function :count_relevant_links
122
+ private_class_method :count_relevant_links
100
123
  end
101
124
  end
102
125
  end
@@ -7,7 +7,7 @@ module Html2rss
7
7
  #
8
8
  # Strategies:
9
9
  # - +:semantic+ — leaf article/section/li/tr/div containers with a primary link
10
- # - +:list+ — repeated tag_path anchors walked to a shared container boundary
10
+ # - +:list+ — repeated cards grouped by parent (`main` is a filter when present)
11
11
  # - +:cluster+ — class / structure clustering for anchorless card grids
12
12
  class Segmenter
13
13
  # @param document [SST::Document]
@@ -68,11 +68,11 @@ module Html2rss
68
68
  # @param container [SST::Node]
69
69
  # @return [Boolean]
70
70
  def landmark_ancestor?(anchor, container)
71
- cache = (@landmark_cache ||= {})
72
- key = [anchor.object_id, container.object_id]
73
- return cache[key] if cache.key?(key)
71
+ cache = (@landmark_cache ||= {}.compare_by_identity)
72
+ anchor_cache = (cache[anchor] ||= {}.compare_by_identity)
73
+ return anchor_cache[container] if anchor_cache.key?(container)
74
74
 
75
- cache[key] = compute_landmark_ancestor?(anchor:, container:)
75
+ anchor_cache[container] = compute_landmark_ancestor?(anchor:, container:)
76
76
  end
77
77
 
78
78
  private
@@ -61,6 +61,9 @@ module Html2rss
61
61
  json_state: {
62
62
  enabled: true
63
63
  },
64
+ json_document: {
65
+ enabled: true
66
+ },
64
67
  xhr_articles: {
65
68
  enabled: true
66
69
  },
@@ -93,7 +96,7 @@ module Html2rss
93
96
  Scraper::SCRAPERS.sum do |scraper|
94
97
  if config.dig(:scraper, scraper.options_key, :enabled)
95
98
  opts = config.dig(:scraper, scraper.options_key)
96
- scraper.respond_to?(:request_slots) ? scraper.request_slots(opts) : 0
99
+ scraper.request_slots(opts)
97
100
  else
98
101
  0
99
102
  end
@@ -113,6 +116,7 @@ module Html2rss
113
116
  @parsed_body = response.parsed_body
114
117
  @body = response.body
115
118
  @url = response.url
119
+ @json_response = response.json_response?
116
120
  @captured_responses = response.captured_responses
117
121
  @opts = opts
118
122
  @request_session = request_session
@@ -131,7 +135,12 @@ module Html2rss
131
135
  def articles
132
136
  @articles ||= extract_articles
133
137
  rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
134
- Log.warn "#{self.class}: no scraper matched #{url} (#{error.message})"
138
+ Log.warn({
139
+ event_name: 'auto_source.no_scraper',
140
+ host: url.host,
141
+ category: error.category,
142
+ message: "#{self.class}: no scraper matched #{url} (#{error.message})"
143
+ }.to_json)
135
144
  @admission_drops = {}.freeze
136
145
  []
137
146
  end
@@ -150,6 +159,8 @@ module Html2rss
150
159
 
151
160
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
152
161
  def extract_articles
162
+ return extract_json_document_articles if json_response?
163
+
153
164
  articles = []
154
165
  matched = false
155
166
  document = nil
@@ -180,7 +191,7 @@ module Html2rss
180
191
  captured_responses:
181
192
  )
182
193
  next unless instance
183
- next unless Scraper.extractable_instance?(instance, parsed_body)
194
+ next unless instance.extractable?
184
195
 
185
196
  matched = true
186
197
  articles.concat(run_scraper(instance))
@@ -189,11 +200,55 @@ module Html2rss
189
200
 
190
201
  raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
191
202
 
203
+ finish_articles(articles)
204
+ end
205
+
206
+ def json_response?
207
+ @json_response
208
+ end
209
+
210
+ def extract_json_document_articles
211
+ instance = json_document_instance
212
+ raise Scraper::NoScraperFound.new(category: :unsupported_surface) unless instance&.extractable?
213
+
214
+ articles = run_scraper(instance)
215
+ raise Scraper::NoScraperFound.new(category: :unsupported_surface) if articles.empty?
216
+
217
+ finish_articles(articles)
218
+ end
219
+
220
+ def json_document_instance
221
+ Scraper.build_instance(
222
+ Scraper::JsonDocument,
223
+ parsed_body,
224
+ opts: @opts.fetch(:scraper, {}),
225
+ url:,
226
+ request_session:,
227
+ body:,
228
+ captured_responses:
229
+ )
230
+ end
231
+
232
+ def finish_articles(articles)
233
+ captured_count = articles.size
192
234
  result = Cleanup.call(articles, url:, **cleanup_options)
193
235
  @admission_drops = result.drop_tallies
236
+ log_admission_emptied(captured_count, result) if captured_count.positive? && result.articles.empty?
194
237
  result.articles.first(article_limit)
195
238
  end
196
239
 
240
+ def log_admission_emptied(captured_count, result)
241
+ payload = {
242
+ event_name: 'auto_source.admission_emptied',
243
+ host: url.host,
244
+ captured_count:,
245
+ drop_tallies: result.drop_tallies
246
+ }
247
+ hint = Cleanup.admission_hint(result.drop_tallies)
248
+ payload[:hint] = hint if hint
249
+ Log.warn(payload.to_json)
250
+ end
251
+
197
252
  def enough_articles?(articles)
198
253
  return false if articles.size < article_limit
199
254
 
@@ -10,8 +10,10 @@ module Html2rss
10
10
  # Default worker threads for batch operations.
11
11
  DEFAULT_CONCURRENCY = 5
12
12
 
13
+ BatchResult = Data.define(:total, :successful, :results)
14
+
13
15
  # Value object representing the outcome of a batch operation.
14
- BatchResult = Data.define(:total, :successful, :results) do
16
+ class BatchResult
15
17
  ##
16
18
  # @return [Hash{Symbol => Object}]
17
19
  def to_h
@@ -27,7 +27,9 @@ File.write('my-feed.yml', result.yaml)
27
27
  rss = Html2rss.apply(result.config)
28
28
  ```
29
29
 
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`).
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
+
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.
31
33
 
32
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`.
33
35
 
@@ -58,13 +60,13 @@ html2rss capture https://example.com --explain # quality JSON on stderr; YAML
58
60
  1. **Request** — `FeedPipeline` (AutoFallback when `:auto`)
59
61
  2. **Discover** — AutoSource extracts admitted articles
60
62
  3. **Segment** — try SST Segmenter strategies `:list` → `:cluster` → `:semantic`
61
- 4. **Gate** — emit items selector only when ≥ `MIN_SELECTOR_MATCHES` (2) articles match
62
- 5. **Assemble** — `{ items: { selector:, enhance: true } }` plus channel
63
+ 4. **Gate** — keep a derived items selector when ≥ `MIN_SELECTOR_MATCHES` (2) articles match
64
+ 5. **Assemble** — `{ items: { selector:, enhance: } }` plus channel. HTML derivation misses fall back to `Selectors::DEFAULT_ITEMS_SELECTOR` (`segment_strategy: :default`) instead of omitting selectors.
63
65
 
64
66
  ## Constraints
65
67
 
66
68
  - Selector quality depends on page structure; treat output as a first draft.
67
- - When the quality gate fails, selectors are omitted (`has_selectors: false`) rather than inventing attribute selectors.
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.
68
70
  - Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
69
71
 
70
72
  See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.