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
@@ -4,15 +4,13 @@ module Html2rss
4
4
  ##
5
5
  # HtmlExtractor is responsible for extracting details (headline, url, images, etc.)
6
6
  # from an article_tag.
7
+ # rubocop:disable Metrics/ClassLength
7
8
  class HtmlExtractor
8
- # Tags ignored when extracting visible text content from article containers.
9
- INVISIBLE_CONTENT_TAGS = %w[svg script noscript style template].to_set.freeze
10
- # Element path pattern ignored when traversing candidate article containers.
11
- IGNORED_CONTAINER_PATH = /(nav|footer|header|svg|script|style)/i
12
9
  # Heading tags used to prioritize title extraction.
13
10
  HEADING_TAGS = %w[h1 h2 h3 h4 h5 h6].freeze
14
- # Selector used to derive non-headline description nodes.
15
- NON_HEADLINE_SELECTOR = (HEADING_TAGS.map { |tag| ":not(#{tag})" } + INVISIBLE_CONTENT_TAGS.to_a).freeze
11
+
12
+ # Element tags that indicate ignored DOM chrome when found in a container path.
13
+ IGNORED_CONTAINER_TAGS = %w[nav footer header svg script style].to_set.freeze
16
14
 
17
15
  # Anchor selector used to identify the canonical article link element.
18
16
  MAIN_ANCHOR_SELECTOR = begin
@@ -26,27 +24,47 @@ 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)
33
+ def extract_visible_text(tag, separator: ' ', exclude_nodes: nil)
34
+ TextExtractor.call(tag, separator:, exclude_nodes:)
35
+ end
36
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
37
+ ##
38
+ # @param article_tag [Nokogiri::XML::Node] article-like container to search within
39
+ # @return [Nokogiri::XML::Node, nil] first eligible descendant anchor
40
+ def main_anchor_for(article_tag)
41
+ return article_tag if article_tag.name == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
42
+
43
+ article_tag.at_css(MAIN_ANCHOR_SELECTOR)
44
+ end
41
45
 
42
- parts.join(separator).squeeze(' ').strip unless parts.empty?
46
+ ##
47
+ # @param node [Nokogiri::XML::Node]
48
+ # @param cache [Hash, nil] identity cache used to store results (must use compare_by_identity)
49
+ # @return [Boolean] true when the node belongs to ignored DOM chrome
50
+ def ignored_container_path?(node, cache = nil)
51
+ return cache[node] if cache&.key?(node)
52
+
53
+ res = walk_ignored_container_path?(node)
54
+ cache[node] = res if cache
55
+ res
43
56
  end
44
57
 
45
58
  private
46
59
 
47
- def visible_child?(node)
48
- !INVISIBLE_CONTENT_TAGS.include?(node.name) &&
49
- !(node.name == 'a' && node['href']&.start_with?('#'))
60
+ def walk_ignored_container_path?(node)
61
+ curr = node
62
+ while curr.respond_to?(:parent)
63
+ return true if IGNORED_CONTAINER_TAGS.include?(curr.name)
64
+
65
+ curr = curr.parent
66
+ end
67
+ false
50
68
  end
51
69
  end
52
70
 
@@ -54,12 +72,14 @@ module Html2rss
54
72
  # @param article_tag [Nokogiri::XML::Node] article-like container to extract from
55
73
  # @param base_url [String, Html2rss::Url] base url used to resolve relative links
56
74
  # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
57
- def initialize(article_tag, base_url:, selected_anchor:)
75
+ # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
76
+ def initialize(article_tag, base_url:, selected_anchor:, fallback_anchorless: false)
58
77
  raise ArgumentError, 'article_tag is required' unless article_tag
59
78
 
60
79
  @article_tag = article_tag
61
80
  @base_url = base_url
62
81
  @selected_anchor = selected_anchor
82
+ @fallback_anchorless = fallback_anchorless
63
83
  end
64
84
 
65
85
  # @return [Hash{Symbol => Object}] extracted article attributes
@@ -80,68 +100,66 @@ module Html2rss
80
100
 
81
101
  attr_reader :article_tag, :base_url, :selected_anchor
82
102
 
