html2rss 0.20.1 → 0.22.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/html2rss.gemspec +1 -2
  3. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +185 -0
  4. data/lib/html2rss/auto_source/scraper/html.rb +73 -16
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +41 -28
  6. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +85 -131
  7. data/lib/html2rss/auto_source/scraper/microdata.rb +2 -2
  8. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +74 -28
  9. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +3 -2
  10. data/lib/html2rss/auto_source/scraper/schema/thing.rb +31 -60
  11. data/lib/html2rss/auto_source/scraper/schema.rb +8 -2
  12. data/lib/html2rss/auto_source/scraper/semantic_html/deduplicator.rb +4 -18
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +55 -11
  14. data/lib/html2rss/auto_source/scraper.rb +0 -3
  15. data/lib/html2rss/auto_source.rb +5 -12
  16. data/lib/html2rss/category_extractor.rb +54 -20
  17. data/lib/html2rss/cli.rb +61 -10
  18. data/lib/html2rss/config/class_methods.rb +3 -3
  19. data/lib/html2rss/config/validator.rb +1 -0
  20. data/lib/html2rss/config.rb +2 -2
  21. data/lib/html2rss/html_extractor/enclosure_extractor.rb +60 -89
  22. data/lib/html2rss/html_extractor/heading_extractor.rb +50 -0
  23. data/lib/html2rss/html_extractor/id_generator.rb +67 -0
  24. data/lib/html2rss/html_extractor/list_candidates.rb +2 -8
  25. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +29 -12
  26. data/lib/html2rss/html_extractor/semantic_containers.rb +9 -35
  27. data/lib/html2rss/html_extractor/text_extractor.rb +77 -0
  28. data/lib/html2rss/html_extractor.rb +80 -61
  29. data/lib/html2rss/rendering/description_builder.rb +3 -3
  30. data/lib/html2rss/rendering.rb +2 -2
  31. data/lib/html2rss/request_service/local_file_strategy.rb +29 -0
  32. data/lib/html2rss/request_service/puppet_commander.rb +4 -0
  33. data/lib/html2rss/request_service.rb +2 -1
  34. data/lib/html2rss/rss_builder/article.rb +44 -23
  35. data/lib/html2rss/rss_builder/enclosure.rb +4 -2
  36. data/lib/html2rss/selectors/extractors/attribute.rb +1 -1
  37. data/lib/html2rss/selectors/extractors/href.rb +1 -1
  38. data/lib/html2rss/selectors/extractors/html.rb +1 -1
  39. data/lib/html2rss/selectors/extractors/static.rb +1 -1
  40. data/lib/html2rss/selectors/extractors/text.rb +1 -1
  41. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +32 -36
  42. data/lib/html2rss/selectors/post_processors/substring.rb +11 -18
  43. data/lib/html2rss/selectors/post_processors/template.rb +3 -2
  44. data/lib/html2rss/selectors.rb +19 -5
  45. data/lib/html2rss/url.rb +29 -15
  46. data/lib/html2rss/version.rb +1 -1
  47. data/lib/html2rss.rb +28 -6
  48. data/schema/html2rss-config.schema.json +12 -1
  49. metadata +8 -17
@@ -5,116 +5,87 @@ module Html2rss
5
5
  ##
6
6
  # Extracts enclosures from HTML tags using various strategies.
7
7
  class EnclosureExtractor
8
+ # CSS union query covering images, media, PDFs, iframes, and archives.
9
+ SELECTOR = [
10
+ 'img[src]:not([src^="data"])',
11
+ 'video source[src]',
12
+ 'audio source[src]',
13
+ 'audio[src]',
14
+ 'a[href$=".pdf"]',
15
+ 'iframe[src]',
16
+ 'a[href$=".zip"]',
17
+ 'a[href$=".tar.gz"]',
18
+ 'a[href$=".tgz"]'
19
+ ].join(',').freeze
20
+
8
21
  # @param article_tag [Nokogiri::XML::Element] article container node
