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
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class TitlePieces
6
+ ##
7
+ # The kicker of one card, first match: the outermost kicker-marked element beside the headline
8
+ # ({KICKER_WORDS}, {KICKER_ATTRIBUTE}) holding no word besides its noise and kicker-marked parts; else
9
+ # a label leaf ({LABEL_WORDS}) opening the heading or anchor text; else the piece right before the
10
+ # headline piece. A paid-content label is never a kicker.
11
+ class Kicker
12
+ # Media element names of both node kinds: a piece holding one is a picture, not a kicker.
13
+ MEDIA_NAMES = Set['img', 'picture', 'figure', 'video', 'svg', :img, :picture, :figure, :video, :svg].freeze
14
+ # Paragraphs of both node kinds: running prose, never a kicker.
15
+ PARAGRAPH_NAMES = Set['p', :p].freeze
16
+ # Class tokens that may hold a {KICKER_WORDS} word: each word with any +-+ or +_+ between its letters,
17
+ # in any case. A cheap gate before the class words are split.
18
+ KICKER_TOKEN = Regexp.new(KICKER_WORDS.map { _1.chars.join('[-_]*') }.join('|'), Regexp::IGNORECASE)
19
+ private_constant :MEDIA_NAMES, :PARAGRAPH_NAMES, :KICKER_TOKEN
20
+
21
+ ##
22
+ # @param nodes [Nodes] reader of the card's nodes
23
+ # @param root [Nokogiri::XML::Node, SST::Node] card root
24
+ # @param source [Nokogiri::XML::Node, SST::Node, nil] headline source: the heading, else the anchor
25
+ # @yieldreturn [Nokogiri::XML::Node, SST::Node, nil] the source's headline piece, read only when
26
+ # no marked or label kicker is found
27
+ def initialize(nodes, root, source, &)
28
+ @nodes = nodes
29
+ @root = root
30
+ @source = source
31
+ @element = marked || (label || preceding(yield) if source)
32
+ end
33
+
34
+ ##
35
+ # @return [Nokogiri::XML::Node, SST::Node, nil] the kicker element
36
+ attr_reader :element
37
+
38
+ ##
39
+ # @return [String, nil] kicker text without its noise pieces ({Article::Title.noise_piece?})
40
+ def text = element && text_without_noise(element)
41
+
42
+ private
43
+
44
+ # A kicker inside the heading or anchor wins over one beside it (a topline in the heading over a
45
+ # type badge above it).
46
+ def marked
47
+ candidates = @nodes.carrying(@root, KICKER_ATTRIBUTE).select { marked?(_1) && worded?(_1) && beside?(_1) }
48
+ kickers = candidates.select { |node| parts_only?(node, candidates) }
49
+ kickers.find { @nodes.inside?(_1, @source) } || kickers.first
50
+ end
51
+
52
+ def marked?(node)
53
+ @nodes.attribute(node, KICKER_ATTRIBUTE) ||
54
+ (@nodes.class_names(node).any?(KICKER_TOKEN) && @nodes.class_words(node).intersect?(KICKER_WORDS))
55
+ end
56
+
57
+ # A label leaf opening the source text, with headline text after it.
58
+ def label
59
+ unseen = @nodes.unseen(@source)
60
+ rest = @nodes.text(@source, unseen).to_s
61
+ @nodes.elements(@source).find do |element|
62
+ label_leaf?(element) && rest.start_with?(@nodes.text(element).to_s) &&
63
+ @nodes.text(@source, unseen + [element]).to_s.match?(WORD)
64
+ end
65
+ end
66
+
67
+ def label_leaf?(element) = @nodes.class_words(element).intersect?(LABEL_WORDS) && !@nodes.paid_label?(element)
68
+
69
+ # The nearest worded piece before the headline piece, when it is a kicker-shaped leaf.
70
+ def preceding(headline_piece)
71
+ candidate = piece_before(headline_piece)
72
+ return unless candidate && shaped?(candidate)
73
+
74
+ leaf = nearest_leaf(candidate)
75
+ leaf if shaped?(leaf)
76
+ end
77
+
78
+ # Walks from +piece+ up to the source, looking back over element siblings; direct text stops it.
79
+ def piece_before(piece)
80
+ current = piece
81
+ until current.nil? || current.equal?(@source)
82
+ before = @nodes.siblings_before(current)
83
+ return if before.nil?
84
+
85
+ worded = before.reverse.find { !skipped?(_1) }
86
+ return worded if worded
87
+
88
+ current = @nodes.parent(current)
89
+ end
90
+ end
91
+
92
+ # Descends into the last worded child of a piece made only of elements: the leaf next to the
93
+ # headline (a topic span after a badge in one box).
94
+ def nearest_leaf(node)
95
+ until @nodes.own_words?(node)
96
+ child = node.children.reverse.find { !skipped?(_1) }
97
+ return node unless child
98
+
99
+ node = child
100
+ end
101
+ node
102
+ end
103
+
104
+ def skipped?(node)
105
+ @nodes.concealed?(node) || @nodes.date_element?(node) || !text_without_noise(node).to_s.match?(WORD)
106
+ end
107
+
108
+ def shaped?(node)
109
+ !PARAGRAPH_NAMES.include?(node.name) && !@nodes.class_words(node).intersect?(LEAD_WORDS) &&
110
+ @nodes.elements(node).none? { MEDIA_NAMES.include?(_1.name) }
111
+ end
112
+
113
+ def worded?(node)
114
+ !@nodes.date_element?(node) && !@nodes.noise?(node) && !@nodes.concealed?(node) &&
115
+ text_without_noise(node).to_s.match?(WORD)
116
+ end
117
+
118
+ def text_without_noise(node) = @nodes.text(node, noise(node))
119
+
120
+ # Kicker pieces that are noise on their own: date, time and concealed elements, paid-content labels
121
+ # and {Article::Title.noise_piece?}.
122
+ def noise(node)
123
+ @nodes.elements(node).select do |piece|
124
+ @nodes.date_element?(piece) || @nodes.concealed?(piece) || @nodes.paid_label?(piece) ||
125
+ Article::Title.noise_piece?(@nodes.text(piece))
126
+ end
127
+ end
128
+
129
+ def beside?(node)
130
+ return true unless @source
131
+ return false if @nodes.inside?(@source, node)
132
+
133
+ !@nodes.inside?(node, @source) || @nodes.text(@source, [node]).to_s.match?(WORD)
134
+ end
135
+
136
+ # Whether +node+ holds no word outside its noise and its nested kicker candidates.
137
+ def parts_only?(node, candidates)
138
+ parts = candidates.select { |other| !other.equal?(node) && @nodes.inside?(other, node) }
139
+ return true if parts.empty?
140
+
141
+ !@nodes.text(node, noise(node) + parts).to_s.match?(WORD)
142
+ end
143
+ end
144
+ private_constant :Kicker
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class TitlePieces
6
+ ##
7
+ # Reads the nodes of one card for {TitlePieces}, SST and Nokogiri alike: element descendants, class
8
+ # words, visible text and the element facts both title rules read (concealed, date and noise
9
+ # elements), each read once per node. {SstNodes} and {NokogiriNodes} supply the reads that differ by
10
+ # node kind; the headline rule stays on {TitlePieces}, the kicker rule on {Kicker}.
11
+ class Nodes
12
+ # Text that holds a word.
13
+ WORD = /[[:alnum:]]/
14
+ # Splits a class token into words.
15
+ CLASS_WORD_BREAK = /[-_]+|(?<=[[:lower:][:digit:]])(?=[[:upper:]])/
16
+ # +time+ element names of both node kinds.
17
+ TIME_NAMES = Set['time', :time].freeze
18
+ private_constant :CLASS_WORD_BREAK, :TIME_NAMES
19
+
20
+ ##
21
+ # @param root [Nokogiri::XML::Node, SST::Node] card root
22
+ # @return [Nodes] the reader for the node kind of +root+
23
+ def self.for(root) = root.instance_of?(SST::Node) ? SstNodes.new : NokogiriNodes.new
24
+
25
+ def initialize
26
+ @words = {}.compare_by_identity
27
+ @elements = {}.compare_by_identity
28
+ @texts = {}.compare_by_identity
29
+ end
30
+
31
+ ##
32
+ # @param node [Nokogiri::XML::Node, SST::Node]
33
+ # @return [Array] element descendants of +node+, read once per node
34
+ def elements(node) = @elements[node] ||= descendants(node)
35
+
36
+ ##
37
+ # Lowercase words of all class tokens, plus each pair of neighbours joined (+region+ +Name+ gives
38
+ # +regionname+, +sub+ +title+ gives +subtitle+).
39
+ #
40
+ # @param node [Nokogiri::XML::Node, SST::Node]
41
+ # @return [Set<String>]
42
+ def class_words(node)
43
+ @words[node] ||= class_names(node).each_with_object(Set.new) do |token, words|
44
+ parts = token.split(CLASS_WORD_BREAK).map(&:downcase)
45
+ words.merge(parts).merge(parts.each_cons(2).map(&:join))
46
+ end
47
+ end
48
+
49
+ ##
50
+ # @param node [Nokogiri::XML::Node, SST::Node]
51
+ # @return [Boolean] whether a sighted reader does not see +node+ ({Visibility.concealed?})
52
+ def concealed?(node)
53
+ Visibility.concealed?(class_names(node), style: style(node), hidden: !attribute(node, 'hidden').nil?)
54
+ end
55
+
56
+ ##
57
+ # @param node [Nokogiri::XML::Node, SST::Node]
58
+ # @param exclude [Array, nil] elements left out
59
+ # @return [String, nil] visible text of +node+; text read without exclusions is read once per node
60
+ def text(node, exclude = nil)
61
+ return visible_text(node, exclude) if exclude&.any?
62
+
63
+ @texts[node] ||= visible_text(node, nil)
64
+ end
65
+
66
+ ##
67
+ # A date chip set apart from the headline words: a date element ({#date_element?}) with no direct text
68
+ # holding a word right before or right after it (+resume on <time>Friday</time>+ is running text).
69
+ #
70
+ # @param node [Nokogiri::XML::Node, SST::Node]
71
+ # @return [Boolean]
72
+ def dropped_date?(node)
73
+ return false unless date_element?(node)
74
+
75
+ before, after = texts_beside(node)
76
+ !before.to_s.match?(WORD) && !after.to_s.match?(WORD)
77
+ end
78
+
79
+ ##
80
+ # @param node [Nokogiri::XML::Node, SST::Node]
81
+ # @return [Boolean] whether +node+ is a +time+ element or its class marks a date, time or duration
82
+ # ({DATE_WORDS})
83
+ def date_element?(node) = TIME_NAMES.include?(node.name) || class_words(node).intersect?(DATE_WORDS)
84
+
85
+ ##
86
+ # @param node [Nokogiri::XML::Node, SST::Node]
87
+ # @return [Array] elements of +node+ a reader does not see as its text: concealed and dropped date ones
88
+ def unseen(node) = elements(node).select { concealed?(_1) || dropped_date?(_1) }
89
+
90
+ ##
91
+ # @param node [Nokogiri::XML::Node, SST::Node]
92
+ # @return [Boolean] whether the text of +node+ is a paid-content label
93
+ # ({LinkDestination::Sponsorship.paid_text?})
94
+ def paid_label?(node) = LinkDestination::Sponsorship.paid_text?(text(node))
95
+
96
+ ##
97
+ # @param node [Nokogiri::XML::Node, SST::Node]
98
+ # @return [Boolean] whether the text of +node+ is headline noise ({Article::Title.headline_noise_piece?})
99
+ # or a paid-content label
100
+ def noise?(node)
101
+ Article::Title.headline_noise_piece?(text(node)) || LinkDestination::Sponsorship.paid_text?(text(node))
102
+ end
103
+ end
104
+ private_constant :Nodes
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class TitlePieces
6
+ ##
7
+ # {Nodes} reads of Nokogiri nodes: direct text is the text node siblings of an element.
8
+ class NokogiriNodes < Nodes
9
+ ##
10
+ # @param root [Nokogiri::XML::Node]
11
+ # @param attribute [String]
12
+ # @return [Array<Nokogiri::XML::Element>] +root+ and its descendants carrying a class or +attribute+
13
+ def carrying(root, attribute) = root.xpath("descendant-or-self::*[@class or @#{attribute}]")
14
+
15
+ ##
16
+ # @param node [Nokogiri::XML::Node]
17
+ # @return [Array<String>] class tokens
18
+ def class_names(node) = node['class'].to_s.split
19
+
20
+ ##
21
+ # @param node [Nokogiri::XML::Node]
22
+ # @param name [String]
23
+ # @return [String, nil] the attribute value
24
+ def attribute(node, name) = node[name]
25
+
26
+ ##
27
+ # @param node [Nokogiri::XML::Node]
28
+ # @return [Nokogiri::XML::Node, nil]
29
+ def parent(node) = node.parent
30
+
31
+ ##
32
+ # @param node [Nokogiri::XML::Node]
33
+ # @param ancestor [Nokogiri::XML::Node]
34
+ # @return [Boolean] whether +node+ is +ancestor+ or inside it
35
+ def inside?(node, ancestor) = node == ancestor || Navigator.descendant_of?(node, ancestor)
36
+
37
+ ##
38
+ # @param node [Nokogiri::XML::Node]
39
+ # @return [Boolean] whether +node+'s direct text (outside its child elements) holds a word
40
+ def own_words?(node) = node.children.any? { _1.text? && _1.text.match?(WORD) }
41
+
42
+ ##
43
+ # @param node [Nokogiri::XML::Node]
44
+ # @return [Array<Nokogiri::XML::Node>, nil] siblings before +node+, or nil when direct text holding a
45
+ # word sits between them and +node+
46
+ def siblings_before(node)
47
+ before = []
48
+ sibling = node.previous_sibling
49
+ while sibling
50
+ return if sibling.text? && sibling.text.match?(WORD)
51
+
52
+ before.unshift(sibling)
53
+ sibling = sibling.previous_sibling
54
+ end
55
+ before
56
+ end
57
+
58
+ private
59
+
60
+ def descendants(node) = node.css('*')
61
+ def style(node) = node['style']
62
+ def visible_text(node, exclude) = Navigator.extract_visible_text(node, exclude_nodes: exclude)
63
+
64
+ # The direct text right before and right after +node+.
65
+ def texts_beside(node) = [node.previous_sibling, node.next_sibling].map { _1.text if _1&.text? }
66
+ end
67
+ private_constant :NokogiriNodes
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ class TitlePieces
6
+ ##
7
+ # {Nodes} reads of SST nodes: direct text is the node's lead text and the tail after each child.
8
+ class SstNodes < Nodes
9
+ ##
10
+ # @param root [SST::Node]
11
+ # @param attribute [String]
12
+ # @return [Array<SST::Node>] +root+ and its descendants carrying a class or +attribute+
13
+ def carrying(root, attribute) = root.find_all { _1.attrs.class_names.any? || _1.attrs.raw.key?(attribute) }
14
+
15
+ ##
16
+ # @param node [SST::Node]
17
+ # @return [Array<String>] class tokens
18
+ def class_names(node) = node.attrs.class_names
19
+
20
+ ##
21
+ # @param node [SST::Node]
22
+ # @param name [String]
23
+ # @return [String, nil] the attribute value
24
+ def attribute(node, name) = node.attrs.raw[name]
25
+
26
+ ##
27
+ # @param node [SST::Node]
28
+ # @return [SST::Node, nil] the parent of an indexed node
29
+ def parent(node) = SST::Index.for_node(node)&.parent_of(node)
30
+
31
+ ##
32
+ # @param node [SST::Node]
33
+ # @param ancestor [SST::Node]
34
+ # @return [Boolean] whether +node+ is +ancestor+ or inside it
35
+ def inside?(node, ancestor) = SST::Index.descendant_or_self?(node, ancestor)
36
+
37
+ ##
38
+ # @param node [SST::Node]
39
+ # @return [Boolean] whether +node+'s direct text (outside its child elements) holds a word
40
+ def own_words?(node) = node.own_text.match?(WORD)
41
+
42
+ ##
43
+ # @param node [SST::Node]
44
+ # @return [Array<SST::Node>, nil] siblings before +node+, or nil when direct text holding a word
45
+ # sits between them and +node+
46
+ def siblings_before(node)
47
+ up = parent(node)
48
+ return unless up
49
+
50
+ index = up.children.index { _1.equal?(node) }
51
+ up.children.take(index) unless [up.lead_text, *up.tails][..index].any? { _1.match?(WORD) }
52
+ end
53
+
54
+ private
55
+
56
+ def descendants(node) = node.descendants
57
+ def style(node) = node.attrs.style
58
+ def visible_text(node, exclude) = node.visible_text(exclude:)
59
+
60
+ # The direct text right before and right after +node+.
61
+ def texts_beside(node)
62
+ up = parent(node)
63
+ return [] unless up
64
+
65
+ [up.lead_text, *up.tails][up.children.index { _1.equal?(node) }, 2]
66
+ end
67
+ end
68
+ private_constant :SstNodes
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # Picks the kicker and the headline text of one teaser card: the one rule shared by
7
+ # {ArticleExtractor} (Nokogiri) and {SstArticleExtractor} (SST), so both give one title for the same
8
+ # markup. The noise vocabulary and composition stay on {Article::Title}; rules: CONTEXT.md § Article title.
9
+ #
10
+ # Everything here reads elements, never headline wording:
11
+ #
12
+ # - Headline: a +data-title+ attribute on the heading or anchor wins. A heading or anchor made only of
13
+ # element pieces (no word in its own direct text) is read from its headline piece: its one outermost
14
+ # headline-marked descendant ({HEADLINE_WORDS}; never a sub-title or a label-marked one, {LABEL_WORDS}),
15
+ # else the selected anchor when it is the only link inside the heading. Pieces beside it (tags, dates,
16
+ # lead paragraphs) are no part of the title.
17
+ # - Headline text leaves out the kicker, concealed elements ({Visibility.concealed?}; they stay when they
18
+ # hold the only words, the accessible name of an otherwise empty link), date and time
19
+ # elements ({Nodes#dropped_date?}) unless direct text holding a word touches them, lead pieces
20
+ # ({LEAD_WORDS}), tag pieces closing the text ({TAG_WORDS}), clock-only or label-only elements before
21
+ # its first word, and date chips closing it ({#date_chip?}).
22
+ # - A paid-content label ({LinkDestination::Sponsorship.paid_text?}) is item metadata, never title text:
23
+ # it is no kicker and leaves the headline text where it opens it, so a kept sponsored item (cleanup
24
+ # +keep_sponsored+) is titled by its headline alone. An ad word ("Anzeige", "Werbung") also names a
25
+ # topic and stays a kicker.
26
+ # - Kicker: {Kicker}.
27
+ class TitlePieces
28
+ # Class words (tokens split on +-+, +_+ and camelCase, neighbours joined) marking kicker / eyebrow
29
+ # text anywhere in the card.
30
+ KICKER_WORDS = Set['kicker', 'eyebrow', 'pretitle', 'overline', 'topline'].freeze
31
+ # Attribute marking kicker text.
32
+ KICKER_ATTRIBUTE = 'data-tb-kicker'
33
+ # Attribute holding the publisher's clean title on the heading or anchor.
34
+ TITLE_ATTRIBUTE = 'data-title'
35
+ # Class words (tokens split on +-+, +_+ and camelCase) marking the headline piece. +subTitle+ and
36
+ # +sub-title+ are not one.
37
+ HEADLINE_WORDS = Set['title', 'headline', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].freeze
38
+ # Class words of a label leaf that is a kicker when it opens the heading or anchor text.
39
+ LABEL_WORDS = (KICKER_WORDS | Set['prefix', 'label', 'alert', 'alerte', 'live', 'regionname']).freeze
40
+ # Class words of date, time and duration elements.
41
+ DATE_WORDS = Set['date', 'time', 'duration', 'timestamp', 'datetime'].freeze
42
+ # Class words of a teaser lead or summary, never part of the title.
43
+ LEAD_WORDS = Set['lead', 'leadin', 'summary', 'excerpt', 'standfirst', 'dek', 'abstract'].freeze
44
+ # Class words of tag or subtitle pieces, dropped when they close the headline text.
45
+ TAG_WORDS = Set['tag', 'tags', 'subtitle'].freeze
46
+
47
+ # Text that holds a word.
48
+ WORD = Nodes::WORD
49
+ # Link element names of both node kinds.
50
+ LINK_NAMES = Set['a', :a].freeze
51
+ private_constant :WORD, :LINK_NAMES
52
+
53
+ ##
54
+ # @param root [Nokogiri::XML::Node, SST::Node] card root
55
+ # @param source [Nokogiri::XML::Node, SST::Node, nil] headline source: the heading, else the anchor
56
+ # @param anchor [Nokogiri::XML::Node, SST::Node, nil] the card's selected anchor
57
+ def initialize(root, source, anchor: nil)
58
+ @anchor = anchor
59
+ @nodes = Nodes.for(root)
60
+ @kicker = Kicker.new(@nodes, root, source) { headline_piece(source) }
61
+ end
62
+
63
+ ##
64
+ # @return [Nokogiri::XML::Node, SST::Node, nil] the kicker element ({Kicker})
65
+ def kicker = @kicker.element
66
+
67
+ ##
68
+ # @return [String, nil] kicker text without its noise pieces ({Article::Title.noise_piece?})
69
+ def kicker_text = @kicker.text
70
+
71
+ ##
72
+ # @param node [Nokogiri::XML::Node, SST::Node, nil] heading or anchor
73
+ # @return [String, nil] its headline text (see the class notes)
74
+ def headline_text(node)
75
+ return unless node
76
+ return @nodes.attribute(node, TITLE_ATTRIBUTE) if @nodes.attribute(node, TITLE_ATTRIBUTE).to_s.match?(WORD)
77
+
78
+ node = headline_piece(node) || node
79
+ @nodes.text(node, headline_drops(node))
80
+ end
81
+
82
+ private
83
+
84
+ # --- Headline ------------------------------------------------------------------------------------
85
+
86
+ # The piece of +node+ holding the headline when +node+ is made only of element pieces.
87
+ def headline_piece(node)
88
+ return if @nodes.own_words?(node)
89
+
90
+ marked = @nodes.elements(node).select { headline_marked?(_1) }
91
+ outermost = marked.reject { |el| marked.any? { |other| !other.equal?(el) && @nodes.inside?(el, other) } }
92
+ return outermost.first if outermost.one?
93
+
94
+ @anchor if sole_anchor_inside?(node)
95
+ end
96
+
97
+ # Whether the selected anchor is the only link inside +node+ (a headline split over several links
98
+ # stays whole).
99
+ def sole_anchor_inside?(node)
100
+ links = @nodes.elements(node).select { LINK_NAMES.include?(_1.name) }
101
+ links.one? && links.first.equal?(@anchor)
102
+ end
103
+
104
+ def headline_marked?(node)
105
+ words = @nodes.class_words(node)
106
+ words.intersect?(HEADLINE_WORDS) && !words.include?('subtitle') && !words.intersect?(LABEL_WORDS)
107
+ end
108
+
109
+ # Elements left out of the headline text of +node+, in rule order.
110
+ def headline_drops(node)
111
+ dropped = drop_concealed!(node, [kicker, *@nodes.elements(node).select { @nodes.dropped_date?(_1) }].compact)
112
+ drop_pieces!(node, dropped, LEAD_WORDS) { |rest, _| rest.match?(WORD) }
113
+ drop_pieces!(node, dropped, TAG_WORDS) { |rest, piece| rest.match?(WORD) && closing?(node, dropped, piece) }
114
+ drop_opening_noise!(node, dropped)
115
+ drop_closing_dates!(node, dropped)
116
+ end
117
+
118
+ # Concealed elements leave unless they hold the only words (the name of an otherwise empty link).
119
+ def drop_concealed!(node, dropped)
120
+ concealed = @nodes.elements(node).select { @nodes.concealed?(_1) }
121
+ @nodes.text(node, dropped + concealed).to_s.match?(WORD) ? dropped.concat(concealed) : dropped
122
+ end
123
+
124
+ # Drops each +words+-marked piece of +node+ the block admits, given the text left without it.
125
+ def drop_pieces!(node, dropped, words)
126
+ @nodes.elements(node).each do |element|
127
+ next unless @nodes.class_words(element).intersect?(words)
128
+
129
+ dropped << element if yield(@nodes.text(node, dropped + [element]).to_s, element)
130
+ end
131
+ end
132
+
133
+ def drop_opening_noise!(node, dropped)
134
+ @nodes.elements(node).each do |element|
135
+ next if within?(element, dropped) || !@nodes.noise?(element)
136
+ break unless opening?(node, dropped, element)
137
+
138
+ dropped << element
139
+ end
140
+ end
141
+
142
+ # Date chips ({#date_chip?}) closing the text.
143
+ def drop_closing_dates!(node, dropped)
144
+ @nodes.elements(node).reverse_each do |element|
145
+ next if within?(element, dropped) || !date_chip?(element)
146
+ break unless closing?(node, dropped, element)
147
+
148
+ dropped << element
149
+ end
150
+ dropped
151
+ end
152
+
153
+ # The date chip of a teaser card: a date-only element ({Article::Title.date_piece?}), or a date element
154
+ # holding only a clock time (+late <time>22:10</time>+, while +at <b>20:15</b>+ is headline text).
155
+ def date_chip?(element)
156
+ Article::Title.date_piece?(@nodes.text(element)) || (@nodes.date_element?(element) && Article::Title.clock_piece?(@nodes.text(element)))
157
+ end
158
+
159
+ # Whether +element+ opens the text of +node+ left after +dropped+.
160
+ def opening?(node, dropped, element) = @nodes.text(node, dropped).to_s.start_with?(@nodes.text(element))
161
+
162
+ # Whether +element+ closes the text of +node+ left after +dropped+.
163
+ def closing?(node, dropped, element) = @nodes.text(node, dropped).end_with?(@nodes.text(element).to_s)
164
+
165
+ # Whether +element+ is or sits inside a dropped element.
166
+ def within?(element, dropped) = dropped.any? { @nodes.inside?(element, _1) }
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # The one vocabulary of elements a sighted reader does not see, in two kinds:
7
+ #
8
+ # - unrendered: a +hidden+ attribute, an inline +display: none+, or a hiding utility class
9
+ # ({HIDDEN_CLASSES}). Neither seen nor spoken.
10
+ # - screen-reader-only: an off-screen or visually-hidden class ({SCREEN_READER_CLASS}: +sr-only+,
11
+ # +visually-hidden+, +h-offscreen+, +_screen-reader-only+, +screen-reader-text+, ...). Spoken, not seen.
12
+ #
13
+ # +aria-hidden="true"+ is neither: the element is seen, only not spoken. Callers pick the kind they
14
+ # need: a sponsorship label still counts when spoken ({LinkDestination::Sponsorship}), while titles
15
+ # and item anchors keep only what is seen ({.concealed?}). Inputs are plain attribute values, so
16
+ # SST and Nokogiri callers share it.
17
+ module Visibility
18
+ # Inline style that hides an element.
19
+ HIDDEN_STYLE = /display\s*:\s*none/i
20
+ # Utility class tokens that keep an element from rendering.
21
+ HIDDEN_CLASSES = Set['hidden', 'd-none', 'is-hidden', 'hide'].freeze
22
+ # Off-screen / visually-hidden class token, with an optional utility or BEM prefix (+u-+, +h-+,
23
+ # +card__+, a leading +_+) and a +-focusable+ variant that shows only on keyboard focus.
24
+ SCREEN_READER_CLASS = /
25
+ \A_?(?:[a-z0-9]+(?:-|__))?
26
+ (?:sr-only|visually-?hidden|screen-?reader-(?:only|text)|off-?screen|element-invisible)
27
+ (?:-focusable)?\z
28
+ /ix
29
+
30
+ class << self
31
+ ##
32
+ # @param class_names [Array<String>] class tokens
33
+ # @param style [String, nil] inline style
34
+ # @param hidden [Boolean] whether the element carries a +hidden+ attribute
35
+ # @return [Boolean] whether the element is not rendered at all
36
+ def unrendered?(class_names, style:, hidden:)
37
+ hidden || style.to_s.match?(HIDDEN_STYLE) || class_names.any? { HIDDEN_CLASSES.include?(_1) }
38
+ end
39
+
40
+ ##
41
+ # @param class_names [Array<String>] class tokens
42
+ # @return [Boolean] whether a class keeps the element off screen for screen readers only
43
+ def screen_reader_only?(class_names) = class_names.any? { _1.match?(SCREEN_READER_CLASS) }
44
+
45
+ ##
46
+ # @param class_names [Array<String>] class tokens
47
+ # @param style [String, nil] inline style
48
+ # @param hidden [Boolean] whether the element carries a +hidden+ attribute
49
+ # @return [Boolean] whether a sighted reader does not see the element
50
+ def concealed?(class_names, style:, hidden:)
51
+ unrendered?(class_names, style:, hidden:) || screen_reader_only?(class_names)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/html2rss/html.rb CHANGED
@@ -4,5 +4,7 @@ module Html2rss
4
4
  ##
