html2rss 0.21.0 → 0.22.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +196 -0
  3. data/lib/html2rss/auto_source/scraper/html.rb +14 -2
  4. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  5. data/lib/html2rss/auto_source/scraper/microdata.rb +16 -4
  6. data/lib/html2rss/auto_source/scraper/semantic_html/deduplicator.rb +1 -1
  7. data/lib/html2rss/auto_source/scraper/semantic_html.rb +28 -20
  8. data/lib/html2rss/auto_source.rb +6 -2
  9. data/lib/html2rss/cli.rb +61 -10
  10. data/lib/html2rss/config/class_methods.rb +3 -3
  11. data/lib/html2rss/config/validator.rb +1 -0
  12. data/lib/html2rss/config.rb +2 -2
  13. data/lib/html2rss/html_extractor/heading_extractor.rb +50 -0
  14. data/lib/html2rss/html_extractor/id_generator.rb +67 -0
  15. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +4 -18
  16. data/lib/html2rss/html_extractor/semantic_containers.rb +28 -3
  17. data/lib/html2rss/html_extractor/text_extractor.rb +77 -0
  18. data/lib/html2rss/html_extractor.rb +78 -61
  19. data/lib/html2rss/html_navigator.rb +17 -0
  20. data/lib/html2rss/rendering.rb +2 -2
  21. data/lib/html2rss/request_service/local_file_strategy.rb +29 -0
  22. data/lib/html2rss/request_service/puppet_commander.rb +4 -0
  23. data/lib/html2rss/request_service.rb +2 -1
  24. data/lib/html2rss/selectors/extractors/attribute.rb +1 -1
  25. data/lib/html2rss/selectors/extractors/href.rb +1 -1
  26. data/lib/html2rss/selectors/extractors/html.rb +1 -1
  27. data/lib/html2rss/selectors/extractors/static.rb +1 -1
  28. data/lib/html2rss/selectors/extractors/text.rb +1 -1
  29. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +8 -1
  30. data/lib/html2rss/selectors.rb +9 -4
  31. data/lib/html2rss/url.rb +26 -13
  32. data/lib/html2rss/version.rb +1 -1
  33. data/lib/html2rss.rb +28 -6
  34. data/schema/html2rss-config.schema.json +20 -2
  35. metadata +7 -2
@@ -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
@@ -138,17 +138,7 @@ module Html2rss
138
138
  # @return [Boolean] true when the anchor is inside the selected heading
139
139
  def heading_anchor?
140
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
148
-
149
- curr = curr.parent
150
- end
151
- false
141
+ heading && (@anchor == heading || HtmlNavigator.descendant_of?(@anchor, heading))
152
142
  end
153
143
 
154
144
  # @return [Boolean] true when anchor text exactly matches heading text
@@ -183,15 +173,11 @@ module Html2rss
183
173
  end
184
174
 
185
175
  def utility_landmark_ancestor?
186
- curr = @anchor.parent
187
176
  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
177
+ condition = proc { |node| node == container || Context::UTILITY_LANDMARK_TAGS.include?(node.name) }
178
+ landmark = HtmlNavigator.parent_until_condition(@anchor.parent, condition)
191
179
 
192
- curr = curr.parent
193
- end
194
- false
180
+ landmark && landmark != container
195
181
  end
196
182
 
197
183
  def icon_only_anchor?
@@ -32,9 +32,34 @@ module Html2rss
32
32
  HtmlExtractor.ignored_container_path?(node, cache)
33
33
  end
34
34
 
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.
35
+ candidates = filter_nested_containers(candidates)
36
+ sort_by_depth(candidates)
37
+ end
38
+
39
+ private
40
+
41
+ def filter_nested_containers(candidates)
42
+ candidate_set = Set.new(candidates)
43
+ rejected = Set.new
44
+
45
+ candidates.each do |candidate_b|
46
+ next if candidate_b.name == 'div'
47
+
48
+ find_and_reject_ancestors(candidate_b, candidate_set, rejected)
49
+ end
50
+
51
+ candidates.reject { |c| rejected.include?(c) }
52
+ end
53
+
54
+ def find_and_reject_ancestors(node, candidate_set, rejected)
55
+ curr = node.parent
56
+ while curr && !curr.document? && curr.name != 'html'
57
+ rejected << curr if candidate_set.include?(curr)
58
+ curr = curr.parent
59
+ end
60
+ end
61
+
62
+ def sort_by_depth(candidates)
38
63
  candidates.each_with_index