83
- class << self
84
- ##
85
- # @param article_tag [Nokogiri::XML::Node] article-like container to search within
86
- # @return [Nokogiri::XML::Node, nil] first eligible descendant anchor
87
- def main_anchor_for(article_tag)
88
- return article_tag if article_tag.name == 'a' && article_tag.matches?(MAIN_ANCHOR_SELECTOR)
89
-
90
- article_tag.at_css(MAIN_ANCHOR_SELECTOR)
91
- end
92
-
93
- ##
94
- # @param node [Nokogiri::XML::Node, String] node or path to test
95
- # @return [Boolean] true when the node belongs to ignored DOM chrome
96
- def ignored_container_path?(node)
97
- path = node.respond_to?(:path) ? node.path : node.to_s
98
-
99
- path.match?(IGNORED_CONTAINER_PATH)
100
- end
101
- end
102
-
103
103
  def extract_url
104
104
  @extract_url ||= begin
105
105
  href = selected_anchor&.[]('href').to_s
106
106
 
107
- Url.from_relative(href.split('#').first.strip, base_url) unless href.empty?
107
+ if href.empty?
108
+ anchorless_url_fallback
109
+ else
110
+ Url.from_relative(href.split('#').first.strip, base_url)
111
+ end
108
112
  end
109
113
  end
110
114
 
115
+ def anchorless_url_fallback
116
+ return unless @fallback_anchorless
117
+
118
+ id = generate_id
119
+ Url.from_relative("##{id}", base_url) if id
120
+ end
121
+
111
122
  def extract_title
112
123
  title_source = heading || selected_anchor
113
- self.class.extract_visible_text(title_source) if title_source
124
+ if title_source
125
+ self.class.extract_visible_text(title_source)
126
+ else
127
+ fallback_anchorless_title
128
+ end
129
+ end
130
+
131
+ def fallback_anchorless_title
132
+ return unless @fallback_anchorless && selected_anchor.nil?
133
+
134
+ text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
135
+ text_node&.text&.strip
114
136
  end
115
137
 
116
138
  def heading
117
- @heading ||= begin
118
- heading_tags = article_tag.css(HEADING_TAGS.join(',')).group_by(&:name)
119
- smallest_heading = heading_tags.keys.min
120
- if smallest_heading
121
- heading_tags[smallest_heading]&.max_by do |tag|
122
- self.class.extract_visible_text(tag)&.size.to_i
123
- end
124
- end
125
- end
139
+ @heading ||= HeadingExtractor.call(
140
+ article_tag,
141
+ fallback_anchorless: @fallback_anchorless,
142
+ selected_anchor:
143
+ )
126
144
  end
127
145
 
128
146
  def extract_description
129
- text = self.class.extract_visible_text(article_tag.css(NON_HEADLINE_SELECTOR), separator: '<br>')
130
- return text if text && !text.empty?
131
-
132
- description = self.class.extract_visible_text(article_tag)
133
- return nil if description.nil? || description.strip.empty?
147
+ exclude = [heading, selected_anchor].compact.to_set
148
+ description = self.class.extract_visible_text(article_tag, exclude_nodes: exclude)
149
+ return if description.nil?
134
150
 
135
- description.strip
151
+ desc = description.strip
152
+ desc.empty? ? nil : desc
136
153
  end
137
154
 
138
155
  def generate_id
139
- [
140
- article_tag['id'],
141
- article_tag.at_css('[id]')&.attr('id'),
142
- extract_url&.path,
143
- extract_url&.query
144
- ].compact.reject(&:empty?).first
156
+ @generate_id ||= IdGenerator.call(
157
+ article_tag,
158
+ heading:,
159
+ url: (selected_anchor ? extract_url : nil),
160
+ selected_anchor:,
161
+ fallback_anchorless: @fallback_anchorless
162
+ )
145
163
  end
146
164
 
147
165
  def extract_image = ImageExtractor.call(article_tag, base_url:)
@@ -149,4 +167,5 @@ module Html2rss
149
167
  def extract_enclosures = EnclosureExtractor.call(article_tag, base_url)
150
168
  def extract_categories = CategoryExtractor.call(article_tag)
151
169
  end
170
+ # rubocop:enable Metrics/ClassLength
152
171
  end
@@ -25,12 +25,12 @@ module Html2rss
25
25
  # @param end_of_range [Integer] Optional, defaults to half the text length
26
26
  # @return [String]
