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
@@ -14,18 +14,32 @@ module Html2rss
14
14
  # @return [Index, nil]
15
15
  def self.for_node(node) = NODE_BINDINGS[node]
16
16
 
17
+ ##
18
+ # Whether +node+ is +ancestor+ or inside it: parent pointers when +node+ is indexed, else a
19
+ # scan of +ancestor+'s subtree.
20
+ #
21
+ # @param node [Node]
22
+ # @param ancestor [Node]
23
+ # @return [Boolean]
24
+ def self.descendant_or_self?(node, ancestor)
25
+ return true if node.equal?(ancestor)
26
+
27
+ index = for_node(node)
28
+ index ? index.descendant_of?(node, ancestor) : !ancestor.find { _1.equal?(node) }.nil?
29
+ end
30
+
17
31
  ##
18
32
  # @param root [Node]
19
33
  # @param parents [Hash{Node => Node, nil}]
20
34
  # @param depths [Hash{Node => Integer}]
21
- # @param ignored_chrome [Hash{Node => Boolean}]
22
- def initialize(root:, parents:, depths:, ignored_chrome:)
35
+ # @param scopes [Hash{Node => Symbol}] {Tags.chrome_scope} of each node
36
+ def initialize(root:, parents:, depths:, scopes:)
23
37
  @root = root
24
38
  @parents = parents
25
39
  @depths = depths
26
- @ignored_chrome = ignored_chrome
40
+ @scopes = scopes
27
41
  @visible_text = {}.compare_by_identity
28
- @word_count = {}.compare_by_identity
42
+ @run_count = {}.compare_by_identity
29
43
  bind_tree(root)
30
44
  end
31
45
 
@@ -44,7 +58,14 @@ module Html2rss
44
58
  ##
45
59
  # @param node [Node]
46
60
  # @return [Boolean]
47
- def ignored_chrome?(node) = @ignored_chrome.fetch(node, false)
61
+ def ignored_chrome?(node) = @scopes[node] == :chrome
62
+
63
+ ##
64
+ # Inside a utility landmark that is not chrome (aside, in-content nav, sectioned footer).
65
+ #
66
+ # @param node [Node]
67
+ # @return [Boolean]
68
+ def peripheral?(node) = @scopes[node] == :peripheral
48
69
 
49
70
  ##
50
71
  # Memoized default visible text (separator space, no excludes).
@@ -59,13 +80,11 @@ module Html2rss
59
80
 
60
81
  ##
61
82
  # @param node [Node]
62
- # @return [Integer]
63
- def memo_word_count(node)
64
- return @word_count[node] if @word_count.key?(node)
83
+ # @return [Integer] memoized letter/digit runs of the default visible text ({WordCount.runs})
84
+ def memo_run_count(node)
85
+ return @run_count[node] if @run_count.key?(node)
65
86
 
66
- count = 0
67
- memo_visible_text(node).to_s.scan(/\p{Alnum}+/) { count += 1 }
68
- @word_count[node] = count
87
+ @run_count[node] = WordCount.runs(memo_visible_text(node))
69
88
  end
70
89
 
71
90
  ##
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Html2rss
4
4
  module SST
5
- Node = Data.define(:name, :attrs, :own_text, :children, :tag_path)
5
+ Node = Data.define(:name, :attrs, :own_text, :tails, :children, :tag_path)
6
6
 
7
7
  ##
8
8
  # Immutable simplified semantic tree node. Predicates and traversal live on
@@ -11,12 +11,13 @@ module Html2rss
11
11
  ##
12
12
  # @param name [Symbol, String]
13
13
  # @param attrs [Attrs, nil]
14
- # @param own_text [String, nil]
14
+ # @param own_text [String, nil] all direct text, in document order
15
+ # @param tails [Array<String>, nil] direct text following each child, parallel to +children+
15
16
  # @param children [Array<Node>, nil]
16
17
  # @param tag_path [String, nil] index-free ancestry path for list clustering
17
18
  # @return [Node]
18
- # @raise [ArgumentError] on invalid name/attrs/children
19
- def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil)
19
+ # @raise [InvalidError] on invalid name/attrs/children
20
+ def self.build(name:, attrs: nil, own_text: nil, children: nil, tag_path: nil, tails: nil) # rubocop:disable Metrics/ParameterLists
20
21
  attrs_value = coerce_attrs(attrs)
21
22
  kids = coerce_children(children)
22
23
 
@@ -24,13 +25,14 @@ module Html2rss
24
25
  name: coerce_name(name),