9
22
  # @param base_url [String, Html2rss::Url] base URL for relative enclosure links
10
23
  # @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
11
24
  def self.call(article_tag, base_url)
12
- [
13
- Extractors::Image,
14
- Extractors::Media,
15
- Extractors::Pdf,
16
- Extractors::Iframe,
17
- Extractors::Archive
18
- ].flat_map { |strategy| strategy.call(article_tag, base_url:) }
25
+ article_tag.css(SELECTOR).filter_map do |element|
26
+ extract_from_element(element, base_url)
27
+ end
19
28
  end
20
- end
21
29
 
22
- # Extraction strategies for enclosure-like media/link tags.
23
- module Extractors
24
- # Extracts image enclosures from HTML tags.
25
- # Finds all image sources and returns them in a format suitable for RSS.
26
- class Image
27
- # @param article_tag [Nokogiri::XML::Element] article container node
28
- # @param base_url [String, Html2rss::Url] base URL for relative image sources
29
- # @return [Array<Hash{Symbol => Object}>] image enclosure hashes
30
- def self.call(article_tag, base_url:)
31
- article_tag.css('img[src]:not([src^="data"])').filter_map do |img|
32
- src = img['src'].to_s
33
- next if src.empty?
34
-
35
- abs_url = Url.from_relative(src, base_url)
36
- {
37
- url: abs_url,
38
- type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
39
- }
40
- end
30
+ def self.extract_from_element(element, base_url)
31
+ case element.name
32
+ when 'img'
33
+ extract_image(element, base_url)
34
+ when 'video', 'audio', 'source'
35
+ extract_media(element, base_url)
36
+ when 'iframe'
37
+ extract_iframe(element, base_url)
38
+ when 'a'
39
+ extract_a(element, base_url)
41
40
  end
42
41
  end
43
42
 
44
- # Extracts media enclosures (video/audio) from HTML tags.
45
- class Media
46
- # @param article_tag [Nokogiri::XML::Element] article container node
47
- # @param base_url [String, Html2rss::Url] base URL for relative media sources
48
- # @return [Array<Hash{Symbol => Object}>] media enclosure hashes
49
- def self.call(article_tag, base_url:)
50
- article_tag.css('video source[src], audio source[src], audio[src]').filter_map do |element|
51
- src = element['src'].to_s
52
- next if src.empty?
43
+ def self.extract_image(img, base_url)
44
+ src = img['src'].to_s
45
+ return if src.empty?
53
46
 
54
- {
55
- url: Url.from_relative(src, base_url),
56
- type: element['type']
57
- }
58
- end
59
- end
47
+ abs_url = Url.from_relative(src, base_url)
48
+ {
49
+ url: abs_url,
50
+ type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'image/jpeg')
51
+ }
60
52
  end
61
53
 
62
- # Extracts PDF enclosures from HTML tags.
63
- class Pdf
64
- # @param article_tag [Nokogiri::XML::Element] article container node
65
- # @param base_url [String, Html2rss::Url] base URL for relative PDF links
66
- # @return [Array<Hash{Symbol => Object}>] PDF enclosure hashes
67
- def self.call(article_tag, base_url:)
68
- article_tag.css('a[href$=".pdf"]').filter_map do |link|
69
- href = link['href'].to_s
70
- next if href.empty?
54
+ def self.extract_media(element, base_url)
55
+ src = element['src'].to_s
56
+ return if src.empty?
71
57
 
72
- abs_url = Url.from_relative(href, base_url)
73
- {
74
- url: abs_url,
75
- type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url)
76
- }
77
- end
78
- end
58
+ {
59
+ url: Url.from_relative(src, base_url),
60
+ type: element['type']
61
+ }
79
62
  end
80
63
 
