html2rss 0.23.0 → 0.24.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.
@@ -1,118 +1,198 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
5
+ # rubocop:disable Metrics/ModuleLength -- Channel value object owns extract + freeze + Marshal
3
6
  module Html2rss
4
7
  ##
5
- # Channel metadata for feed builders, extracted from the HTML document head
6
- # and HTTP response (with optional config overrides).
8
+ # Materialized channel metadata for feed builders and {FeedResult}.
9
+ #
10
+ # Extract once via {Channel.from_response}; builders consume this value object and
11
+ # do not own channel extraction. Safe to Marshal (no Response / Nokogiri retained).
12
+ # Strings and image URL are normalized and frozen at construction (including Marshal load).
7
13
  #
8
- # Format adapters ({FeedBuilder::Rss}, {FeedBuilder::JsonFeed}) consume this type; they do
9
- # not own channel extraction.
10
- class Channel
11
- # Fallback RSS ttl (in minutes) when no cache directives are present.
12
- DEFAULT_TTL_IN_MINUTES = 360
13
- # Description template used when no explicit or discovered description exists.
14
- DEFAULT_DESCRIPTION_TEMPLATE = 'Latest items from %<url>s'
14
+ # @!attribute [r] title
15
+ # @return [String]
16
+ # @!attribute [r] url
17
+ # @return [Html2rss::Url]
18
+ # @!attribute [r] description
19
+ # @return [String]
20
+ # @!attribute [r] language
21
+ # @return [String, nil]
22
+ # @!attribute [r] ttl
23
+ # @return [Integer]
24
+ # @!attribute [r] last_build_date
25
+ # @return [Time]
26
+ # @!attribute [r] image
27
+ # @return [Html2rss::Url, nil]
28
+ # @!attribute [r] author
29
+ # @return [String, nil]
30
+ Channel = Data.define(:title, :url, :description, :language, :ttl, :last_build_date, :image, :author) do
31
+ class << self
32
+ ##
33
+ # Materializes channel attributes from an HTTP response and optional overrides.
34
+ #
35
+ # @param response [Html2rss::RequestService::Response]
36
+ # @param overrides [Hash{Symbol => Object}] optional overrides for channel attributes
37
+ # @return [Html2rss::Channel]
38
+ def from_response(response, overrides: {})
39
+ new(
40
+ title: title_for(response, overrides),
41
+ url: url_for(response),
42
+ description: description_for(response, overrides),
43
+ language: language_for(response, overrides),
44
+ ttl: ttl_for(response, overrides),
45
+ last_build_date: last_build_date_for(response),
46
+ image: image_for(response, overrides),
47
+ author: author_for(response, overrides)
48
+ )
49
+ end
15
50
 
16
- ##
17
- # @param response [Html2rss::RequestService::Response]
18
- # @param overrides [Hash{Symbol => String}] optional overrides for channel attributes
19
- def initialize(response, overrides: {})
20
- @response = response
21
- @overrides = overrides
22
- end
51
+ private
23
52
 
24
- # @return [String] channel title derived from overrides, document title, or URL
25
- def title
26
- @title ||= fetch_title
27
- end
53
+ # Fallback RSS ttl (in minutes) when no cache directives are present.
54
+ def default_ttl_in_minutes = 360
28
55
 
29
- # @return [Html2rss::Url] canonical channel URL
30
- def url = @url ||= Html2rss::Url.from_absolute(@response.url)
56
+ # Description template used when no explicit or discovered description exists.
57
+ def default_description_template = 'Latest items from %<url>s'
31
58
 
32
- # @return [String] channel description text
33
- def description
34
- return overrides[:description] unless overrides[:description].to_s.empty?
59
+ def url_for(response) = Html2rss::Url.from_absolute(response.url)
35
60
 
36
- description = parsed_body.at_css('meta[name="description"]')&.[]('content') if html_response?
61
+ def title_for(response, overrides)
62
+ return overrides[:title] if overrides[:title]
37
63
 