27
27
  def self.remove_pattern_from_start(text, pattern, end_of_range: (text.size * 0.5).to_i)
28
- return text unless text.is_a?(String) && pattern.is_a?(String)
28
+ return text unless text.is_a?(String) && pattern.is_a?(String) && !pattern.empty?
29
29
 
30
30
  index = text.index(pattern)
31
- return text if index.nil? || index >= end_of_range
31
+ return text if index.nil? || index > end_of_range
32
32
 
33
- text.gsub(/^(.{0,#{end_of_range}})#{Regexp.escape(pattern)}/, '\1')
33
+ "#{text[0, index]}#{text[(index + pattern.size)..]}"
34
34
  end
35
35
 
36
36
  # @param base [String] The base text content for the description
@@ -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
@@ -9,6 +9,7 @@ module Html2rss
9
9
  ##
10
10
  # Article is a simple data object representing an article extracted from a page.
11
11
  # It is enumerable and responds to all keys specified in PROVIDED_KEYS.
12
+ # rubocop:disable Metrics/ClassLength
12
13
  class Article
13
14
  include Enumerable
14
15
  include Comparable
@@ -17,6 +18,11 @@ module Html2rss
17
18
  PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
18
19
  # Separator used to build deterministic deduplication fingerprints.
19
20
  DEDUP_FINGERPRINT_SEPARATOR = '#!/'
21
+ # Sentinel object used to pre-initialize instance variables in the constructor.
22
+ # This ensures all Article instances share the exact same object shape (Ruby 3.3+ optimization),
23
+ # preventing performance warnings and slower instance variable access due to shape transitions
24
+ # when attributes are lazily/conditionally accessed in different sequences.
25
+ NOT_SET = Object.new.freeze
20
26
 
21
27
  # @param options [Hash{Symbol => String}]
22
28
  # @option options [String] :id stable article identifier
@@ -31,9 +37,9 @@ module Html2rss
31
37
  # @option options [Array<String>] :categories category labels
32
38
  # @option options [Class] :scraper scraper class that produced the article
33
39
  def initialize(**options)
34
- @to_h = {}
35
- options.each_pair { |key, value| @to_h[key] = value.freeze if value }
36
- @to_h.freeze
40
+ @to_h = options.each_with_object({}) { |(k, v), h| h[k] = v.freeze if v }.freeze
41
+
42
+ @description = @url = @image = @guid = @enclosures = @enclosure = @categories = @published_at = NOT_SET
37
43
 
38
44
  return unless (unknown_keys = options.keys - PROVIDED_KEYS).any?
39
45
 
@@ -62,7 +68,9 @@ module Html2rss
62
68
 
63
69
  # @return [String] rendered article description
64
70
  def description
65
- @description ||= Rendering::DescriptionBuilder.new(
71
+ return @description unless @description == NOT_SET
72
+
73
+ @description = Rendering::DescriptionBuilder.new(
66
74
  base: @to_h[:description],
67
75
  title:,
68
76
  url:,
@@ -73,12 +81,16 @@ module Html2rss
73
81
 
74
82
  # @return [Url, nil]
75
83
  def url
76
- @url ||= Url.sanitize(@to_h[:url])
84
+ return @url unless @url == NOT_SET
85
+
86
+ @url = Url.sanitize(@to_h[:url])
77
87
  end
78
88
 
79
89
  # @return [Url, nil]
80
90
  def image
81
- @image ||= Url.sanitize(@to_h[:image])
91
+ return @image unless @image == NOT_SET
92
+
93
+ @image = Url.sanitize(@to_h[:image])
82
94
  end
83
95
 
84
96
  # @return [String, nil]
@@ -87,7 +99,9 @@ module Html2rss
87
99
  # Generates a unique identifier based on the URL and ID using CRC32.
88
100
  # @return [String]
89
101
  def guid
90
- @guid ||= Zlib.crc32(fetch_guid).to_s(36).encode('utf-8')
102
+ return @guid unless @guid == NOT_SET
103
+
104
+ @guid = Zlib.crc32(fetch_guid).to_s(36).encode('utf-8')
91
105
  end
92
106
 
93
107
  ##
@@ -100,27 +114,32 @@ module Html2rss
100
114
 
101
115
  # @return [Array<Html2rss::RssBuilder::Enclosure>] normalized enclosure objects
102
116
  def enclosures
103
- @enclosures ||= Array(@to_h[:enclosures])
104
- .map { |enclosure| Html2rss::RssBuilder::Enclosure.new(**enclosure) }
117
+ return @enclosures unless @enclosures == NOT_SET
118
+
119
+ @enclosures = Array(@to_h[:enclosures])
120
+ .map { |enclosure| Html2rss::RssBuilder::Enclosure.new(**enclosure) }
105
121
  end
106
122
 
107
123
  # @return [Html2rss::RssBuilder::Enclosure, nil]
108
124
  def enclosure
109
- return @enclosure if defined?(@enclosure)
110
-
111
- case (object = @to_h[:enclosures]&.first)
112
- when Hash
113
- @enclosure = Html2rss::RssBuilder::Enclosure.new(**object)
114
- when nil
115
- @enclosure = Html2rss::RssBuilder::Enclosure.new(url: image) if image
116
- else
117
- Log.warn "Article: unknown enclosure type: #{object.class}"
118
- end
125
+ return @enclosure unless @enclosure == NOT_SET
126
+
127
+ @enclosure = case (object = @to_h[:enclosures]&.first)
128
+ when Hash
129
+ Html2rss::RssBuilder::Enclosure.new(**object)
130
+ when nil
131
+ Html2rss::RssBuilder::Enclosure.new(url: image) if image
132
+ else
133
+ Log.warn "Article: unknown enclosure type: #{object.class}"
134
+ nil
135
+ end
119
136
  end
120
137
 
121
138
  # @return [Array<String>] normalized, unique category names
122
139
  def categories
123
- @categories ||= @to_h[:categories].dup.to_a.tap do |categories|
140
+ return @categories unless @categories == NOT_SET
141
+
142
+ @categories = @to_h[:categories].dup.to_a.tap do |categories|
124
143
  categories.map! { |category| category.to_s.strip }
125
144
  categories.reject!(&:empty?)
126
145
  categories.uniq!
@@ -130,11 +149,12 @@ module Html2rss
130
149
  # Parses and returns the published_at time.
131
150
  # @return [DateTime, nil]
132
151
  def published_at
133
- return if (string = @to_h[:published_at].to_s.strip).empty?
152
+ return @published_at unless @published_at == NOT_SET
134
153
 
135
- @published_at ||= DateTime.parse(string)
154
+ string = @to_h[:published_at].to_s.strip
155
+ @published_at = string.empty? ? nil : DateTime.parse(string)
136
156
  rescue ArgumentError
137
- nil
157
+ @published_at = nil
138
158
  end
139
159
 
140
160
  # @return [Class, nil] scraper class that produced this article
@@ -183,5 +203,6 @@ module Html2rss
183
203
  value
184
204
  end
185
205
  end
206
+ # rubocop:enable Metrics/ClassLength
186
207
  end
187
208
  end
@@ -16,9 +16,11 @@ module Html2rss
16
16
  def self.guess_content_type_from_url(url, default: 'application/octet-stream')
17
17
  return default unless url
18
18
 
19
- url = url.path.split('?').first
19
+ path = url.path
20
+ ext = File.extname(path)
21
+ ext = ext[1..] if ext.start_with?('.')
20
22
 
21
- content_type = MIME::Types.type_for(File.extname(url).delete('.'))
23
+ content_type = MIME::Types.type_for(ext)
22
24
  content_type.first&.to_s || 'application/octet-stream'
23
25
  end
24
26
 
@@ -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.
@@ -91,7 +91,6 @@ module Html2rss
91
91
  end
92
92
 
93
93
  ##
94
- # Shorthand method to get the sanitized HTML.
95
94
  # @param html [String]
96
95
  # @param url [String, Html2rss::Url]
97
96
  # @return [String, nil]
@@ -102,11 +101,42 @@ module Html2rss
102
101
  new(html, context).get
103
102
  end
104
103
 
104
+ ##
105
+ # @param channel_url [String, Html2rss::Url]
106
+ # @return [Hash] the memoized sanitize configuration
107
+ # rubocop:disable Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
108
+ def self.sanitize_config(channel_url)
109
+ @sanitize_configs ||= {}
110
+ @sanitize_configs[channel_url] ||= begin
111
+ config = Sanitize::Config.merge(
112
+ Sanitize::Config::RELAXED,
113
+ attributes: { all: %w[dir lang alt title translate] },
114
+ add_attributes: TAG_ATTRIBUTES,
115
+ transformers: [
116
+ lambda { |env|
117
+ HtmlTransformers::TransformUrlsToAbsoluteOnes.new(channel_url).call(**env)
118
+ },
119
+ ->(env) { HtmlTransformers::WrapImgInA.new.call(**env) }
120
+ ]
121
+ )
122
+ config[:elements].push('audio', 'video', 'source')
123
+ config.freeze
124
+ end
125
+ end
126
+ # rubocop:enable Metrics/MethodLength, ThreadSafety/ClassInstanceVariable
127
+
105
128
  ##
106
129
  # @return [String, nil]
107
130
  def get
108
- sanitized_html = Sanitize.fragment(value, sanitize_config).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
109
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
+
110
140
  sanitized_html.strip!
111
141
  sanitized_html.empty? ? nil : sanitized_html
112
142
  end
@@ -114,40 +144,6 @@ module Html2rss
114
144
  private
115
145
 
116
146
  def channel_url = context.dig(:config, :channel, :url)
117
-
118
- ##
119
- # @return [Sanitize::Config]
120
- def sanitize_config # rubocop:disable Metrics/MethodLength
121
- config = Sanitize::Config.merge(
122
- Sanitize::Config::RELAXED,
123
- attributes: { all: %w[dir lang alt title translate] },
124
- add_attributes: TAG_ATTRIBUTES,
125
- transformers: [
126
- method(:transform_urls_to_absolute_ones),
127
- method(:wrap_img_in_a)
128
- ]
129
- )
130
- config[:elements].push('audio', 'video', 'source')
131
- config
132
- end
133
-
134
- ##
135
- # Wrapper for transform_urls_to_absolute_ones to pass the channel_url.
136
- #
137
- # @param env [Hash]
138
- # @return [nil]
139
- def transform_urls_to_absolute_ones(env)
140
- HtmlTransformers::TransformUrlsToAbsoluteOnes.new(channel_url).call(**env)
141
- end
142
-
143
- ##
144
- # Wrapper for wrap_img_in_a.
145
- #
146
- # @param env [Hash]
147
- # @return [nil]
148
- def wrap_img_in_a(env)
149
- HtmlTransformers::WrapImgInA.new.call(**env)
150
- end
151
147
  end
152
148
  end
153
149
  end
@@ -34,19 +34,17 @@ module Html2rss
34
34
  # @param context [Selectors::Context] post-processor context
35
35
  # @return [void]
36
36
  def self.validate_args!(value, context)
37
- assert_type value, String, :value, context:
37
+ assert_type(value, String, :value, context:)
38
38
 
39
39
  options = context[:options]
40
- assert_type options[:start], Integer, :start, context:
41
-
42
- end_index = options[:end]
43
- assert_type(end_index, Integer, :end, context:) if end_index
40
+ assert_type(options[:start], Integer, :start, context:)
41
+ assert_type(options[:end], Integer, :end, context:) if options.key?(:end)
44
42
  end
45
43
 
46
44
  ##
47
45
  # Extracts the substring from the original string based on the provided start and end indices.
48
46
  #
49
- # @return [String] The extracted substring.
47
+ # @return [String, nil] The extracted substring.
50
48
  def get
51
49
  value[range]
52
50
  end
@@ -56,21 +54,16 @@ module Html2rss
56
54
  #
57
55
  # @return [Range] The range object representing the start and end/Infinity (integers).
58
56
  def range
59
- return (start_index..) unless end_index?
57
+ options = context[:options]
58
+ start = options[:start]
60
59
 
61
- if start_index == end_index
62
- raise ArgumentError,
63
- 'The `start` value must be unequal to the `end` value.'
64
- end
60
+ return (start..) unless options.key?(:end)
65
61
 
66
- (start_index..end_index)
67
- end
62
+ finish = options[:end]
63
+ raise ArgumentError, 'The `start` value must be unequal to the `end` value.' if start == finish
68
64
 
69
- private
70
-
71
- def end_index? = !context[:options][:end].to_s.empty?
72
- def end_index = context[:options][:end].to_i
73
- def start_index = context[:options][:start].to_i
65
+ (start..finish)
66
+ end
74
67
  end
75
68
  end
76
69
  end