81
- # Extracts iframe enclosures from HTML tags.
82
- class Iframe
83
- # @param article_tag [Nokogiri::XML::Element] article container node
84
- # @param base_url [String, Html2rss::Url] base URL for relative iframe links
85
- # @return [Array<Hash{Symbol => Object}>] iframe enclosure hashes
86
- def self.call(article_tag, base_url:)
87
- article_tag.css('iframe[src]').filter_map do |iframe|
88
- src = iframe['src']
89
- next if src.nil? || src.empty?
64
+ def self.extract_iframe(iframe, base_url)
65
+ src = iframe['src'].to_s
66
+ return if src.empty?
90
67
 
91
- abs_url = Url.from_relative(src, base_url)
92
- {
93
- url: abs_url,
94
- type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
95
- }
96
- end
97
- end
68
+ abs_url = Url.from_relative(src, base_url)
69
+ {
70
+ url: abs_url,
71
+ type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url, default: 'text/html')
72
+ }
98
73
  end
99
74
 
100
- # Extracts archive enclosures (zip, tar.gz, tgz) from HTML tags.
101
- class Archive
102
- # @param article_tag [Nokogiri::XML::Element] article container node
103
- # @param base_url [String, Html2rss::Url] base URL for relative archive links
104
- # @return [Array<Hash{Symbol => Object}>] archive enclosure hashes
105
- def self.call(article_tag, base_url:)
106
- article_tag.css('a[href$=".zip"], a[href$=".tar.gz"], a[href$=".tgz"]').filter_map do |link|
107
- href = link['href'].to_s
108
- next if href.empty?
75
+ def self.extract_a(link, base_url)
76
+ href = link['href'].to_s
77
+ return if href.empty?
109
78
 
110
- abs_url = Url.from_relative(href, base_url)
111
- {
112
- url: abs_url,
113
- type: 'application/zip'
114
- }
115
- end
79
+ abs_url = Url.from_relative(href, base_url)
80
+
81
+ if href.end_with?('.pdf')
82
+ { url: abs_url, type: RssBuilder::Enclosure.guess_content_type_from_url(abs_url) }
83
+ else
84
+ { url: abs_url, type: 'application/zip' }
116
85
  end
117
86
  end
87
+
88
+ private_class_method :extract_from_element, :extract_image, :extract_media, :extract_iframe, :extract_a
118
89
  end
119
90
  end