38
- return format(DEFAULT_DESCRIPTION_TEMPLATE, url:) if description.to_s.empty?
64
+ title = parsed_title(response)
65
+ return title if title
39
66
 
40
- description
41
- end
67
+ url_for(response).channel_titleized
68
+ end
42
69
 
43
- # @return [Integer] cache time-to-live in minutes
44
- def ttl
45
- calculated = if overrides[:ttl]
46
- overrides[:ttl].to_i
47
- elsif (max_age = headers['cache-control']&.match(/max-age=(\d+)/)&.[](1))
48
- max_age.to_i.fdiv(60).ceil
49
- else
50
- DEFAULT_TTL_IN_MINUTES
51
- end
52
-
53
- min_ttl = Html2rss.defaults.min_ttl
54
- min_ttl ? [calculated, min_ttl].max : calculated
55
- end
70
+ def parsed_title(response)
71
+ return unless html_response?(response)
56
72
 
57
- # @return [String, nil] ISO-like language code when available
58
- def language
59
- return overrides[:language] if overrides[:language]
73
+ title = parsed_body(response).at_css('head > title')&.text.to_s
74
+ return if title.empty?
60
75
 
61
- if (language_code = headers['content-language']&.match(/^([a-z]{2})/))
62
- return language_code[0]
76
+ title.gsub(/\s+/, ' ').strip
63
77
  end
64
78
 
65
- return unless html_response?
79
+ def description_for(response, overrides)
80
+ return overrides[:description] unless overrides[:description].to_s.empty?
66
81
 
67
- parsed_body['lang'] || parsed_body.at_css('[lang]')&.[]('lang')
68
- end
82
+ description = meta_description(response)
83
+ return format(default_description_template, url: url_for(response)) if description.to_s.empty?
69
84
 
70
- # @return [String, nil] channel author metadata
71
- def author
72
- return overrides[:author] if overrides[:author]
85
+ description
86
+ end
73
87
 
74
- return unless html_response?
88
+ def meta_description(response)
89
+ return unless html_response?(response)
75
90
 
76
- parsed_body.at_css('meta[name="author"]')&.[]('content')
77
- end
91
+ parsed_body(response).at_css('meta[name="description"]')&.[]('content')
92
+ end
93
+
94
+ def ttl_for(response, overrides)
95
+ calculated = if overrides[:ttl]
96
+ overrides[:ttl].to_i
97
+ elsif (max_age = headers(response)['cache-control']&.match(/max-age=(\d+)/)&.[](1))
98
+ max_age.to_i.fdiv(60).ceil
99
+ else
100
+ default_ttl_in_minutes
101
+ end
102
+
103
+ min_ttl = Html2rss.defaults.min_ttl
104
+ min_ttl ? [calculated, min_ttl].max : calculated
105
+ end
78
106
 
79
- # @return [String, Time] source last-modified timestamp or current time fallback
80
- def last_build_date = headers['last-modified'] || Time.now
107
+ def language_for(response, overrides)
108
+ return overrides[:language] if overrides[:language]
81
109
 
82
- # @return [Html2rss::Url, nil] channel image URL
83
- def image
84
- return overrides[:image] if overrides[:image]
110
+ if (language_code = headers(response)['content-language']&.match(/^([a-z]{2})/))
111
+ return language_code[0]
112
+ end
85
113
 
86
- return unless html_response?
114
+ return unless html_response?(response)
87
115
 
88
- if (image_url = parsed_body.at_css('meta[property="og:image"]')&.[]('content'))
89
- Url.sanitize(image_url)
116
+ parsed_body(response)['lang'] || parsed_body(response).at_css('[lang]')&.[]('lang')
90
117
  end