25
26
  attrs: attrs_value,
26
27
  own_text: own_text.to_s.freeze,
28
+ tails: Array(tails).map { _1.to_s.freeze }.freeze,
27
29
  children: kids,
28
30
  tag_path: tag_path.to_s.freeze
29
31
  )
30
32
  end
31
33
 
32
34
  def self.coerce_name(name)
33
- raise ArgumentError, 'name is required' if name.nil? || name.to_s.strip.empty?
35
+ raise InvalidError, 'name is required' if name.nil? || name.to_s.strip.empty?
34
36
 
35
37
  name.to_sym
36
38
  end
@@ -38,7 +40,7 @@ module Html2rss
38
40
 
39
41
  def self.coerce_attrs(attrs)
40
42
  attrs_value = attrs.nil? ? Attrs.empty : attrs
41
- raise ArgumentError, 'attrs must be SST::Attrs' unless attrs_value.is_a?(Attrs)
43
+ raise InvalidError, 'attrs must be SST::Attrs' unless attrs_value.is_a?(Attrs)
42
44
 
43
45
  attrs_value
44
46
  end
@@ -46,12 +48,16 @@ module Html2rss
46
48
 
47
49
  def self.coerce_children(children)
48
50
  kids = Array(children).freeze
49
- raise ArgumentError, 'children must be SST::Node instances' unless kids.all?(Node)
51
+ raise InvalidError, 'children must be SST::Node instances' unless kids.all?(Node)
50
52
 
51
53
  kids
52
54
  end
53
55
  private_class_method :coerce_children
54
56
 
57
+ ##
58
+ # @return [String] direct text before the first child
59
+ def lead_text = own_text.delete_suffix(tails.join)
60
+
55
61
  ##
56
62
  # @return [Boolean]
57
63
  def link?
@@ -68,11 +74,7 @@ module Html2rss
68
74
 
69
75
  ##
70
76
  # @return [Boolean]
71
- def utility_landmark? = Tags::UTILITY_LANDMARK_NAMES.include?(name)
72
-
73
- ##
74
- # @return [Boolean]
75
- def ignored_container_name? = Tags::IGNORED_CONTAINER_NAMES.include?(name)
77
+ def utility_landmark? = Tags.utility_landmark?(name.name, attrs.raw['role'])
76
78
 
77
79
  ##
78
80
  # @return [Boolean]
@@ -117,15 +119,23 @@ module Html2rss
117
119
  end
118
120
 
119
121
  ##
120
- # @return [Array<Node>]
122
+ # @yieldparam node [Node]
123
+ # @return [Array<Node>] self and descendants (depth-first) for which the block is truthy
121
124
  def find_all(&)
122
- each_node.select(&)
125
+ collect_matching([], &)
123
126
  end
124
127
 
125
128
  ##
126
- # @return [Node, nil]
127
- def find(&)
128
- each_node.find(&)
129
+ # @yieldparam node [Node]
130
+ # @return [Node, nil] first self-or-descendant (depth-first) for which the block is truthy
131
+ def find(&block)
132
+ return self if yield(self)
133
+
134
+ children.each do |child|
135
+ hit = child.find(&block)
136
+ return hit if hit
137
+ end
138
+ nil
129
139
  end
130
140
 
131
141
  ##
@@ -143,23 +153,26 @@ module Html2rss
143
153
  end
144
154
 
145
155
  ##
146
- # @return [Integer] alphanumeric word count of visible text
147
- def word_count
148
- if (index = Index.for_node(self))
149
- return index.memo_word_count(self)
150
- end
151
-
152
- count = 0
153
- visible_text.to_s.scan(/\p{Alnum}+/) { count += 1 }
154
- count
156
+ # @return [Integer] letter/digit runs of visible text ({WordCount.runs})
157
+ def run_count
158
+ index = Index.for_node(self)
159
+ index ? index.memo_run_count(self) : WordCount.runs(visible_text)
155
160
  end
156
161
 
157
162
  ##
158
- # @return [Float] words per descendant link
163
+ # @return [Float] letter/digit runs per descendant link
159
164
  def text_density
160
- words = word_count
165
+ runs = run_count
161
166
  links = count_descendants(&:link?)
162
- links.zero? ? words.to_f : words.to_f / links
167
+ links.zero? ? runs.to_f : runs.to_f / links
168
+ end
169
+
170
+ protected
171
+
172
+ def collect_matching(found, &block)
173
+ found << self if yield(self)
174
+ children.each { |child| child.collect_matching(found, &block) }
175
+ found
163
176
  end