120
91
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class HtmlExtractor
5
+ ##
6
+ # HeadingExtractor identifies and returns the best heading element within a container.
7
+ class HeadingExtractor
8
+ # Heading tags used to prioritize title extraction.
9
+ HEADING_TAGS = HtmlExtractor::HEADING_TAGS
10
+
11
+ class << self
12
+ ##
13
+ # @param article_tag [Nokogiri::XML::Element] container node
14
+ # @param fallback_anchorless [Boolean] whether to use fallback search
15
+ # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
16
+ # @return [Nokogiri::XML::Node, nil] the heading node, if found
17
+ def call(article_tag, fallback_anchorless:, selected_anchor:)
18
+ tags = article_tag.css(HEADING_TAGS.join(','))
19
+ if tags.any?
20
+ select_best_heading(tags)
21
+ elsif fallback_anchorless && selected_anchor.nil?
22
+ fallback_heading(article_tag)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def select_best_heading(tags)
29
+ min_tag_name = tags.map(&:name).min
30
+ best_tag = nil
31
+ max_size = -1
32
+
33
+ tags.each do |tag|
34
+ next if tag.name != min_tag_name
35
+
36
+ size = TextExtractor.call(tag)&.size.to_i
37
+ (best_tag = tag) && (max_size = size) if size > max_size
38
+ end
39
+
40
+ best_tag
41
+ end
42
+
43
+ def fallback_heading(article_tag)
44
+ fallback_tags = article_tag.css('strong, b, [class*="title"], [class*="font-bold"], [class*="font-semibold"]')
45
+ fallback_tags.find { |t| !TextExtractor.call(t).to_s.strip.empty? }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Html2rss
6
+ class HtmlExtractor
7
+ ##
8
+ # IdGenerator determines the unique ID for an article container node.
9
+ class IdGenerator
10
+ class << self
11
+ ##
12
+ # @param article_tag [Nokogiri::XML::Element] container node
13
+ # @param heading [Nokogiri::XML::Node, nil] heading node
14
+ # @param url [Html2rss::Url, nil] absolute article URL
15
+ # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
16
+ # @param fallback_anchorless [Boolean] whether to use fallback hashing
17
+ # @return [String, nil] the generated ID, if any
18
+ def call(article_tag, heading:, url:, selected_anchor:, fallback_anchorless:)
19
+ id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
20
+ return id_from_dom if id_from_dom
21
+
22
+ heading_text = resolve_heading_text(article_tag, heading, fallback_anchorless)
23
+ if heading_text && !heading_text.strip.empty?
24
+ generate_slug(heading_text)
25
+ elsif fallback_anchorless
26
+ generate_content_hash(article_tag)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def parse_id_from_dom(article_tag, url, selected_anchor)
33
+ candidates = [article_tag['id'], article_tag.at_css('[id]')&.attr('id')]
34
+ candidates += [url&.path, url&.query] if selected_anchor
35
+ candidates.compact.reject(&:empty?).first
36
+ end
37
+
38
+ def resolve_heading_text(article_tag, heading, fallback_anchorless)
39
+ text = heading ? TextExtractor.call(heading) : nil
40
+ if text.nil? || text.strip.empty?
41
+ fallback_text_node_content(article_tag, fallback_anchorless)
42
+ else
43
+ text
44
+ end
45
+ end
46
+
47
+ def fallback_text_node_content(article_tag, fallback_anchorless)
48
+ return unless fallback_anchorless
49
+
50
+ article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
51
+ end
52
+
53
+ def generate_slug(text)
54
+ slug = text.downcase.gsub(/[^a-z0-9]+/, '-')
55
+ slug = slug[1..] if slug.start_with?('-')
56
+ slug = slug[0..-2] if slug.end_with?('-')
57
+ slug unless slug.empty?
58
+ end
59
+
60
+ def generate_content_hash(article_tag)
61
+ text = TextExtractor.call(article_tag).to_s.strip
62
+ Zlib.crc32(text).to_s(36) unless text.empty?
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -75,17 +75,11 @@ module Html2rss
75
75
  def each_anchor(anchor_filter:)
76
76
  return enum_for(:each_anchor, anchor_filter:) unless block_given?
77
77
 
78
- traversal_root&.traverse do |node|
79
- yield node if relevant_anchor?(node, anchor_filter:)
78
+ traversal_root&.css(HtmlExtractor::MAIN_ANCHOR_SELECTOR)&.each do |node|
79
+ yield node if anchor_filter.call(node)
80
80
  end
81
81
  end
82
82
 
83
- def relevant_anchor?(node, anchor_filter:)
84
- node.element? &&
85
- node.matches?(HtmlExtractor::MAIN_ANCHOR_SELECTOR) &&
86
- anchor_filter.call(node)
87
- end
88
-
89
83
  def traversal_root
90
84
  parsed_body.at_css('body, html') || parsed_body.root
91
85
  end
@@ -31,6 +31,8 @@ module Html2rss
31
31
 
32
32
  # Shared context for all anchors in one semantic container.
33
33
  class Context
34
+ attr_reader :container
35
+
34
36
  # Ancestor tags that usually indicate navigation/utility regions.
35
37
  UTILITY_LANDMARK_TAGS = %w[nav aside footer menu].freeze
36
38
 
@@ -56,7 +58,7 @@ module Html2rss
56
58
  def visible_text(node)
57
59
  return '' unless node
58
60
 