118
+
119
+ def author_for(response, overrides)
120
+ return overrides[:author] if overrides[:author]
121
+ return unless html_response?(response)
122
+
123
+ parsed_body(response).at_css('meta[name="author"]')&.[]('content')
124
+ end
125
+
126
+ def last_build_date_for(response)
127
+ header = headers(response)['last-modified']
128
+ return Time.now unless header
129
+
130
+ Time.httpdate(header)
131
+ rescue ArgumentError
132
+ Time.now
133
+ end
134
+
135
+ def image_for(response, overrides)
136
+ return overrides[:image] if overrides[:image]
137
+ return unless html_response?(response)
138
+
139
+ if (image_url = parsed_body(response).at_css('meta[property="og:image"]')&.[]('content'))
140
+ Url.sanitize(image_url)
141
+ end
142
+ end
143
+
144
+ def parsed_body(response) = response.parsed_body
145
+ def headers(response) = response.headers
146
+ def html_response?(response) = response.html_response?
147
+ end
148
+
149
+ ##
150
+ # @param title [String]
151
+ # @param url [Html2rss::Url, String]
152
+ # @param description [String]
153
+ # @param language [String, nil]
154
+ # @param ttl [Integer]
155
+ # @param last_build_date [Time, String]
156
+ # @param image [Html2rss::Url, String, nil]
157
+ # @param author [String, nil]
158
+ # rubocop:disable Metrics/ParameterLists -- Data.define members are the channel contract
159
+ def initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
160
+ super(
161
+ title: freeze_string(title),
162
+ url: url.is_a?(Url) ? url : Url.from_absolute(url),
163
+ description: freeze_string(description),
164
+ language: language && freeze_string(language),
165
+ ttl: Integer(ttl),
166
+ last_build_date: normalize_last_build_date(last_build_date),
167
+ image: normalize_image(image),
168
+ author: author && freeze_string(author)
169
+ )
91
170
  end
171
+ # rubocop:enable Metrics/ParameterLists
92
172
 
93
173
  private
94
174
 
95
- attr_reader :overrides
175
+ def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author]
96
176
 
97
- def parsed_body = @parsed_body ||= @response.parsed_body
98
- def headers = @headers ||= @response.headers
99
- def html_response? = @html_response ||= @response.html_response?
177
+ def marshal_load((title, url, description, language, ttl, last_build_date, image, author))
178
+ initialize(title:, url:, description:, language:, ttl:, last_build_date:, image:, author:)
179
+ end
100
180
 
101
- def fetch_title
102
- override_title = overrides[:title]
103
- return override_title if override_title
104
- return parsed_title if parsed_title
181
+ def freeze_string(value) = value.to_s.dup.freeze
105
182
 
106
- url.channel_titleized
107
- end
183
+ def normalize_image(value)
184
+ return value if value.is_a?(Url)
185
+ return if value.nil?
108
186
 
109
- def parsed_title
110
- return unless html_response?
187
+ Url.sanitize(value.to_s)
188
+ end
111
189
 
112
- title = parsed_body.at_css('head > title')&.text.to_s
113
- return if title.empty?
190
+ def normalize_last_build_date(value)
191
+ return value if value.is_a?(Time)
192
+ raise ArgumentError, 'last_build_date must be a Time or HTTP-date String' unless value.is_a?(String)
114
193
 
115
- title.gsub(/\s+/, ' ').strip
194
+ Time.httpdate(value)
116
195
  end
117
196
  end
197
+ # rubocop:enable Metrics/ModuleLength
118
198
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module FeedBuilder
5
+ ##
6
+ # Presentation rules for rendering an {Html2rss::Article} into feed wire formats.
7
+ #
8
+ # Owns description enrichment ({Html::Rendering::DescriptionBuilder}) and RSS
9
+ # non-image enclosure selection. {Article} keeps raw extracted fields only.
10
+ module ItemPresentation
11
+ class << self
12
+ ##
13
+ # @param article [Html2rss::Article]
14
+ # @return [String, nil] sanitized description HTML/text for feed item bodies
15
+ def description_for(article)
16
+ Html::Rendering::DescriptionBuilder.new(
17
+ base: article.description,
18
+ title: article.title,
19
+ url: article.url,
20
+ enclosures: article.enclosures,
21
+ image: article.image
22
+ ).call
23
+ end
24
+
25
+ ##
26
+ # First non-image enclosure for RSS +enclosure+ (images stay on description / JSON Feed).
27
+ #
28
+ # @param article [Html2rss::Article]
29
+ # @return [Html2rss::Article::Enclosure, nil]
30
+ def rss_enclosure_for(article)
31
+ article.enclosures.find { |enc| !image_mime_type?(enc.type) }
32
+ end
33
+
34
+ private
35
+
36
+ def image_mime_type?(type)
37
+ type.to_s.downcase.start_with?('image/')
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -50,17 +50,33 @@ module Html2rss
50
50
 