39
64
  .sort_by { |node, index| [-node.ancestors.size, index] }
40
65
  .map!(&:first)
@@ -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
@@ -4,13 +4,11 @@ module Html2rss
4
4
  ##
5
5
  # HtmlExtractor is responsible for extracting details (headline, url, images, etc.)
6
6
  # from an article_tag.
7
- class HtmlExtractor # rubocop:disable Metrics/ClassLength
8
- # Tags ignored when extracting visible text content from article containers.
9
- INVISIBLE_CONTENT_TAGS = %w[svg script noscript style template].to_set.freeze
7
+ # rubocop:disable Metrics/ClassLength
8
+ class HtmlExtractor
10
9
  # Heading tags used to prioritize title extraction.
11
10
  HEADING_TAGS = %w[h1 h2 h3 h4 h5 h6].freeze
12
- # Selector used to derive non-headline description nodes.
13
- NON_HEADLINE_SELECTOR = (HEADING_TAGS.map { |tag| ":not(#{tag})" } + INVISIBLE_CONTENT_TAGS.to_a).freeze
11
+
14
12
  # Element tags that indicate ignored DOM chrome when found in a container path.
15
13
  IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
16
14
 
@@ -26,20 +24,14 @@ module Html2rss
26
24
  class << self
27
25
  ##
28
26
  # Extracts visible text from a given node and its children.
27
+ # Delegates to TextExtractor.
29
28
  #
30
29
  # @param tag [Nokogiri::XML::Node] the node from which to extract visible text
31
30
  # @param separator [String] separator used to join text fragments (default is a space)
31
+ # @param exclude_nodes [Array<Nokogiri::XML::Node>, nil] nodes to exclude from extraction
32
32
  # @return [String, nil] the concatenated visible text, or nil if none is found
33
- def extract_visible_text(tag, separator: ' ')
34
- parts = tag.children.filter_map do |child|
35
- next unless visible_child?(child)
36
-
37
- raw_text = child.children.empty? ? child.text : extract_visible_text(child)
38
- text = raw_text&.strip
39
- text unless text.to_s.empty?
40
- end
41
-
42
- parts.join(separator).squeeze(' ').strip unless parts.empty?
33
+ def extract_visible_text(tag, separator: ' ', exclude_nodes: nil)
34
+ TextExtractor.call(tag, separator:, exclude_nodes:)
43
35
  end
44
36
 
45
37
  ##
@@ -55,42 +47,47 @@ module Html2rss
55
47
  # @param node [Nokogiri::XML::Node]
56
48
  # @param cache [Hash, nil] identity cache used to store results (must use compare_by_identity)
57
49
  # @return [Boolean] true when the node belongs to ignored DOM chrome
50
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
58
51
  def ignored_container_path?(node, cache = nil)
59
52
  return cache[node] if cache&.key?(node)
60
53
 
61
- res = walk_ignored_container_path?(node)
62
- cache[node] = res if cache
63
- res
64
- end
54
+ curr = node
55
+ visited = []
56
+ is_ignored = false
65
57
 
66
- private
58
+ while curr.respond_to?(:parent) && curr
59
+ if cache&.key?(curr)
60
+ is_ignored = cache[curr]
61
+ break
62
+ end
67
63
 
68
- def walk_ignored_container_path?(node)
69
- curr = node
70
- while curr.respond_to?(:parent)
71
- return true if IGNORED_CONTAINER_TAGS.include?(curr.name)
64
+ if IGNORED_CONTAINER_TAGS.include?(curr.name)
65
+ is_ignored = true
66
+ break
67
+ end
72
68
 
69
+ visited << curr
73
70
  curr = curr.parent
74
71
  end
75
- false
76
- end
72
+ visited.each { |n| cache[n] = is_ignored } if cache
77
73
 
78
- def visible_child?(node)
79
- !INVISIBLE_CONTENT_TAGS.include?(node.name) &&
80
- !(node.name == 'a' && node['href']&.start_with?('#'))
74
+ is_ignored
81
75
  end
76
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
82
77
  end
83
78
 
84
79
  ##
85
80
  # @param article_tag [Nokogiri::XML::Node] article-like container to extract from
86
81
  # @param base_url [String, Html2rss::Url] base url used to resolve relative links
87
82
  # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
