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
@@ -7,7 +7,7 @@ module Html2rss
7
7
  module Scraper
8
8
  ##
9
9
  # Scrapes articles from XML sitemaps explicitly linked in HTML or discovered via standard sitemap paths.
10
- class Sitemap # rubocop:disable Metrics/ClassLength -- scraper + structured warn paths
10
+ class Sitemap
11
11
  include Enumerable
12
12
 
13
13
  # Selector for sitemap link tags in HTML head
@@ -43,8 +43,9 @@ module Html2rss
43
43
  # @param url [String, Html2rss::Url] canonical page URL
44
44
  # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
45
45
  # @param body [String, nil] raw response body for direct sitemap XML parsing
46
- # @option _opts [Float] :min_priority minimum priority score (default: 0.3)
47
- # @option _opts [Integer] :max_age_days maximum entry age in days (default: 30)
46
+ # @param opts [Hash] sitemap options
47
+ # @option opts [Float] :min_priority minimum priority score (default: 0.3)
48
+ # @option opts [Integer] :max_age_days maximum entry age in days (default: 30)
48
49
  def initialize(parsed_body, url:, request_session: nil, body: nil, **opts)
49
50
  @parsed_body = parsed_body
50
51
  @body = body
@@ -65,13 +66,7 @@ module Html2rss
65
66
  def each
66
67
  return enum_for(:each) unless block_given?
67
68
 
68
- entries = fetch_and_parse_entries
69
- return if entries.empty?
70
-
71
- entries.each do |entry|
72
- article_hash = article_from_entry(entry)
73
- yield article_hash if article_hash
74
- end
69
+ fetch_and_parse_entries.each { |entry| yield article_from_entry(entry) }
75
70
  end
76
71
 
77
72
  private
@@ -137,11 +132,11 @@ module Html2rss
137
132
 
138
133
  # rubocop:disable-next Metrics/MethodLength -- structured fetch-failed warn
139
134
  def fetch_xml(target_url)
140
- return unless request_session && target_url
135
+ return unless request_session
141
136
 
142
137
  response = request_session.follow_up(url: target_url, relation: :auto_source, origin_url: url)
143
138
  response&.body
144
- rescue Html2rss::RequestService::RequestBudgetExceeded
139
+ rescue Html2rss::RequestService::RequestBudgetExceeded, Html2rss::ContractError
145
140
  raise
146
141
  rescue Html2rss::Error => error