51
51
  ##
52
52
  # JSON Feed items must include content_html or content_text.
53
+ # Title is a fallback only when description is absent.
54
+ #
53
55
  # @return [Hash]
54
56
  def content_fields
55
- description = article.description
56
- return { content_html: description } if description
57
+ if (description = ItemPresentation.description_for(article))
58
+ return content_for(description)
59
+ end
57
60
 
58
- title = article.title
59
- return { content_text: title } if title
61
+ return { content_text: article.title } if article.title
60
62
 
61
63
  {}
62
64
  end
63
65
 
66
+ ##
67
+ # @param description [String]
68
+ # @return [Hash]
69
+ def content_for(description)
70
+ html?(description) ? { content_html: description } : { content_text: description }
71
+ end
72
+
73
+ ##
74
+ # @param value [String]
75
+ # @return [Boolean]
76
+ def html?(value)
77
+ Nokogiri::HTML.fragment(value).children.any?(&:element?)
78
+ end
79
+
64
80
  ##
65
81
  # @return [Array<String>, nil]
66
82
  def tags
@@ -13,9 +13,15 @@ module Html2rss
13
13
  ##
14
14
  # @param channel [Html2rss::Channel]
15
15
  # @param articles [Array<Html2rss::Article>]
16
- def initialize(channel:, articles:)
16
+ # @param user_comment [String] required generator comment (from {Status})
17
+ # @param feed_url [String, nil] optional absolute self URL for the feed (JSON Feed feed_url)
18
+ def initialize(channel:, articles:, user_comment:, feed_url: nil)
19
+ raise ArgumentError, 'user_comment must be a non-blank String' if blank_string?(user_comment)
20
+
17
21
  @channel = channel
18
22
  @articles = articles
23
+ @feed_url = normalize_feed_url(feed_url)
24
+ @user_comment = user_comment
19
25
  end
20
26
 
21
27
  ##
@@ -28,7 +34,19 @@ module Html2rss
28
34
 
29
35
  private
30
36
 
31
- attr_reader :channel, :articles
37
+ attr_reader :channel, :articles, :feed_url, :user_comment
38
+
39
+ def blank_string?(value)
40
+ !value.is_a?(String) || value.strip.empty?
41
+ end
42
+
43
+ def normalize_feed_url(value)
44
+ return if value.nil?
45
+ raise ArgumentError, 'feed_url must be a String or nil' unless value.is_a?(String)
46
+ return if value.strip.empty?
47
+
48
+ Url.from_absolute(value).to_s
49
+ end
32
50
 
33
51
  ##
34
52
  # @return [Hash]
@@ -37,7 +55,9 @@ module Html2rss
37
55
  version: VERSION_URL,
38
56
  title: channel.title,
39
57
  home_page_url: channel.url.to_s,
58
+ feed_url:,
40
59
  description: channel.description,
60
+ user_comment:,
41
61
  language: channel.language,
42
62
  icon: channel.image&.to_s
43
63
  }
@@ -14,7 +14,7 @@ module Html2rss
14
14
  def add_item(article, item_maker)
15
15
  add_item_string_values(article, item_maker)
16
16
  add_item_categories(article, item_maker)
17
- add_enclosure(article.enclosure, item_maker)
17
+ add_enclosure(ItemPresentation.rss_enclosure_for(article), item_maker)
18
18
  add_item_guid(article, item_maker)
19
19
  end
20
20
 
@@ -34,11 +34,14 @@ module Html2rss
34
34
  private