164
177
  end
165
178
  end
@@ -8,13 +8,10 @@ module Html2rss
8
8
  # Sole Nokogiri consumer on the heuristic auto-source path. Builds an
9
9
  # immutable SST::Document with parent/depth/chrome indices.
10
10
  class Normalizer # rubocop:disable Metrics/ClassLength
11
- # Raised when no SST nodes survive normalization for the chosen root.
12
- class EmptyTree < ArgumentError; end
13
-
14
11
  # Hard ceiling for SST node allocations; beyond this we degrade.
15
12
  MAX_NODES = 5_000
16
13
 
17
- # Tags stripped entirely from the SST.
14
+ # Tags stripped entirely from the SST; a +noscript+ keeps only its file links (see {#child_nodes}).
18
15
  STRIPPED_TAGS = Set['script', 'style', 'noscript', 'iframe', 'svg', 'template'].freeze
19
16
 
20
17
  # Kept when MAX_NODES forces semantic-tag-only degrade.
@@ -32,9 +29,12 @@ module Html2rss
32
29
  href |
33
30
  src |
34
31
  srcset |
32
+ poster\z |
35
33
  style |
36
34
  datetime |
37
35
  itemprop |
36
+ rel\z |
37
+ hidden\z |
38
38
  role |
39
39
  type |
40
40
  category |
@@ -57,17 +57,14 @@ module Html2rss
57
57
  # Typed Attrs fields excluded from the leftover raw hash.
58
58
  TYPED_ATTR_NAMES = Set['href', 'src', 'id', 'class', 'datetime', 'itemprop', 'style', 'srcset', 'type'].freeze
59
59
 
60
- # String form of Tags::IGNORED_CONTAINER_NAMES for chrome checks without to_sym.
61
- IGNORED_CONTAINER_TAGS = Tags::IGNORED_CONTAINER_NAMES.to_set(&:to_s).freeze
62
-
63
60
  class << self
64
61
  ##
65
62
  # @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
66
63
  # HTML string or already-parsed node (String is parsed once here)
67
- # @return [Document]
68
- # @raise [ArgumentError] when input is nil or unsupported
64
+ # @return [Document, nil] +nil+ when no SST nodes survive normalization
65
+ # @raise [InvalidError] when input is nil or unsupported
69
66
  def call(input)
70
- raise ArgumentError, 'input is required' if input.nil?
67
+ raise InvalidError, 'input is required' if input.nil?
71
68
 
72
69
  new(coerce(input)).call
73
70
  end
@@ -81,7 +78,7 @@ module Html2rss
81
78
  when String then Nokogiri::HTML(input)
82
79
  when Nokogiri::XML::Node then input
83
80
  else
84
- raise ArgumentError, "expected String or Nokogiri::XML::Node, got #{input.class}"
81
+ raise InvalidError, "expected String or Nokogiri::XML::Node, got #{input.class}"
85
82
  end
86
83
  end
87
84
  end
@@ -93,17 +90,17 @@ module Html2rss
93
90
  @degraded = false
94
91
  @parents = {}.compare_by_identity
95
92
  @depths = {}.compare_by_identity
96
- @ignored_chrome = {}.compare_by_identity
93
+ @scopes = {}.compare_by_identity
97
94
  end
98
95
 
99
96
  ##
100
- # @return [Document]
97
+ # @return [Document, nil] +nil+ when no SST nodes survive normalization
101
98
  def call
102
99
  root_nk = resolve_root_nk
103
- root = normalize_element(root_nk, parent: nil, depth: 0, path: '', chrome: false)
104
- raise EmptyTree, 'SST Normalizer produced an empty tree' unless root
100
+ root = normalize_element(root_nk, depth: 0, path: '', scope: :page)
101
+ return unless root
105
102
 
106
- index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
103
+ index = Index.new(root:, parents: @parents, depths: @depths, scopes: @scopes)
107
104
  Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
108
105
  end
109
106
 
@@ -149,16 +146,16 @@ module Html2rss
149
146
  node.element? && !STRIPPED_TAGS.include?(Html2rss::Html::Probe.tag(node))
150
147
  end
151
148
 
152
- # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
153
- def normalize_element(nk_node, parent:, depth:, path:, chrome:)
149
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
150
+ def normalize_element(nk_node, depth:, path:, scope:)
154
151
  return unless nk_node.element?
155
152
 