88
- def initialize(article_tag, base_url:, selected_anchor:)
83
+ # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
84
+ def initialize(article_tag, base_url:, selected_anchor:, fallback_anchorless: false)
89
85
  raise ArgumentError, 'article_tag is required' unless article_tag
90
86
 
91
87
  @article_tag = article_tag
92
88
  @base_url = base_url
93
89
  @selected_anchor = selected_anchor
90
+ @fallback_anchorless = fallback_anchorless
94
91
  end
95
92
 
96
93
  # @return [Hash{Symbol => Object}] extracted article attributes
@@ -115,54 +112,73 @@ module Html2rss
115
112
  @extract_url ||= begin
116
113
  href = selected_anchor&.[]('href').to_s
117
114
 
118
- Url.from_relative(href.split('#').first.strip, base_url) unless href.empty?
115
+ if href.empty?
116
+ anchorless_url_fallback
117
+ else
118
+ Url.from_relative(href.split('#').first.strip, base_url)
119
+ end
119
120
  end
120
121
  end
121
122
 
122
- def extract_title
123
- title_source = heading || selected_anchor
124
- self.class.extract_visible_text(title_source) if title_source
123
+ def anchorless_url_fallback
124
+ return unless @fallback_anchorless
125
+
126
+ id = generate_id
127
+ Url.from_relative("##{id}", base_url) if id
125
128
  end
126
129
 
127
- def heading
128
- @heading ||= begin
129
- tags = article_tag.css(HEADING_TAGS.join(','))
130
- tags.any? ? select_best_heading(tags) : nil
131
- end
130
+ # rubocop:disable Metrics/CyclomaticComplexity
131
+ def extract_title
132
+ source = heading || selected_anchor
133
+ title_text = source ? self.class.extract_visible_text(source) : fallback_anchorless_title
134
+ return unless title_text
135
+
136
+ kicker = kicker_node ? self.class.extract_visible_text(kicker_node).to_s.strip : nil
137
+ kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
132
138
  end
139
+ # rubocop:enable Metrics/CyclomaticComplexity
140
+
141
+ def fallback_anchorless_title
142
+ return unless @fallback_anchorless && selected_anchor.nil?
133
143
 
134
- def select_best_heading(tags)
135
- min_tag_name = tags.map(&:name).min
136
- best_tag = nil
137
- max_size = -1
144
+ text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
145
+ text_node&.text&.strip
146
+ end
138
147
 
139
- tags.each do |tag|
140
- next if tag.name != min_tag_name
148
+ def heading
149
+ @heading ||= HeadingExtractor.call(
150
+ article_tag,
151
+ fallback_anchorless: @fallback_anchorless,
152
+ selected_anchor:
153
+ )
154
+ end
141
155
 
142
- size = self.class.extract_visible_text(tag)&.size.to_i
143
- (best_tag = tag) && (max_size = size) if size > max_size
156
+ def kicker_node
157
+ @kicker_node ||= begin
158
+ selector = '[data-tb-kicker], [class*="kicker"], [class*="eyebrow"], ' \
159
+ '[class*="pre-title"], [class*="pretitle"], [class*="overline"]'
160
+ node = article_tag.at_css(selector)
161
+ node && heading && (node == heading || HtmlNavigator.descendant_of?(node, heading)) ? nil : node
144
162
  end
145
-
146
- best_tag
147
163
  end
148
164
 
149
165
  def extract_description
150
- text = self.class.extract_visible_text(article_tag.css(NON_HEADLINE_SELECTOR), separator: '<br>')
151
- return text if text && !text.empty?
152
-
153
- description = self.class.extract_visible_text(article_tag)
154
- return nil if description.nil? || description.strip.empty?
166
+ exclude = [heading, selected_anchor, kicker_node].compact.to_set
167
+ description = self.class.extract_visible_text(article_tag, exclude_nodes: exclude)
168
+ return if description.nil?
155
169
 
156
- description.strip
170
+ desc = description.strip
171
+ desc.empty? ? nil : desc
157
172
  end
158
173
 
159
174
  def generate_id
160
- [
161
- article_tag['id'],
162
- article_tag.at_css('[id]')&.attr('id'),
163
- extract_url&.path,
164
- extract_url&.query
165
- ].compact.reject(&:empty?).first
175
+ @generate_id ||= IdGenerator.call(
176
+ article_tag,
177
+ heading:,
178
+ url: (selected_anchor ? extract_url : nil),
179
+ selected_anchor:,
180
+ fallback_anchorless: @fallback_anchorless
181
+ )
166
182
  end