59
- HtmlExtractor.extract_visible_text(node).to_s.strip
61
+ (@visible_texts ||= {}.compare_by_identity)[node] ||= HtmlExtractor.extract_visible_text(node).to_s.strip
60
62
  end
61
63
 
62
64
  # @param anchor [Nokogiri::XML::Node] anchor candidate
@@ -70,12 +72,6 @@ module Html2rss
70
72
  def utility_text?(text)
71
73
  @link_heuristics.utility_text?(text)
72
74
  end
73
-
74
- # @param ancestors [Array<Nokogiri::XML::Node>]
75
- # @return [Boolean] true when the anchor lives inside navigation chrome
76
- def utility_landmark?(ancestors)
77
- ancestors.any? { |node| UTILITY_LANDMARK_TAGS.include?(node.name) }
78
- end
79
75
  end
80
76
 
81
77
  # One anchor plus the facts needed to decide whether it represents content.
@@ -131,7 +127,7 @@ module Html2rss
131
127
 
132
128
  # @return [Boolean] true when visible anchor text has words
133
129
  def meaningful_text?
134
- text.scan(/\p{Alnum}+/).any?
130
+ @meaningful_text ||= text.match?(/\p{Alnum}/)
135
131
  end
136
132
 
137
133
  # @return [Boolean] true when the destination route has content signals
@@ -142,8 +138,17 @@ module Html2rss
142
138
  # @return [Boolean] true when the anchor is inside the selected heading
143
139
  def heading_anchor?
144
140
  heading = @context.heading
141
+ return false unless heading
142
+
143
+ curr = @anchor
144
+ container = @context.container
145
+ while curr.respond_to?(:parent)
146
+ return true if curr == heading
147
+ break if curr == container
145
148
 
146
- heading && @anchor.ancestors.include?(heading)
149
+ curr = curr.parent
150
+ end
151
+ false
147
152
  end
148
153
 
149
154
  # @return [Boolean] true when anchor text exactly matches heading text
@@ -151,14 +156,14 @@ module Html2rss
151
156
  heading_text = @context.heading_text
152
157
 
153
158
  meaningful_text? &&
154
- heading_text.scan(/\p{Alnum}+/).any? &&
159
+ heading_text.match?(/\p{Alnum}/) &&
155
160
  heading_text == text
156
161
  end
157
162
 
158
163
  private
159
164
 
160
165
  def representative_content_anchor?
161
- heading_anchor? || meaningful_text? || content_like_destination?
166
+ meaningful_text? || content_like_destination? || heading_anchor?
162
167
  end
163
168
 
164
169
  def utility_text_suppressed?
@@ -174,7 +179,19 @@ module Html2rss
174
179
  def ineligible_anchor?
175
180
  destination_facts.high_confidence_utility_destination ||
176
181
  icon_only_anchor? ||
177
- @context.utility_landmark?(@anchor.ancestors.to_a)
182
+ utility_landmark_ancestor?
183
+ end
184
+
185
+ def utility_landmark_ancestor?
186
+ curr = @anchor.parent
187
+ container = @context.container
188
+ while curr.respond_to?(:parent)
189
+ return true if Context::UTILITY_LANDMARK_TAGS.include?(curr.name)
190
+ break if curr == container
191
+
192
+ curr = curr.parent
193
+ end
194
+ false
178
195
  end
179
196
 
180
197
  def icon_only_anchor?
@@ -27,43 +27,17 @@ module Html2rss
27
27
 
28
28
  # @return [Array<Nokogiri::XML::Node>] candidate semantic containers
29
29
  def call
30
- containers = SELECTORS.each_with_object([]) do |selector, memo|
31
- collect_selector_containers(selector, memo)
30
+ cache = {}.compare_by_identity
31
+ candidates = @parsed_body.css(SELECTORS.join(',')).reject do |node|
32
+ HtmlExtractor.ignored_container_path?(node, cache)
32
33
  end
33
34
 