147
142
  Log.warn({
@@ -160,8 +155,6 @@ module Html2rss
160
155
  end
161
156
 
162
157
  def article_from_entry(entry)
163
- return unless entry.url
164
-
165
158
  {
166
159
  url: entry.url,
167
160
  title: entry.title,
@@ -154,7 +154,7 @@ module Html2rss
154
154
  end
155
155
 
156
156
  def archive_like?
157
- category_archive? || tag_archive? || author_archive? || date_archive? || body_classes.include?('archive')
157
+ body_classes.include?('archive')
158
158
  end
159
159
 
160
160
  def singular_like?
@@ -178,7 +178,7 @@ module Html2rss
178
178
 
179
179
  canonical_url = Html2rss::Url.from_relative(href, url)
180
180
  same_origin_url?(canonical_url, url) ? canonical_url : url
181
- rescue ArgumentError
181
+ rescue Html2rss::Url::InvalidUrlError
182
182
  url
183
183
  end
184
184
 
@@ -54,7 +54,7 @@ module Html2rss
54
54
  return log_missing_api_root if href.empty?
55
55
 
56
56
  Html2rss::Url.from_relative(href, page_url)
57
- rescue ArgumentError => error
57
+ rescue Html2rss::Url::InvalidUrlError => error
58
58
  logger.warn("#{WordpressApi}: invalid WordPress API endpoint #{href.inspect} (#{error.message})")
59
59
  nil
60
60
  end
@@ -110,13 +110,11 @@ module Html2rss
110
110
  def normalized_api_path(path)
111
111
  segments = path.to_s.split('/').reject(&:empty?)
112
112
  normalized_path = "/#{segments.join('/')}"
113
- normalized_path = '/' if normalized_path == '/'
114
113
  normalized_path.end_with?('/') ? normalized_path : "#{normalized_path}/"
115
114
  end
116
115
 
117
116
  def normalized_rest_route(route)
118
117
  value = route.to_s
119
- value = '/' if value.empty?
120
118
  value = "/#{value}" unless value.start_with?('/')
121
119
  trim_trailing_slashes(value)
122
120
  end
@@ -46,10 +46,8 @@ module Html2rss
46
46
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
47
47
  # @param url [String, Html2rss::Url] canonical page URL
48
48
  # @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
49
- # @param _opts [Hash] unused scraper-specific options
50
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
51
49
  # @return [void]
52
- def initialize(parsed_body, url:, request_session: nil, **_opts)
50
+ def initialize(parsed_body, url:, request_session: nil)
53
51
  @parsed_body = parsed_body
54
52
  @url = Html2rss::Url.from_absolute(url)
55
53
  @request_session = request_session
@@ -109,6 +107,8 @@ module Html2rss
109
107
  relation: :auto_source,
110
108
  origin_url: url
111
109
  )
110
+ rescue Html2rss::ContractError
111
+ raise
112
112
  rescue Html2rss::Error => error
113
113
  Log.warn({
114
114
  event_name: 'wordpress_api.fetch_failed',
@@ -132,8 +132,8 @@ module Html2rss
132
132
  absolute_link(post[:link])
133
133
  end
134
134
 
135
- def article_id(_post, article_url)
136
- root_path_query_id(article_url) || present_string(article_url.path) || article_url.to_s
135
+ def article_id(article_url)
136
+ root_path_query_id(article_url) || present_string(article_url.path)
137
137
  end
138
138
 
139
139
  def article_title(post)
@@ -154,7 +154,7 @@ module Html2rss
154
154
 
155
155
  def article_attributes(post, article_url)
156
156
  {
157
- id: article_id(post, article_url),
157
+ id: article_id(article_url),
158
158
  title: article_title(post),
159
159
  description: article_description(post),
160
160
  url: article_url,
@@ -168,7 +168,7 @@ module Html2rss
168
168
  return unless value
169
169
 
170
170
  Html2rss::Url.from_relative(value, url)
171
- rescue ArgumentError
171
+ rescue Html2rss::Url::InvalidUrlError
172
172
  nil
173
173
  end
174
174
 
@@ -26,16 +26,14 @@ module Html2rss
26
26
  # @param _parsed_body [Nokogiri::HTML::Document, nil] unused HTML document
27
27
  # @param url [String, Html2rss::Url] page URL used to resolve relative links
28
28
  # @param captured_responses [Array<Hash>] JSON bodies from Response#captured_responses
29
- # @param _opts [Hash] scraper-specific options
30
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
31
- def initialize(_parsed_body, url:, captured_responses: [], **_opts)
29
+ def initialize(_parsed_body, url:, captured_responses: [])
32
30
  @url = url
33
31
  @captured_responses = captured_responses
34
32
  end
35
33
 
36
34
  # @return [Boolean] true when any captured body contains article-like arrays
37
35
  def extractable?
38
- parsed_bodies.any? { |doc| JsonState::CandidateDetector.candidate_array?(doc) }
36
+ parsed_bodies.any?
39
37
  end
40
38
 
41
39
  # @yield [Hash{Symbol => Object}] normalized article hash
@@ -38,8 +38,16 @@ module Html2rss
38
38
  # Flat ordered list (request slot accounting, detection helpers).
39
39
  SCRAPERS = (DOCUMENT_SCRAPERS + SCRAPER_TIERS.flatten).freeze
40
40
 
41
- # Heuristic scrapers that share one memoized SST::Document per page.
41
+ # Heuristic scrapers that share one memoized SST::Document per page. The only titles composed
42
+ # (+Kicker: Headline+, noise stripped): their cards are read by Html::SstArticleExtractor. Every
43
+ # other scraper yields publisher-declared or structured headlines, kept verbatim (collapse only).
42
44
  HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
45
+ # In-page structured scrapers whose items {Corroboration} checks against the rendered page.
46
+ # Publisher-declared sources (NativeFeed, WordpressApi, Sitemap, MetaOembed) stay exempt.
47
+ CORROBORATED_SCRAPERS = Set[Schema, Microdata, Microformats2, JsonState, XhrArticles, JsonDocument].freeze
48
+ # Scrapers whose items are read off the rendered page: such an item needs a title ({Cleanup}
49
+ # drops it as +blank_title+), while a publisher-declared item (feed, API) may be untitled.
50
+ PAGE_SCRAPERS = (CORROBORATED_SCRAPERS | HEURISTIC_SCRAPERS).freeze
43
51
  # Scrapers that accept a shared follow-up +request_session+.
44
52
  REQUEST_SESSION_SCRAPERS = Set[NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
45
53
  # Scrapers that consume browser-captured XHR/fetch JSON bodies.
@@ -80,7 +88,7 @@ module Html2rss
80
88
  return if CATEGORY_MESSAGES.key?(category)
81
89
 
82
90
  valid_categories = CATEGORY_MESSAGES.keys.join(', ')
83
- raise ArgumentError, "Unknown category: #{category.inspect}. Valid categories are: #{valid_categories}"
91
+ raise InvalidOption, "Unknown category: #{category.inspect}. Valid categories are: #{valid_categories}"
84
92
  end
85
93
  end
86
94
 
@@ -117,15 +125,6 @@ module Html2rss
117
125
  HEURISTIC_SCRAPERS.intersect?(tier)
118
126
  end
119
127
 
120
- ##
121
- # @param parsed_body [Nokogiri::HTML::Document]
122
- # @return [SST::Document, nil]
123
- def self.normalize_sst(parsed_body)
124
- SST::Normalizer.call(parsed_body)
125
- rescue SST::Normalizer::EmptyTree
126
- nil
127
- end
128
-
129
128
  ##
130
129
  # Builds a scraper when enabled; returns nil when disabled.
131
130
  #
@@ -220,8 +219,6 @@ module Html2rss
220
219
  private_class_method :app_shell_surface?
221
220
 
222
221
  def self.high_entropy_surface?(parsed_body)
223
- return false if parsed_body.nil?
224
-
225
222
  parsed_body.css('body a[href]').size >= HIGH_ENTROPY_MIN_ANCHORS
226
223
  end
227
224
  private_class_method :high_entropy_surface?
@@ -7,6 +7,9 @@ module Html2rss
7
7
  ##
8
8
  # One candidate content block discovered by {Segmenter}.
9
9
  class Segment
10
+ # Raised when a segment (or the Segmenter producing segments) gets invalid input.
11
+ class InvalidError < Html2rss::ContractError; end
12
+
10
13
  # Allowed Segmenter strategies.
11
14
  STRATEGIES = Set[:semantic, :list, :cluster].freeze
12
15
 
@@ -16,17 +19,28 @@ module Html2rss
16
19
  # @param strategy [Symbol]
17
20
  # @param position [Integer]
18
21
  # @return [Segment]
19
- # @raise [ArgumentError] on invalid construction
22
+ # @raise [InvalidError] on invalid construction
20
23
  def self.build(root_node:, strategy:, position:, primary_link: nil)
21
- raise ArgumentError, 'root_node must be SST::Node' unless root_node.is_a?(SST::Node)
24
+ raise InvalidError, 'root_node must be SST::Node' unless root_node.is_a?(SST::Node)
22
25
  unless primary_link.nil? || primary_link.is_a?(SST::Node)
23
- raise ArgumentError, 'primary_link must be SST::Node or nil'
26
+ raise InvalidError, 'primary_link must be SST::Node or nil'
24
27
  end
25
- raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless STRATEGIES.include?(strategy)
26
- raise ArgumentError, 'position must be Integer' unless position.is_a?(Integer)
28
+
29
+ strategy!(strategy)
30
+ raise InvalidError, 'position must be Integer' unless position.is_a?(Integer)
27
31
 
28
32
  new(root_node:, primary_link:, strategy:, position:)
29
33
  end
34
+
35
+ ##
36
+ # @param strategy [Symbol]
37
+ # @return [Symbol] +strategy+ when it is one of {STRATEGIES}
38
+ # @raise [InvalidError] for any other value
39
+ def self.strategy!(strategy)
40
+ return strategy if STRATEGIES.include?(strategy)
41
+
42
+ raise InvalidError, "unknown strategy: #{strategy.inspect}"
43
+ end
30
44
  end
31
45
  end
32
46
  end
@@ -7,23 +7,21 @@ module Html2rss
7
7
  # Class / structure clustering for anchorless pages
8
8
  # (port of Discovery::DomClustering + OverlapResolver; group scoring owned by Scoring::ClusterScorer).
9
9
  module Cluster
10
- module_function
11
-
12
10
  ##
13
11
  # @param segmenter [Segmenter]
14
12
  # @return [Array<Segment>]
15
- def call(segmenter)
13
+ def self.call(segmenter)
16
14
  primary = PrimaryLink.new(segmenter)
17
15
  nodes = best_group_nodes(segmenter)
18
16
  nodes.each_with_index.filter_map do |node, position|
19
17
  link = primary.select(node)
20
- next unless link || segmenter.permit_unanchored
18
+ next unless link
21
19
 
22
20
  Segment.build(root_node: node, primary_link: link, strategy: :cluster, position:)
23
21
  end
24
22
  end
25
23
 
26
- def best_group_nodes(segmenter)
24
+ def self.best_group_nodes(segmenter)
27
25
  groups = collect_groups(segmenter)
28
26
  return [] if groups.empty?
29
27
 
@@ -33,10 +31,9 @@ module Html2rss
33
31
  final_groups = resolver.filter_1_to_1_overlap(non_containers)
34
32
  scorer.select_best_group(final_groups)
35
33
  end
36
- module_function :best_group_nodes
37
34
  private_class_method :best_group_nodes
38
35
 
39
- def collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
36
+ def self.collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
40
37
  class_groups = Hash.new { |hash, key| hash[key] = [] }
41
38
  structure_groups = Hash.new { |hash, key| hash[key] = [] }
42
39
  min = segmenter.minimum_selector_frequency
@@ -57,24 +54,21 @@ module Html2rss
57
54
 
58
55
  structure_groups.select { |_, nodes| nodes.size >= min }
59
56
  end
60
- module_function :collect_groups
61
57
  private_class_method :collect_groups
62
58
 
63
- def normalize_class(class_names)
59
+ def self.normalize_class(class_names)
64
60
  return '' if class_names.empty?
65
61
 
66
62
  class_names.map { ::Html2rss::Html::Probe.fold(_1) }.sort.join(' ')
67
63
  end
68
- module_function :normalize_class
69
64
  private_class_method :normalize_class
70
65
 
71
- def structure_signature(node)
66
+ def self.structure_signature(node)
72
67
  kids = node.children
73
68
  return '' if kids.empty?
74
69
 
75
70
  kids.map { |child| child.name.to_s }.join('>')
76
71
  end
77
- module_function :structure_signature
78
72
  private_class_method :structure_signature
79
73
 
80
74
  ##
@@ -121,21 +115,23 @@ module Html2rss
121
115
  groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
122
116
  end
123
117
 
124
- # rubocop:disable-next Metrics/MethodLength
118
+ # A group is a container of +nodes_b+ when one of its nodes strictly encloses two or more of them.
125
119
  def container_of?(nodes_a, nodes_b)
126
120
  return false unless @layout_tags.include?(nodes_b.first.name)
127
121
 
128
- nodes_a.any? do |node_a|
129
- count = 0
130
- nodes_b.each do |node_b|
131
- next if node_a.equal?(node_b)
122
+ counts = enclosed_counts(nodes_b)
123
+ nodes_a.any? { |node_a| counts[node_a] > 1 }
124
+ end
132
125
 
133
- if @index.descendant_of?(node_b, node_a)
134
- count += 1
135
- break if count > 1
136
- end
126
+ # Number of +nodes+ strictly beneath each ancestor, tallied once per group instead of per pair.
127
+ def enclosed_counts(nodes)
128
+ @enclosed_counts ||= {}.compare_by_identity
129
+ @enclosed_counts[nodes] ||= nodes.each_with_object(Hash.new(0).compare_by_identity) do |node, tally|
130
+ ancestor = @index.parent_of(node)
131
+ while ancestor
132
+ tally[ancestor] += 1
133
+ ancestor = @index.parent_of(ancestor)
137
134
  end
138
- count > 1
139
135
  end
140
136
  end
141
137
 
@@ -7,118 +7,94 @@ module Html2rss
7
7
  # Builds repeated-list article segments by grouping cards under a shared parent.
8
8
  # When the document has a +main+ or +[role=main]+, anchors outside it are dropped.
9
9
  module List
10
- module_function
11
-
12
10
  ##
13
11
  # @param segmenter [Segmenter]
14
12
  # @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
-
13
+ def self.call(segmenter)
14
+ article_pairs(segmenter).each_with_index.map do |(article_tag, link), position|
28
15
  Segment.build(root_node: article_tag, primary_link: link, strategy: :list, position:)
29
16
  end
30
17
  end
31
18
 
32
- def article_pairs(segmenter)
19
+ def self.article_pairs(segmenter)
33
20
  top_groups(segmenter).flatten(1)
34
21
  end
35
- module_function :article_pairs
36
22
  private_class_method :article_pairs
37
23
 
38
- def top_groups(segmenter)
24
+ def self.top_groups(segmenter)
39
25
  cards_by_parent(segmenter)
40
26
  .select { |_parent, cards| cards.size >= segmenter.minimum_selector_frequency }
41
27
  .max_by(segmenter.use_top_selectors) { |_parent, cards| cards.size }
42
28
  .map(&:last)
43
29
  end
44
- module_function :top_groups
45
30
  private_class_method :top_groups
46
31
 
47
- def cards_by_parent(segmenter) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
32
+ def self.cards_by_parent(segmenter)
48
33
  index = segmenter.index
49
34
  main = main_landmark(index)
50
35
  groups = {}.compare_by_identity
36
+ link_counts = relevant_link_counts(segmenter)
51
37
 
52
- index.each_node do |node|
53
- next unless node.link?
54
- next if index.ignored_chrome?(node)
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
60
-
61
- parent = index.parent_of(card)
62
- next unless parent
63
-
64
- (groups[parent] ||= []) << [card, node]
38
+ index.each_node.select { |node| card_anchor?(segmenter, index, main, node) }.each do |node|
39
+ card = parent_until_boundary(segmenter, node, link_counts)
40
+ (groups[index.parent_of(card)] ||= []) << [card, node]
65
41
  end
66
42
  groups
67
43
  end
68
- module_function :cards_by_parent
69
44
  private_class_method :cards_by_parent
70
45
 
71
- def main_landmark(index)
46
+ def self.card_anchor?(segmenter, index, main, node)
47
+ node.link? && !index.ignored_chrome?(node) && relevant_anchor?(segmenter, node) &&
48
+ (main.nil? || inside_main?(index, node, main))
49
+ end
50
+ private_class_method :card_anchor?
51
+
52
+ def self.main_landmark(index)
72
53
  index.each_node.find { |node| node.name == :main || node.attrs.raw['role'] == 'main' }
73
54
  end
74
- module_function :main_landmark
75
55
  private_class_method :main_landmark
76
56
 
77
- def inside_main?(index, node, main)
57
+ def self.inside_main?(index, node, main)
78
58
  node.equal?(main) || index.descendant_of?(node, main)
79
59
  end
80
- module_function :inside_main?
81
60
  private_class_method :inside_main?
82
61
 
83
- def relevant_anchor?(segmenter, node)
84
- facts = segmenter.link_resolver.destination_facts(node)
85
- return false unless facts
86
-
87
- text = node.visible_text.to_s.strip
62
+ def self.relevant_anchor?(segmenter, node)
88
63
  !segmenter.noise_policy.noise_anchor?(
89
- text:, destination_facts: facts, anchor: node, utility_landmark_ancestor: false
64
+ text: node.visible_text, destination_facts: segmenter.link_resolver.destination_facts(node),
65
+ anchor: node
90
66
  )
91
67
  end
92
- module_function :relevant_anchor?
93
68
  private_class_method :relevant_anchor?
94
69
 
95
70
  BOUNDARY_TAGS = Set[:body, :html].freeze
96
71
  private_constant :BOUNDARY_TAGS
97
72
 
98
- def parent_until_boundary(segmenter, node)
73
+ def self.parent_until_boundary(segmenter, node, link_counts)
99
74
  index = segmenter.index
100
- link_counts = Hash.new { |hash, curr| hash[curr] = count_relevant_links(segmenter, curr) }
101
75
 
102
76
  index.parent_until(node, lambda { |curr|
103
77
  return true if BOUNDARY_TAGS.include?(curr.name)
104
- return false if index.ignored_chrome?(curr)
105
78
 
106
- parent = index.parent_of(curr)
107
- parent && link_counts[parent] > link_counts[curr]
79
+ link_counts[index.parent_of(curr)] > link_counts[curr]
108
80
  })
109
81
  end
110
- module_function :parent_until_boundary
111
82
  private_class_method :parent_until_boundary
112
83
 
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
84
+ # Node-keyed counts shared across every anchor of one run, so shared ancestors are counted once.
85
+ # Keyed by identity: SST nodes are Data, whose structural +#hash+ walks the whole subtree.
86
+ def self.relevant_link_counts(segmenter)
87
+ relevant = Hash.new { |hash, node| hash[node] = relevant_anchor?(segmenter, node) }.compare_by_identity
88
+ Hash.new { |hash, node| hash[node] = count_relevant_links(node, relevant) }.compare_by_identity
89
+ end
90
+ private_class_method :relevant_link_counts
91
+
92
+ # Walk nodes are relevant anchors or their non-link ancestors, so a link counts as one.
93
+ def self.count_relevant_links(node, relevant)
94
+ return 1 if node.link?
95
+
96
+ node.count_descendants { |child| child.link? && relevant[child] }
120
97
  end
121
- module_function :count_relevant_links
122
98
  private_class_method :count_relevant_links
123
99
  end
124
100
  end
@@ -4,8 +4,9 @@ module Html2rss
4
4
  class AutoSource
5
5
  class Segmenter
6
6
  ##
7
- # Selects the strongest content-like primary link inside a container
8
- # (port of Discovery::SemanticAnchorCandidates).
7
+ # Selects the strongest content-like primary link of a container
8
+ # (port of Discovery::SemanticAnchorCandidates). A card without a qualifying link of its own
9
+ # falls back to the anchor that wraps the whole card.
9
10
  class PrimaryLink
10
11
  # @param segmenter [Segmenter]
11
12
  def initialize(segmenter)
@@ -32,11 +33,28 @@ module Html2rss
32
33
  winners[dest] = facts if current.nil? || facts[:score] > current[:score]
33
34
  end
34
35
 
35
- winners.values.max_by { |f| f[:score] }&.fetch(:anchor)
36
+ winners.values.max_by { |f| f[:score] }&.fetch(:anchor) || wrapping_link(container)
36
37
  end
37
38
 
38
39
  private
39
40
 
41
+ # The nearest ancestor link when it wraps a whole card.
42
+ def wrapping_link(container)
43
+ link = @index.parent_until(@index.parent_of(container), :link?.to_proc)
44
+ return unless link && !@index.ignored_chrome?(link) && card_link?(link)
45
+
46
+ link if candidate_facts(link, container)
47
+ end
48
+
49
+ # A card link holds the card's heading and no other link. A link holding other links is a
50
+ # parser-recovered unclosed anchor spanning a region; one without a heading wraps a fragment
51
+ # of a story (such as one live-blog entry), not a card.
52
+ def card_link?(link)
53
+ (@card_links ||= {}.compare_by_identity).fetch(link) do
54
+ @card_links[link] = link.find(&:heading?) && link.find { |node| node.link? && !node.equal?(link) }.nil?
55
+ end
56
+ end
57
+
40
58
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
41
59
  def candidate_facts(anchor, container)
42
60
  destination = @link_resolver.destination_facts(anchor)
@@ -45,8 +63,8 @@ module Html2rss
45
63
  text = anchor.visible_text.to_s.strip
46
64
  heading = first_heading(container)
47
65
  heading_text = heading ? heading.visible_text.to_s.strip : ''
48
- heading_anchor = heading_anchor?(anchor, heading)
49
66
  meaningful = text.match?(/\p{Alnum}/)
67
+ heading_anchor = heading_anchor?(anchor, heading) || (!meaningful && overlay_link?(anchor, heading))
50
68
  content_like = destination.content_path
51
69
  heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
52
70
 
@@ -73,6 +91,13 @@ module Html2rss
73
91
 
74
92
  anchor.equal?(heading) || @index.descendant_of?(anchor, heading)
75
93
  end
94
+
95
+ # An empty overlay link beside the heading (or a wrapper of it) stands in for the heading's link.
96
+ def overlay_link?(anchor, heading)
97
+ return false unless heading&.visible_text.to_s.match?(/\p{Alnum}/)
98
+
99
+ @index.descendant_of?(heading, @index.parent_of(anchor))
100
+ end
76
101
  end
77
102
  end
78
103
  end