167
183
 
168
184
  def extract_image = ImageExtractor.call(article_tag, base_url:)
@@ -170,4 +186,5 @@ module Html2rss
170
186
  def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
171
187
  def extract_categories = CategoryExtractor.call(article_tag)
172
188
  end
189
+ # rubocop:enable Metrics/ClassLength
173
190
  end
@@ -49,6 +49,23 @@ module Html2rss
49
49
 
50
50
  current_tag.ancestors(tag_name).first
51
51
  end
52
+
53
+ ##
54
+ # Returns true if child_node is a descendant of parent_node.
55
+ # Walks up using parent pointers to avoid NodeSet allocations.
56
+ #
57
+ # @param child_node [Nokogiri::XML::Node] potential descendant
58
+ # @param parent_node [Nokogiri::XML::Node] potential ancestor
59
+ # @return [Boolean] true when child_node is a descendant of parent_node
60
+ def descendant_of?(child_node, parent_node)
61
+ curr = child_node.respond_to?(:parent) ? child_node.parent : nil
62
+ while curr
63
+ return true if curr == parent_node
64
+
65
+ curr = curr.respond_to?(:parent) ? curr.parent : nil
66
+ end
67
+ false
68
+ end
52
69
  end
53
70
  end
54
71
  end
@@ -4,6 +4,8 @@ module Html2rss
4
4
  # Namespace for HTML rendering logic, used to generate rich content such as
5
5
  # images, audio, video, or embedded documents for feed descriptions.
6
6
  #
7
+ # @see Html2rss::Rendering::DescriptionBuilder
8
+ #
7
9
  # @example
8
10
  # Html2rss::Rendering::ImageRenderer.new(
9
11
  # url: "https://example.com/image.jpg",
@@ -16,8 +18,6 @@ module Html2rss
16
18
  # image: "https://example.com/image.jpg",
17
19
  # title: "Example"
18
20
  # )
19
- #
20
- # @see Html2rss::Rendering::DescriptionBuilder
21
21
  module Rendering
22
22
  end
23
23
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class RequestService
5
+ ##
6
+ # Strategy to read a local HTML file.
7
+ class LocalFileStrategy < Strategy
8
+ ##
9
+ # Executes the local file read.
10
+ #
11
+ # @return [Response] the mock response wrapped around the file contents
12
+ # @raise [ArgumentError] if the local file path is missing
13
+ # @raise [Errno::ENOENT] if the file does not exist
14
+ def execute
15
+ file_path = ctx.request[:local_file_path]
16
+ raise ArgumentError, 'Local file path is required for local_file strategy' unless file_path
17
+ raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
18
+
19
+ body = File.read(file_path)
20
+ Response.new(
21
+ body:,
22
+ headers: { 'content-type' => 'text/html; charset=utf-8' },
23
+ url: ctx.url,
24
+ status: 200
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
@@ -97,6 +97,10 @@ module Html2rss
97
97
 
98
98
  attr_reader :ctx, :browser, :skip_request_resources, :referer, :latest_navigation_response, :main_frame
99
99
 
100
+ ##
101
+ # Re-raises a deferred navigation error when one was captured.
102
+ #
103
+ # @raise [Html2rss::Error] when a navigation request or response validation failed
100
104
  def raise_navigation_error_if_any
101
105
  raise @navigation_error if @navigation_error
102
106
  end
@@ -57,7 +57,8 @@ module Html2rss
57
57
  @strategies = {
58
58
  faraday: FaradayStrategy,
59
59
  botasaurus: BotasaurusStrategy,
60
- browserless: BrowserlessStrategy
60
+ browserless: BrowserlessStrategy,
61
+ local_file: LocalFileStrategy
61
62
  }
62
63
  @default_strategy_name = :faraday
63
64
  end
@@ -25,7 +25,7 @@ module Html2rss
25
25
  # during post processing with {PostProcessors::ParseTime}.
26
26
  class Attribute
27
27
  # The available options for the attribute extractor.
28
- Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
28
+ Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true)
29
29
 
30
30
  ##
31
31
  # Initializes the Attribute extractor.
@@ -25,7 +25,7 @@ module Html2rss
25
25
  # 'http://blog-without-a-feed.example.com/posts/latest-findings'