5
5
  # DOM helpers for navigating and extracting from HTML trees.
6
6
  module Html
7
+ # Raised when an extractor is built without the node or segment it extracts from.
8
+ class InvalidError < Html2rss::ContractError; end
7
9
  end
8
10
  end
@@ -21,7 +21,7 @@ module Html2rss
21
21
  # @param url [Html2rss::Url] normalized destination URL
22
22
  # @return [DestinationFacts] route facts for downstream link scoring
23
23
  def self.build(url) # rubocop:disable Metrics/MethodLength
24
- classifier = PathClassifier.new(url.path_segments)
24
+ classifier = classifier_for(url)
25
25
 
26
26
  new(
27
27
  url:,
@@ -37,6 +37,22 @@ module Html2rss
37
37
  high_confidence_utility_destination: classifier.utility_destination?
38
38
  )
39
39
  end
40
+
41
+ # @param url [Html2rss::Url]
42
+ # @return [PathClassifier] path classifier that also sees the first host label (an obituary subdomain)
43
+ def self.classifier_for(url)
44
+ PathClassifier.new(url.path_segments.map { decode_segment(_1) },
45
+ host_label: url.host.to_s.split('.').first, query: !url.query.nil?)
46
+ end
47
+ private_class_method :classifier_for
48
+
49
+ # Url keeps path segments percent-encoded; classification words are plain NFC text.
50
+ # @param segment [String] percent-encoded path segment
51
+ # @return [String] decoded, NFC-normalized segment
52
+ def self.decode_segment(segment)
53
+ Addressable::URI.unencode_component(segment).force_encoding(Encoding::UTF_8).scrub.unicode_normalize(:nfc)
54
+ end
55
+ private_class_method :decode_segment
40
56
  end
41
57
  end
42
58
  end