35
35
 
36
36
  def add_item_string_values(article, item_maker)
37
- %i[title description author].each do |attr|
38
- next unless (value = article.send(attr))
39
- next if value.empty?
40
-
41
- item_maker.send(:"#{attr}=", value)
37
+ {
38
+ title: article.title,
39
+ description: ItemPresentation.description_for(article),
40
+ author: article.author
41
+ }.each do |attr, value|
42
+ next if value.nil? || value.empty?
43
+
44
+ item_maker.public_send(:"#{attr}=", value)
42
45
  end
43
46
 
44
47
  item_maker.link = article.url.to_s if article.url
@@ -61,10 +64,14 @@ module Html2rss
61
64
  # @param channel [Html2rss::Channel] The channel information for the RSS feed.
62
65
  # @param articles [Array<Html2rss::Article>] The list of articles to include in the RSS feed.
63
66
  # @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
64
- def initialize(channel:, articles:, stylesheets: [])
67
+ # @param generator [String] required preformatted generator comment (from {Status})
68
+ def initialize(channel:, articles:, generator:, stylesheets: [])
69
+ raise ArgumentError, 'generator must be a non-blank String' if blank_string?(generator)
70
+
65
71
  @channel = channel
66
72
  @articles = articles
67
73
  @stylesheets = stylesheets
74
+ @generator = generator
68
75
  end
69
76
 
70
77
  # @return [RSS::Rss] RSS 2.0 document instance
@@ -79,7 +86,11 @@ module Html2rss
79
86
 
80
87
  private
81
88
 
82
- attr_reader :channel, :articles
89
+ attr_reader :channel, :articles, :generator
90
+
91
+ def blank_string?(value)
92
+ !value.is_a?(String) || value.strip.empty?
93
+ end
83
94
 
84
95
  def stylesheets
85
96
  @stylesheets.map { |style| Stylesheet.new(**style) }
@@ -100,17 +111,6 @@ module Html2rss
100
111
  maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
101
112
  end
102
113
  end
103
-
104
- def generator
105
- scraper_namespace_regex = /(?<namespace>Html2rss|Scraper)::/
106
-
107
- scraper_counts = articles.flat_map(&:scraper).tally.map do |klass, count|
108
- scraper_name = klass.to_s.gsub(scraper_namespace_regex, '')
109
- "#{scraper_name} (#{count})"
110
- end
111
-
112
- "html2rss V. #{Html2rss::VERSION} (scrapers: #{scraper_counts.join(', ')})"
113
- end
114
114
  end
115
115
  end
116
116
  end
@@ -1,23 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Html2rss
4
- # Entrypoint and namespace for feed building and formatting (RSS 2.0 / JSON Feed 1.1).
4
+ # Namespace for feed format adapters (RSS 2.0 / JSON Feed 1.1).
5
+ #
6
+ # {FeedResult} constructs {Rss} / {JsonFeed} directly — there is no dispatcher
7
+ # entrypoint on this module. Stylesheets are a scrape-time artifact carried on
8
+ # FeedResult; +feed_url+ is a render-time JSON Feed-only option.
9
+ #
10
+ # Channel field projection:
11
+ # - Rss: language, title, description, ttl, link, updated
12
+ # - JsonFeed: title, home_page_url, description, language, icon, authors (+ feed_url)
5
13
  module FeedBuilder
6
- # Builds the requested feed type from the channel and article list.
7
- #
8
- # @param type [Symbol] :rss or :json_feed
9
- # @param channel [Html2rss::Channel]
10
- # @param articles [Array<Html2rss::Article>]
11
- # @return [RSS::Rss, Hash] format-compliant representation of the feed
12
- def self.build(type, channel:, articles:, **)
13
- case type
14
- when :rss
15
- Rss.new(channel:, articles:, **).call
16
- when :json_feed
17
- JsonFeed.new(channel:, articles:).call
18
- else
19
- raise ArgumentError, "Unknown feed type: #{type}"
20
- end
21
- end
22
14
  end
23
15
  end