156
- tag = Html2rss::Html::Probe.tag(nk_node)
157
- return if STRIPPED_TAGS.include?(tag)
153
+ tag = Html::Probe.tag(nk_node)
154
+ return unless (nk_children = child_nodes(nk_node, tag))
158
155
 
159
156
  if @node_count >= MAX_NODES && !@degraded
160
157
  @degraded = true
161
- Html2rss::Log.warn({
158
+ Log.warn({
162
159
  event_name: 'sst.normalizer.max_nodes',
163
160
  max_nodes: MAX_NODES,
164
161
  message: "sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only"
@@ -168,26 +165,32 @@ module Html2rss
168
165
  return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
169
166
 
170
167
  @node_count += 1
171
- tag_path = path.empty? ? "/#{tag}" : "#{path}/#{tag}"
168
+ tag_path = "#{path}/#{tag}"
172
169
  attrs = extract_attrs(nk_node)
173
- own_text = direct_text(nk_node)
174
- chrome_here = chrome || IGNORED_CONTAINER_TAGS.include?(tag)
170
+ scope_here = Tags.chrome_scope(nk_node, scope)
175
171
 
176
- children = nk_node.element_children.filter_map do |child|
177
- normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
178
- end.freeze
172
+ children, lead, tails = normalize_children(nk_children, depth:, path: tag_path, scope: scope_here)
179
173
 
180
- node = Node.build(name: tag, attrs:, own_text:, children:, tag_path:)
181
- @parents[node] = parent == :pending ? nil : parent
174
+ node = Node.build(name: tag, attrs:, own_text: [lead, *tails].join, children:, tag_path:, tails:)
182
175
  @depths[node] = depth
183
- @ignored_chrome[node] = chrome_here
184
-
185
- # Fix parent pointers now that node exists.
176
+ @scopes[node] = scope_here
186
177
  children.each { |child| @parents[child] = node }
187
178
 
188
179
  node
189
180
  end
190
181
 
182
+ # The Nokogiri children to normalize, or nil for a stripped tag. A +noscript+ is kept only for the
183
+ # file links inside it ({Html::ArticleRules::Enclosure.file_link?}), as its only children, so a
184
+ # card's no-script download link still names its enclosure; its text stays invisible
185
+ # ({Tags::INVISIBLE_NAMES}) and a +noscript+ without one is stripped.
186
+ def child_nodes(nk_node, tag)
187
+ return nk_node.children unless STRIPPED_TAGS.include?(tag)
188
+ return unless tag == 'noscript'
189
+
190
+ links = nk_node.xpath('.//a[@href]').select { Html::ArticleRules::Enclosure.file_link?(_1['href']) }
191
+ links unless links.empty?
192
+ end
193
+
191
194
  def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
192
195
  Attrs.build(
193
196
  href: nk_node['href'],
@@ -213,8 +216,18 @@ module Html2rss
213
216
  end
214
217
  end
215
218
 
216
- def direct_text(nk_node)
217
- nk_node.children.select(&:text?).map(&:text).join
219
+ # Walks child nodes once, keeping direct text in the slot of the preceding kept child
220
+ # (slot 0 is the lead text) so text order survives the element-only tree.
221
+ def normalize_children(nk_children, depth:, path:, scope:)
222
+ children = []
223
+ slots = [+'']
224
+ nk_children.each do |child|
225
+ next slots.last << child.text if child.text?
226
+
227
+ node = normalize_element(child, depth: depth + 1, path:, scope:)
228
+ (children << node) && (slots << +'') if node
229
+ end
230
+ [children, slots.first, slots.drop(1)]
218
231
  end
219
232
  end
220
233
  end
@@ -11,16 +11,127 @@ module Html2rss
11
11
  BLOCK_NAMES = Set[:p, :div, :li, :ul, :ol, :h1, :h2, :h3, :h4, :h5, :h6, :tr, :br].freeze
12
12
  # Names skipped when collecting visible text.
13
13
  INVISIBLE_NAMES = Set[:svg, :script, :noscript, :style, :template].freeze
14
- # Landmark names treated as utility chrome.
14
+ # Utility landmark names: peripheral unless chrome (see {.chrome_scope}).
15
15
  UTILITY_LANDMARK_NAMES = Set[:nav, :aside, :footer, :menu].freeze
16
16
  # Container names ignored as article roots.
17
- IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :svg, :script, :style].freeze
17
+ IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :dialog, :svg, :script, :style].freeze
18
+ # Ignored containers that are chrome only at page level (ARIA banner/contentinfo scoping).
19
+ PAGE_CHROME_NAMES = Set[:header, :footer].freeze
20
+ # Ancestors that scope a header/footer to their own section instead of the page.
21
+ SECTIONING_NAMES = Set[:article, :section, :aside, :main].freeze
18
22
  # Names excluded from class/structure clustering.
19
23
  CLUSTER_EXCLUDED_NAMES = Set[:html, :body, :nav, :footer, :header, :svg, :script, :style].freeze
20
24
  # Href prefixes that never count as content destinations.
21
25
  SKIP_HREF_PREFIXES = ['#', 'javascript:', 'mailto:', 'tel:', 'file://', 'sms:', 'data:'].freeze
22
26
  # Layout-ish names used when resolving nested wrapper groups.
23
27
  LAYOUT_NAMES = Set[:div, :section, :article, :li, :ul, :ol].freeze
28
+
29
+ # header/footer/nav: chrome at page level, their parent's scope inside sectioning content.
30
+ PAGE_CHROME = PAGE_CHROME_NAMES.to_set(&:to_s).freeze
31
+ # +noscript+ too: the SST keeps one only for its file links (enclosures), never as content anchors.
32
+ ALWAYS_CHROME = ((IGNORED_CONTAINER_NAMES.to_set(&:to_s) - PAGE_CHROME - Set['nav']) | Set['noscript']).freeze
33
+ UTILITY_LANDMARK = UTILITY_LANDMARK_NAMES.to_set(&:to_s).freeze
34
+ SECTIONING = SECTIONING_NAMES.to_set(&:to_s).freeze
35
+ # ARIA roles whose subtree is chrome anywhere (+search+ is not: it may wrap the result list).
36
+ CHROME_ROLES = Set['navigation', 'menu', 'menubar', 'dialog', 'alertdialog'].freeze
37
+ # ARIA landmark roles that, like header/footer, are chrome only at page level.
38
+ PAGE_CHROME_ROLES = Set['banner', 'contentinfo'].freeze
39
+ # Utility landmark roles: the chrome roles plus +complementary+ (the aside role).
40
+ UTILITY_ROLES = (CHROME_ROLES | Set['complementary']).freeze
41
+ # A nav or list labelled (aria-label) or classed as pagination is chrome in any scope.
42
+ PAGER_NAMES = Set['nav', 'ul', 'ol'].freeze
43
+ # Pagination nouns ("Results pagination", "paginación"), never +paginated+.
44
+ PAGER_LABEL = /\bpagina(?:tion|tie|ción|cion|zione|cja|ção|cao)(?!\p{L})|\bpaginering\b|\bpaginierung\b|
45
+ \bpager\b|\bpaging\b|\bpage\snavigation\b/ix
46
+ # nav: a class-token part (+pagination+, +gsc-pagination+, +pager__list+), not +paginated-list+.
47
+ PAGER_CLASS = /(?:\A|\s|[-_])(?:pagination|pager|paging)(?:\z|\s|[-_])/i
48
+ # ul/ol: only a whole class token (+infinite-pager+ or +js-pagination-container+ is a feed list).
49
+ PAGER_TOKENS = Set['pagination', 'pager', 'paging'].freeze
50
+ PAGER_RELS = Set['next', 'prev', 'previous'].freeze
51
+ # A nav linking rel=next/prev is a pager.
52
+ PAGER_LINK_XPATH = PAGER_RELS.map { "contains(concat(' ', normalize-space(@rel), ' '), ' #{_1} ')" }
53
+ .join(' or ').then { ".//a[#{_1}]" }.freeze
54
+ # Page-navigation link shapes: a page number, an arrow, prev/next wording, or a page query/path.
55
+ PAGE_LINK_TEXT = /\A(?:\d{1,4}|[«‹›»←→<>]+|\W*(?:prev(?:ious)?|next|older|newer|first|last)\b.*)\z/im
56
+ PAGE_LINK_HREF = %r{[?&](?:page|paged|pg|p)=\d+|/page/\d+}i
57
+ private_constant :PAGE_CHROME, :ALWAYS_CHROME, :UTILITY_LANDMARK, :SECTIONING, :CHROME_ROLES,
58
+ :PAGE_CHROME_ROLES, :UTILITY_ROLES, :PAGER_NAMES, :PAGER_LABEL, :PAGER_CLASS,
59
+ :PAGER_TOKENS, :PAGER_RELS, :PAGER_LINK_XPATH, :PAGE_LINK_TEXT, :PAGE_LINK_HREF
60
+
61
+ ##
62
+ # The single chrome-scope rule, folded down the tree by both the SST normalizer
63
+ # and the Nokogiri ancestor walk. +:chrome+ sticks: dialog, noscript and the ARIA navigation,
64
+ # menu and dialog roles are chrome; header/footer/nav and the banner/contentinfo roles
65
+ # are chrome only at +:page+ level; a pager (a nav labelled or classed as pagination or
66
+ # linking rel=next/prev, or a list labelled or whole-token classed as pagination whose
67
+ # links mostly look like page navigation) is chrome in any scope. Otherwise a
68
+ # utility landmark ({.utility_landmark?}) opens a sticky +:peripheral+ scope, and
69
+ # article/section/main (or role=main) open +:sectioned+, so card headers and in-content
70
+ # nav lists stay out of chrome.
71
+ #
72
+ # @param element [Nokogiri::XML::Element] element being entered
73
+ # @param scope [Symbol] parent scope: +:page+, +:sectioned+, +:peripheral+, or +:chrome+
74
+ # @return [Symbol] scope the element's subtree inherits
75
+ def self.chrome_scope(element, scope)
76
+ tag = Html::Probe.tag(element)
77
+ roles = role_tokens(element['role'])
78
+ return :chrome if scope == :chrome || chrome?(element, tag, roles, scope)
79
+ return :peripheral if scope == :peripheral || utility?(tag, roles)
80
+
81
+ SECTIONING.include?(tag) || roles.include?('main') ? :sectioned : scope
82
+ end
83
+
84
+ ##
85
+ # Utility landmark: nav/aside/footer/menu, or a navigation, menu, dialog or
86
+ # complementary role. Peripheral content when not chrome; never an article card.
87
+ #
88
+ # @param tag [String] folded tag name
89
+ # @param role [String, nil] raw +role+ attribute
90
+ # @return [Boolean]
91
+ def self.utility_landmark?(tag, role) = utility?(tag, role_tokens(role))
92
+
93
+ ##
94
+ # Chrome only at page level: header/footer, or the banner/contentinfo role.
95
+ #
96
+ # @param tag [String] folded tag name
97
+ # @param role [String, nil] raw +role+ attribute
98
+ # @return [Boolean]
99
+ def self.page_level_chrome?(tag, role) = page_level?(tag, role_tokens(role))
100
+
101
+ def self.role_tokens(role) = role ? role.downcase.split : []
102
+
103
+ def self.utility?(tag, roles) = UTILITY_LANDMARK.include?(tag) || roles.any? { UTILITY_ROLES.include?(_1) }
104
+
105
+ def self.page_level?(tag, roles) = PAGE_CHROME.include?(tag) || roles.any? { PAGE_CHROME_ROLES.include?(_1) }
106
+
107
+ def self.chrome?(element, tag, roles, scope)
108
+ return true if ALWAYS_CHROME.include?(tag) || roles.any? { CHROME_ROLES.include?(_1) }
109
+
110
+ (scope == :page && (tag == 'nav' || page_level?(tag, roles))) || pager?(element, tag)
111
+ end
112
+
113
+ def self.pager?(element, tag)
114
+ return false unless PAGER_NAMES.include?(tag)
115
+ return list_pager?(element) unless tag == 'nav'
116
+ return true if element['aria-label'].to_s.match?(PAGER_LABEL) || element['class'].to_s.match?(PAGER_CLASS)
117
+
118
+ !element.at_xpath(PAGER_LINK_XPATH).nil?
119
+ end
120
+
121
+ # A list is a pager only when labelled or whole-token classed as one and at least
122
+ # half of its links look like page navigation.
123
+ def self.list_pager?(element)
124
+ named = element['aria-label'].to_s.match?(PAGER_LABEL) ||
125
+ element['class'].to_s.downcase.split.any? { PAGER_TOKENS.include?(_1) }
126
+ links = named ? element.css('a') : []
127
+ !links.empty? && links.count { page_link?(_1) } * 2 >= links.size
128
+ end
129
+
130
+ def self.page_link?(link)
131
+ link['rel'].to_s.downcase.split.any? { PAGER_RELS.include?(_1) } ||
132
+ link['href'].to_s.match?(PAGE_LINK_HREF) || link.text.strip.match?(PAGE_LINK_TEXT)
133
+ end
134
+ private_class_method :role_tokens, :utility?, :page_level?, :chrome?, :pager?, :list_pager?, :page_link?
24
135
  end
25
136
  end
26
137
  end