26
26
  class Href
27
27
  # The available options for the href (attribute) extractor.
28
- Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
28
+ Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true)
29
29
 
30
30
  ##
31
31
  # Initializes the Href extractor.
@@ -24,7 +24,7 @@ module Html2rss
24
24
  # {PostProcessors::SanitizeHtml}.
25
25
  class Html
26
26
  # The available options for the html extractor.
27
- Options = Struct.new('HtmlOptions', :selector, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
27
+ Options = Struct.new('HtmlOptions', :selector, keyword_init: true)
28
28
 
29
29
  ##
30
30
  # Initializes the Html extractor.
@@ -17,7 +17,7 @@ module Html2rss
17
17
  # 'Foobar'
18
18
  class Static
19
19
  # The available option for the static extractor.
20
- Options = Struct.new('StaticOptions', :static, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
20
+ Options = Struct.new('StaticOptions', :static, keyword_init: true)
21
21
 
22
22
  ##
23
23
  # Initializes the Static extractor.
@@ -22,7 +22,7 @@ module Html2rss
22
22
  # 'Lorem ipsum dolor ...'
23
23
  class Text
24
24
  # The available options for the text extractor.
25
- Options = Struct.new('TextOptions', :selector, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
25
+ Options = Struct.new('TextOptions', :selector, keyword_init: true)
26
26
 
27
27
  ##
28
28
  # Initializes the Text extractor.
@@ -128,8 +128,15 @@ module Html2rss
128
128
  ##
129
129
  # @return [String, nil]
130
130
  def get
131
- sanitized_html = Sanitize.fragment(value, self.class.sanitize_config(channel_url)).to_s
131
+ # Temporarily replace newlines with a placeholder to preserve them during space collapsing
132
+ temp_value = value.to_s.gsub("\n", ' __NEWLINE_PLACEHOLDER__ ')
133
+ sanitized_html = Sanitize.fragment(temp_value, self.class.sanitize_config(channel_url)).to_s
132
134
  sanitized_html.gsub!(/\s+/, ' ')
135
+
136
+ # Restore newlines and clean up surrounding whitespace
137
+ sanitized_html.gsub!(/[ \t\r]*__NEWLINE_PLACEHOLDER__[ \t\r]*/, "\n")
138
+ sanitized_html.gsub!(/\n{3,}/, "\n\n")
139
+
133
140
  sanitized_html.strip!
134
141
  sanitized_html.empty? ? nil : sanitized_html
135
142
  end
@@ -18,7 +18,7 @@ module Html2rss
18
18
  include Enumerable
19
19
 
20
20
  # A context instance passed to item extractors and post-processors.
21
- Context = Struct.new('Context', :options, :item, :config, :scraper, keyword_init: true) # rubocop:disable Style/RedundantStructKeywordInit
21
+ Context = Struct.new('Context', :options, :item, :config, :scraper, keyword_init: true)
22
22
 
23
23
  # Default selectors options merged into user configuration.
24
24
  DEFAULT_CONFIG = { items: { enhance: true } }.freeze
@@ -103,11 +103,15 @@ module Html2rss
103
103
  # @param article_tag [Nokogiri::XML::Element] HTML element to extract additional info from.
104
104
  # @param base_url [String, Html2rss::Url] base URL for normalization during enhancement
105
105
  # @return [Hash] The enhanced article hash.
106
+ # rubocop:disable Metrics/MethodLength
106
107
  def enhance_article_hash(article_hash, article_tag, base_url = @url)
107
108
  selected_anchor = HtmlExtractor.main_anchor_for(article_tag)
108
- return article_hash unless selected_anchor
109
-
110
- extracted = HtmlExtractor.new(article_tag, base_url:, selected_anchor:).call
109
+ extracted = HtmlExtractor.new(
110
+ article_tag,
111
+ base_url:,
112
+ selected_anchor:,
113
+ fallback_anchorless: true
114
+ ).call
111
115
  return article_hash unless extracted
112
116
 
113
117
  extracted.each_with_object(article_hash) do |(key, value), hash|
@@ -116,6 +120,7 @@ module Html2rss
116
120
  hash[key] = value
117
121
  end
118
122
  end
123
+ # rubocop:enable Metrics/MethodLength
119
124
 
120
125
  ##
121
126
  # Selects the value for a given attribute from an HTML element.