html2rss 0.24.0 → 0.26.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # Detection is intentionally shallow for most scrapers, but instance-based
12
12
  # matching is available for scrapers that need to carry expensive selection
13
13
  # state forward into extraction.
14
- module Scraper
14
+ module Scraper # rubocop:disable Metrics/ModuleLength -- tier registry + construction helpers
15
15
  # Root markers indicating likely app-shell/client-rendered surfaces.
16
16
  APP_SHELL_ROOT_SELECTORS = '#app, #root, #__next, [data-reactroot], [ng-app], [id*="app-shell"]'
17
17
  # Maximum anchors tolerated before app-shell detection is considered unlikely.
@@ -19,31 +19,37 @@ module Html2rss
19
19
  # Maximum visible text length tolerated for app-shell classification.
20
20
  APP_SHELL_MAX_VISIBLE_TEXT_LENGTH = 220
21
21
 
22
- # Ordered scraper classes considered during auto-source extraction.
23
- SCRAPERS = [
24
- WordpressApi,
25
- Sitemap,
26
- Schema,
27
- Microdata,
28
- Microformats2,
29
- JsonState,
30
- MetaOembed,
31
- SemanticHtml,
32
- Html
22
+ # Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
23
+ # Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
24
+ SCRAPER_TIERS = [
25
+ [Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
26
+ [WordpressApi, Sitemap, MetaOembed].freeze,
27
+ [SemanticHtml].freeze,
28
+ [Html].freeze
33
29
  ].freeze
34
30
 
31
+ # Flat ordered list (request slot accounting, detection helpers).
32
+ SCRAPERS = SCRAPER_TIERS.flatten.freeze
33
+
34
+ # Heuristic scrapers that share one memoized SST::Document per page.
35
+ HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
36
+ # Scrapers that accept a shared follow-up +request_session+.
37
+ REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
38
+ # Scrapers that consume browser-captured XHR/fetch JSON bodies.
39
+ CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
40
+
35
41
  ##
36
42
  # Error raised when no suitable scraper is found.
37
43
  class NoScraperFound < Html2rss::Error
38
- # User-facing messages grouped by no-scraper surface category.
44
+ # Surface diagnostics shared with {Html2rss::NoFeedItemsExtracted} (one string home).
39
45
  CATEGORY_MESSAGES = {
40
- blocked_surface: 'No scrapers found: blocked surface likely (anti-bot or interstitial). ' \
41
- 'Retry with --strategy browserless, try a more specific public listing URL, ' \
46
+ blocked_surface: 'blocked surface likely (anti-bot or interstitial). ' \
47
+ 'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
42
48
  'or run from an environment that can complete anti-bot checks.',
43
- app_shell: 'No scrapers found: app-shell surface detected (client-rendered page with little or no ' \
44
- 'server-rendered article HTML). Retry with --strategy browserless, or target a direct ' \
49
+ app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
50
+ 'server-rendered article HTML). Configure BOTASAURUS_SCRAPER_URL or target a direct ' \
45
51
  'listing/update URL instead of a homepage or shell entrypoint.',
46
- unsupported_surface: 'No scrapers found: unsupported extraction surface for auto mode. ' \
52
+ unsupported_surface: 'unsupported extraction surface for auto mode. ' \
47
53
  'Try a direct listing/changelog/category URL, ' \
48
54
  'or use explicit selectors in a feed config.'
49
55
  }.freeze
@@ -53,7 +59,7 @@ module Html2rss
53
59
  def initialize(message = nil, category: :unsupported_surface)
54
60
  validate_category!(category)
55
61
  @category = category
56
- super(message || CATEGORY_MESSAGES.fetch(@category))
62
+ super(message || "No scrapers found: #{CATEGORY_MESSAGES.fetch(@category)}")
57
63
  end
58
64
 
59
65
  attr_reader :category
@@ -77,9 +83,11 @@ module Html2rss
77
83
  # @option opts [Hash] :microdata scraper toggle and configuration
78
84
  # @option opts [Hash] :microformats2 scraper toggle and configuration
79
85
  # @option opts [Hash] :json_state scraper toggle and configuration
86
+ # @option opts [Hash] :xhr_articles scraper toggle and configuration
80
87
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
81
88
  # @option opts [Hash] :semantic_html scraper toggle and configuration
82
89
  # @option opts [Hash] :html scraper toggle and configuration
90
+ # @option opts [Hash] :sitemap scraper toggle and configuration
83
91
  # @return [Array<Class>] An array of scraper classes that can handle the parsed body.
84
92
  def self.from(parsed_body, opts = Html2rss::AutoSource::DEFAULT_CONFIG[:scraper])
85
93
  scrapers = SCRAPERS.select { |scraper| opts.dig(scraper.options_key, :enabled) }
@@ -90,63 +98,111 @@ module Html2rss
90
98
  scrapers
91
99
  end
92
100
 
93
- # Returns scraper instances ready for extraction.
94
- # @param parsed_body [Nokogiri::HTML::Document] The parsed HTML document.
95
- # @param url [String, Html2rss::Url] The page url.
96
- # @param request_session [Html2rss::RequestSession, nil] Shared follow-up session.
97
- # @param opts [Hash] The options hash.
101
+ ##
102
+ # @param tier [Array<Class>]
103
+ # @return [Boolean]
104
+ def self.heuristic_tier?(tier)
105
+ tier.intersect?(HEURISTIC_SCRAPERS)
106
+ end
107
+
108
+ ##
109
+ # @param parsed_body [Nokogiri::HTML::Document]
110
+ # @return [SST::Document, nil]
111
+ def self.normalize_sst(parsed_body)
112
+ SST::Normalizer.call(parsed_body)
113
+ rescue ArgumentError
114
+ nil
115
+ end
116
+
117
+ ##
118
+ # Builds a scraper when enabled; returns nil when disabled.
119
+ #
120
+ # @param scraper [Class]
121
+ # @param parsed_body [Nokogiri::HTML::Document]
122
+ # @param opts [Hash] full scraper options map
123
+ # @param url [Html2rss::Url, String]
124
+ # @param request_session [Html2rss::RequestSession, nil]
125
+ # @param body [String, nil]
126
+ # @param document [SST::Document, nil]
127
+ # @param link_resolver [Scoring::LinkResolver, nil]
128
+ # @param captured_responses [Array<Hash>] browser-captured JSON XHR/fetch bodies
98
129
  # @option opts [Hash] :wordpress_api scraper toggle and configuration
99
130
  # @option opts [Hash] :schema scraper toggle and configuration
100
131
  # @option opts [Hash] :microdata scraper toggle and configuration
101
132
  # @option opts [Hash] :microformats2 scraper toggle and configuration
102
133
  # @option opts [Hash] :json_state scraper toggle and configuration
134
+ # @option opts [Hash] :xhr_articles scraper toggle and configuration
103
135
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
104
136
  # @option opts [Hash] :semantic_html scraper toggle and configuration
105
137
  # @option opts [Hash] :html scraper toggle and configuration
106
- # @return [Array<Object>] An array of scraper instances that can handle the parsed body.
107
- #
108
- # `instances_for` is the main entrypoint for extraction. It lets a scraper
109
- # decide whether it matches using the same instance that will later yield
110
- # article hashes, which keeps precomputed state close to the scraper that
111
- # owns it.
112
- def self.instances_for(parsed_body, url:, request_session: nil,
113
- opts: Html2rss::AutoSource::DEFAULT_CONFIG[:scraper])
114
- instances = SCRAPERS.filter_map do |scraper|
115
- next unless opts.dig(scraper.options_key, :enabled)
116
-
117
- instance = scraper.new(parsed_body, url:, request_session:, **opts.fetch(scraper.options_key, {}))
118
- next unless extractable_instance?(instance, parsed_body)
119
-
120
- instance
121
- end
122
-
123
- raise no_scraper_found_for(parsed_body) if instances.empty?
124
-
125
- instances
138
+ # @option opts [Hash] :sitemap scraper toggle and configuration
139
+ # @return [Object, nil]
140
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
141
+ def self.build_instance(scraper, parsed_body, opts:, url:, request_session: nil, body: nil, document: nil,
142
+ link_resolver: nil, captured_responses: [])
143
+ return unless opts.dig(scraper.options_key, :enabled)
144
+ return if HEURISTIC_SCRAPERS.include?(scraper) && document.nil?
145
+
146
+ scraper_opts = opts.fetch(scraper.options_key, {}).except(:enabled)
147
+ kwargs = construction_kwargs(
148
+ scraper,
149
+ request_session:,
150
+ body:,
151
+ document:,
152
+ link_resolver:,
153
+ captured_responses:
154
+ )
155
+ scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
126
156
  end
157
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
127
158
 
159
+ ##
160
+ # @param instance [Object]
161
+ # @param parsed_body [Nokogiri::HTML::Document]
162
+ # @return [Boolean]
128
163
  def self.extractable_instance?(instance, parsed_body)
129
164
  return instance.extractable? if instance.respond_to?(:extractable?)
130
165
 
131
166
  instance.class.articles?(parsed_body)
132
167
  end
133
- private_class_method :extractable_instance?
134
168
 
135
- def self.no_scraper_found_for(parsed_body)
136
- NoScraperFound.new(category: classify_no_scraper_surface(parsed_body))
169
+ ##
170
+ # @param parsed_body [Nokogiri::HTML::Document]
171
+ # @param body [String, nil] raw response body (preferred for blocked-surface checks)
172
+ # @return [NoScraperFound]
173
+ def self.no_scraper_found_for(parsed_body, body: nil)
174
+ NoScraperFound.new(category: classify_no_scraper_surface(parsed_body, body:))
137
175
  end
138
- private_class_method :no_scraper_found_for
139
176
 
140
- def self.classify_no_scraper_surface(parsed_body)
141
- return :blocked_surface if blocked_surface?(parsed_body)
177
+ def self.construction_kwargs(scraper, request_session:, body:, document:, link_resolver:, # rubocop:disable Metrics/ParameterLists -- scraper construction bag
178
+ captured_responses:)
179
+ if HEURISTIC_SCRAPERS.include?(scraper)
180
+ { document:, link_resolver: }.compact
181
+ else
182
+ {}.tap do |kwargs|
183
+ kwargs[:request_session] = request_session if REQUEST_SESSION_SCRAPERS.include?(scraper)
184
+ kwargs[:body] = body if scraper == Sitemap
185
+ kwargs[:captured_responses] = captured_responses if CAPTURED_RESPONSE_SCRAPERS.include?(scraper)
186
+ end
187
+ end
188
+ end
189
+ private_class_method :construction_kwargs
190
+
191
+ ##
192
+ # Classifies why scrapers could not extract from a parsed page.
193
+ #
194
+ # @param parsed_body [Nokogiri::HTML::Document]
195
+ # @param body [String, nil] raw body for blocked-surface detection
196
+ # @return [Symbol] one of {NoScraperFound::CATEGORY_MESSAGES} keys
197
+ def self.classify_no_scraper_surface(parsed_body, body: nil)
198
+ return :blocked_surface if blocked_surface?(parsed_body, body:)
142
199
  return :app_shell if app_shell_surface?(parsed_body)
143
200
 
144
201
  :unsupported_surface
145
202
  end
146
- private_class_method :classify_no_scraper_surface
147
203
 
148
- def self.blocked_surface?(parsed_body)
149
- Html2rss::RequestService::BlockedSurface.interstitial?(parsed_body.to_html)
204
+ def self.blocked_surface?(parsed_body, body: nil)
205
+ Html2rss::RequestService::BlockedSurface.interstitial?(body || parsed_body.to_html)
150
206
  end
151
207
  private_class_method :blocked_surface?
152
208
 
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ ##
6
+ # One candidate content block discovered by {Segmenter}.
7
+ Segment = Data.define(:root_node, :primary_link, :strategy, :position) do
8
+ ##
9
+ # @param root_node [SST::Node]
10
+ # @param primary_link [SST::Node, nil]
11
+ # @param strategy [Symbol]
12
+ # @param position [Integer]
13
+ # @return [Segment]
14
+ # @raise [ArgumentError] on invalid construction
15
+ def self.build(root_node:, strategy:, position:, primary_link: nil)
16
+ raise ArgumentError, 'root_node must be SST::Node' unless root_node.is_a?(SST::Node)
17
+ unless primary_link.nil? || primary_link.is_a?(SST::Node)
18
+ raise ArgumentError, 'primary_link must be SST::Node or nil'
19
+ end
20
+ raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
21
+ raise ArgumentError, 'position must be Integer' unless position.is_a?(Integer)
22
+
23
+ new(root_node:, primary_link:, strategy:, position:)
24
+ end
25
+ end
26
+ # Allowed Segmenter strategies.
27
+ Segment::STRATEGIES = %i[semantic list cluster].to_set.freeze
28
+ end
29
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class Segmenter
6
+ ##
7
+ # Class / structure clustering for anchorless pages
8
+ # (port of Discovery::DomClustering + OverlapResolver; group scoring owned by Scoring::ClusterScorer).
9
+ module Cluster
10
+ module_function
11
+
12
+ ##
13
+ # @param segmenter [Segmenter]
14
+ # @return [Array<Segment>]
15
+ def call(segmenter)
16
+ 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:)
19
+ end
20
+ end
21
+
22
+ def best_group_nodes(segmenter)
23
+ class_groups = collect_class_groups(segmenter)
24
+ groups = class_groups.empty? ? collect_structure_groups(segmenter) : class_groups
25
+ return [] if groups.empty?
26
+
27
+ resolver = OverlapResolver.new(index: segmenter.index)
28
+ scorer = Scoring::ClusterScorer.new
29
+ non_containers = resolver.filter_containers(groups)
30
+ final_groups = resolver.filter_1_to_1_overlap(non_containers)
31
+ scorer.select_best_group(final_groups)
32
+ end
33
+ module_function :best_group_nodes
34
+ private_class_method :best_group_nodes
35
+
36
+ def collect_class_groups(segmenter) # rubocop:disable Metrics/AbcSize
37
+ groups = Hash.new { |h, k| h[k] = [] }
38
+ segmenter.index.each_node do |node|
39
+ next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
40
+ next if segmenter.index.ignored_chrome?(node)
41
+
42
+ 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)
55
+
56
+ sig = structure_signature(node)
57
+ groups[sig] << node unless sig.empty?
58
+ end
59
+ groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
60
+ end
61
+ module_function :collect_structure_groups
62
+ private_class_method :collect_structure_groups
63
+
64
+ def normalize_class(class_names)
65
+ return '' if class_names.empty?
66
+
67
+ class_names.sort.join(' ')
68
+ end
69
+ module_function :normalize_class
70
+ private_class_method :normalize_class
71
+
72
+ def structure_signature(node)
73
+ kids = node.children
74
+ return '' if kids.empty?
75
+
76
+ kids.map { |c| c.name.to_s }.join('>')
77
+ end
78
+ module_function :structure_signature
79
+ private_class_method :structure_signature
80
+
81
+ ##
82
+ # Filters layout wrapper groups and resolves 1-to-1 nested card wrappers.
83
+ class OverlapResolver
84
+ # Cap pairwise work on dense class grids.
85
+ MAX_GROUPS = 40
86
+
87
+ # @param index [SST::Index]
88
+ def initialize(index:)
89
+ @index = index
90
+ @layout_tags = SST::Tags::LAYOUT_NAMES
91
+ @scorer = Scoring::ClusterScorer.new
92
+ end
93
+
94
+ # @param groups [Hash{String => Array<SST::Node>}]
95
+ # @return [Hash{String => Array<SST::Node>}]
96
+ def filter_containers(groups)
97
+ capped = cap_groups(groups)
98
+ capped.reject do |cls_a, nodes_a|
99
+ capped.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
100
+ end
101
+ end
102
+
103
+ # @param groups [Hash{String => Array<SST::Node>}]
104
+ # @return [Hash{String => Array<SST::Node>}]
105
+ def filter_1_to_1_overlap(groups)
106
+ discarded = {}
107
+ groups.each_key do |cls_a|
108
+ groups.each_key do |cls_b|
109
+ next if cls_a == cls_b || discarded[cls_a] || discarded[cls_b]
110
+
111
+ resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
112
+ end
113
+ end
114
+ groups.reject { |cls, _| discarded[cls] }
115
+ end
116
+
117
+ private
118
+
119
+ def cap_groups(groups)
120
+ return groups if groups.size <= MAX_GROUPS
121
+
122
+ groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
123
+ end
124
+
125
+ # rubocop:disable Metrics/MethodLength
126
+ def container_of?(nodes_a, nodes_b)
127
+ return false unless @layout_tags.include?(nodes_b.first.name)
128
+
129
+ nodes_a.any? do |node_a|
130
+ count = 0
131
+ nodes_b.each do |node_b|
132
+ next if node_a.equal?(node_b)
133
+
134
+ if @index.descendant_of?(node_b, node_a)
135
+ count += 1
136
+ break if count > 1
137
+ end
138
+ end
139
+ count > 1
140
+ end
141
+ end
142
+ # rubocop:enable Metrics/MethodLength
143
+
144
+ def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
145
+ nodes_a = groups[cls_a]
146
+ nodes_b = groups[cls_b]
147
+ return if nodes_a.size != nodes_b.size
148
+
149
+ nested = nodes_a.zip(nodes_b).all? { |a, b| !a.equal?(b) && @index.descendant_of?(b, a) }
150
+ return unless nested
151
+
152
+ discarded[keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b] = true
153
+ end
154
+
155
+ def keep_descendant?(nodes_a, nodes_b)
156
+ @scorer.avg_words(nodes_b) >= 0.8 * @scorer.avg_words(nodes_a) &&
157
+ @layout_tags.include?(nodes_b.first.name)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class Segmenter
6
+ ##
7
+ # Builds repeated-list article segments from tag_path frequency
8
+ # (port of Discovery::ListCandidates using tag_path instead of xpath).
9
+ module List
10
+ module_function
11
+
12
+ ##
13
+ # @param segmenter [Segmenter]
14
+ # @return [Array<Segment>]
15
+ def call(segmenter)
16
+ primary = PrimaryLink.new(segmenter)
17
+ pairs = article_pairs(segmenter)
18
+ seen = {}.compare_by_identity
19
+
20
+ pairs.each_with_index.filter_map do |(article_tag, selected_anchor), position|
21
+ next unless seen[article_tag].nil?
22
+
23
+ seen[article_tag] = true
24
+
25
+ link = selected_anchor || primary.select(article_tag)
26
+ next unless link || segmenter.permit_unanchored
27
+
28
+ Segment.build(root_node: article_tag, primary_link: link, strategy: :list, position:)
29
+ end
30
+ end
31
+
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
42
+ end
43
+ module_function :article_pairs
44
+ private_class_method :article_pairs
45
+
46
+ def relevant_links_by_path(segmenter)
47
+ by_path = Hash.new { |hash, path| hash[path] = [] }
48
+ segmenter.index.each_node do |node|
49
+ next unless node.link?
50
+ next if segmenter.index.ignored_chrome?(node)
51
+ next unless relevant_anchor?(segmenter, node)
52
+
53
+ by_path[node.tag_path] << node
54
+ end
55
+ by_path
56
+ end
57
+ module_function :relevant_links_by_path
58
+ private_class_method :relevant_links_by_path
59
+
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)
64
+ end
65
+ module_function :top_paths
66
+ private_class_method :top_paths
67
+
68
+ def relevant_anchor?(segmenter, node)
69
+ facts = segmenter.link_resolver.destination_facts(node)
70
+ return false unless facts
71
+
72
+ text = node.visible_text.to_s.strip
73
+ !segmenter.noise_policy.noise_anchor?(
74
+ text:, destination_facts: facts, anchor: node, utility_landmark_ancestor: false
75
+ )
76
+ end
77
+ module_function :relevant_anchor?
78
+ private_class_method :relevant_anchor?
79
+
80
+ def parent_until_boundary(segmenter, node)
81
+ index = segmenter.index
82
+ link_counts = Hash.new { |hash, curr| hash[curr] = count_links(curr) }
83
+
84
+ index.parent_until(node, lambda { |curr|
85
+ return true if %i[body html].include?(curr.name)
86
+ return false if index.ignored_chrome?(curr)
87
+
88
+ parent = index.parent_of(curr)
89
+ parent && link_counts[parent] > link_counts[curr]
90
+ })
91
+ end
92
+ module_function :parent_until_boundary
93
+ private_class_method :parent_until_boundary
94
+
95
+ def count_links(node)
96
+ node.link? ? 1 : node.descendants.count(&:link?)
97
+ end
98
+ module_function :count_links
99
+ private_class_method :count_links
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class Segmenter
6
+ ##
7
+ # Selects the strongest content-like primary link inside a container
8
+ # (port of Discovery::SemanticAnchorCandidates).
9
+ class PrimaryLink
10
+ # @param segmenter [Segmenter]
11
+ def initialize(segmenter)
12
+ @segmenter = segmenter
13
+ @index = segmenter.index
14
+ @link_resolver = segmenter.link_resolver
15
+ @noise_policy = segmenter.noise_policy
16
+ end
17
+
18
+ ##
19
+ # @param container [SST::Node]
20
+ # @return [SST::Node, nil]
21
+ def select(container) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
22
+ winners = {}
23
+
24
+ container.find_all(&:link?).each do |anchor|
25
+ next if @index.ignored_chrome?(anchor)
26
+
27
+ facts = candidate_facts(anchor, container)
28
+ next unless facts
29
+
30
+ dest = facts[:destination]
31
+ current = winners[dest]
32
+ winners[dest] = facts if current.nil? || facts[:score] > current[:score]
33
+ end
34
+
35
+ winners.values.max_by { |f| f[:score] }&.fetch(:anchor)
36
+ end
37
+
38
+ private
39
+
40
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
41
+ def candidate_facts(anchor, container)
42
+ destination = @link_resolver.destination_facts(anchor)
43
+ return unless destination
44
+
45
+ text = anchor.visible_text.to_s.strip
46
+ heading = first_heading(container)
47
+ heading_text = heading ? heading.visible_text.to_s.strip : ''
48
+ heading_anchor = heading_anchor?(anchor, heading)
49
+ meaningful = text.match?(/\p{Alnum}/)
50
+ content_like = destination.content_path
51
+ heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
52
+
53
+ return if @noise_policy.noise_anchor?(
54
+ text:, destination_facts: destination, anchor:, container:, heading_anchor:,
55
+ utility_landmark_ancestor: @segmenter.landmark_ancestor?(anchor, container)
56
+ )
57
+ return unless meaningful || content_like || heading_anchor
58
+
59
+ score = (heading_anchor ? 100 : 0) +
60
+ (heading_match ? 20 : 0) +
61
+ (meaningful ? 10 : 0) +
62
+ (content_like ? 10 : 0)
63
+
64
+ { anchor:, destination: destination.destination, score: }
65
+ end
66
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
67
+
68
+ def first_heading(container)
69
+ (@headings ||= {}.compare_by_identity)[container] ||= container.find(&:heading?)
70
+ end
71
+
72
+ def heading_anchor?(anchor, heading)
73
+ return false unless heading
74
+
75
+ anchor.equal?(heading) || @index.descendant_of?(anchor, heading)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end