34
- containers.sort_by { document_order.fetch(_1) }
35
- end
36
-
37
- private
38
-
39
- def document_order
40
- @document_order ||= begin
41
- order = {}
42
- index = 0
43
-
44
- @parsed_body.traverse do |node|
45
- next unless node.element?
46
-
47
- order[node] = index
48
- index += 1
49
- end
50
-
51
- order.compare_by_identity
52
- end
53
- end
54
-
55
- def collect_selector_containers(selector, containers)
56
- @parsed_body.css(selector).each do |container|
57
- next if HtmlExtractor.ignored_container_path?(container)
58
- next if seen[container]
59
-
60
- seen[container] = true
61
- containers << container
62
- end
63
- end
64
-
65
- def seen
66
- @seen ||= {}.compare_by_identity
35
+ # Preserve the original post-order traversal intent (specific-first)
36
+ # by sorting candidates by depth (descending) while keeping original document
37
+ # order for nodes at the same depth.
38
+ candidates.each_with_index
39
+ .sort_by { |node, index| [-node.ancestors.size, index] }
40
+ .map!(&:first)
67
41
  end
68
42
  end
69
43
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class HtmlExtractor
5
+ ##
6
+ # TextExtractor extracts visible text from DOM elements, preserving lists
7
+ # and block spacing while sanitizing white spaces.
8
+ class TextExtractor
9
+ # HTML block elements that trigger line breaks or special formatting.
10
+ BLOCK_TAGS = %w[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
11
+ # Tags ignored when extracting visible text content.
12
+ INVISIBLE_CONTENT_TAGS = %w[svg script noscript style template].to_set.freeze
13
+
14
+ class << self
15
+ ##
16
+ # @param tag [Nokogiri::XML::Node] the node from which to extract visible text
17
+ # @param separator [String] separator used to join text fragments (default is a space)
18
+ # @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
19
+ # @return [String, nil] the concatenated visible text, or nil if none is found
20
+ def call(tag, separator: ' ', exclude_nodes: nil)
21
+ return tag.text.gsub(/\s+/, ' ').strip if tag.respond_to?(:text?) && tag.text?
22
+
23
+ parts = iterate_children(tag, separator, exclude_nodes)
24
+ return if parts.empty?
25
+
26
+ parts.join.squeeze(' ').strip
27
+ end
28
+
29
+ private
30
+
31
+ def iterate_children(tag, separator, exclude_nodes)
32
+ last = false
33
+ tag.children.each_with_object([]) do |c, p|
34
+ next if exclude_nodes&.include?(c) || !visible_child?(c)
35
+
36
+ text, block = process_child_node(c, separator, exclude_nodes)
37
+ next if text.empty?
38
+
39
+ append_separator!(p, separator, block, last)
40
+ (p << text) && (last = block)
41
+ end
42
+ end
43
+
44
+ def process_child_node(child, separator, exclude_nodes)
45
+ child_text = get_child_text(child, separator, exclude_nodes)
46
+ return ['', false] if child_text.empty?
47
+
48
+ child_text = "- #{child_text}" if child.name == 'li'
49
+ [child_text, BLOCK_TAGS.include?(child.name)]
50
+ end
51
+
52
+ def get_child_text(child, separator, exclude_nodes)
53
+ if child.children.empty?
54
+ child.text.to_s.gsub(/\s+/, ' ').strip
55
+ else
56
+ call(child, separator:, exclude_nodes:).to_s.strip
57
+ end
58
+ end
59
+
60
+ def append_separator!(parts, separator, is_block, last_was_block)
61
+ return if parts.empty?
62
+
63
+ parts << if is_block || last_was_block
64
+ (separator == ' ' ? "\n" : separator)
65
+ else
66
+ ' '
67
+ end
68
+ end
69
+
70
+ def visible_child?(node)
71
+ !INVISIBLE_CONTENT_TAGS.include?(node.name) &&
72
+ !(node.name == 'a' && node['href']&.start_with?('#'))
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end