html2rss 0.23.0 → 0.25.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article.rb +42 -35
  4. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  5. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  6. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  9. data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
  10. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  12. data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
  13. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  14. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  16. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  17. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  18. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  19. data/lib/html2rss/auto_source/scraper.rb +81 -44
  20. data/lib/html2rss/auto_source/segment.rb +29 -0
  21. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  22. data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
  23. data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
  24. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  25. data/lib/html2rss/auto_source/segmenter.rb +66 -0
  26. data/lib/html2rss/auto_source.rb +74 -21
  27. data/lib/html2rss/channel.rb +159 -79
  28. data/lib/html2rss/cli.rb +8 -13
  29. data/lib/html2rss/config/auto_source_contract.rb +2 -0
  30. data/lib/html2rss/config/request_controls.rb +33 -0
  31. data/lib/html2rss/config/validator.rb +15 -8
  32. data/lib/html2rss/config.rb +6 -2
  33. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  34. data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
  35. data/lib/html2rss/feed_builder/json_feed.rb +22 -2
  36. data/lib/html2rss/feed_builder/rss.rb +36 -24
  37. data/lib/html2rss/feed_builder.rb +9 -17
  38. data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
  39. data/lib/html2rss/feed_pipeline.rb +66 -55
  40. data/lib/html2rss/feed_result.rb +92 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +279 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +79 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +205 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
  62. data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
  63. data/lib/html2rss/request_service/budget.rb +7 -2
  64. data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
  65. data/lib/html2rss/request_service/network_guard.rb +5 -3
  66. data/lib/html2rss/request_service/response.rb +9 -1
  67. data/lib/html2rss/request_service/strategy.rb +22 -0
  68. data/lib/html2rss/request_session.rb +4 -0
  69. data/lib/html2rss/scoring/anchor_score.rb +34 -0
  70. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  71. data/lib/html2rss/scoring/container_assessor.rb +83 -0
  72. data/lib/html2rss/scoring/engine.rb +101 -0
  73. data/lib/html2rss/scoring/link_resolver.rb +72 -0
  74. data/lib/html2rss/scoring/observation.rb +53 -0
  75. data/lib/html2rss/scoring/ranked_segment.rb +45 -0
  76. data/lib/html2rss/scoring/score.rb +30 -0
  77. data/lib/html2rss/scoring.rb +33 -0
  78. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
  79. data/lib/html2rss/selectors.rb +26 -24
  80. data/lib/html2rss/sst/attrs.rb +91 -0
  81. data/lib/html2rss/sst/document.rb +23 -0
  82. data/lib/html2rss/sst/index.rb +112 -0
  83. data/lib/html2rss/sst/node.rb +147 -0
  84. data/lib/html2rss/sst/normalizer.rb +171 -0
  85. data/lib/html2rss/sst/tags.rb +26 -0
  86. data/lib/html2rss/sst/text.rb +81 -0
  87. data/lib/html2rss/sst.rb +8 -0
  88. data/lib/html2rss/status.rb +132 -0
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +41 -29
  91. data/schema/html2rss-config.schema.json +20 -0
  92. metadata +45 -19
  93. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  94. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  95. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  96. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  97. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  98. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  99. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  100. data/lib/html2rss/auto_source/discovery.rb +0 -14
  101. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  102. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  103. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  104. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  105. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  106. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  107. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  108. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  109. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
@@ -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
data/lib/html2rss/cli.rb CHANGED
@@ -79,6 +79,9 @@ module Html2rss
79
79
  method_option :max_requests,
80
80
  type: :numeric,
81
81
  desc: 'Maximum requests to allow for this feed build'
82
+ method_option :limit,
83
+ type: :numeric,
84
+ desc: 'Maximum number of articles to keep (default: 25)'
82
85
  method_option :input,
83
86
  type: :string,
84
87
  desc: 'Local HTML file path to read input from'
@@ -187,22 +190,13 @@ module Html2rss
187
190
  end
188
191
 
189
192
  def request_controls
190
- Html2rss::Config::RequestControls.new(
191
- strategy: options[:strategy]&.to_sym,
193
+ Html2rss::Config::RequestControls.from_cli_options(
194
+ strategy: options[:strategy],
192
195
  max_redirects: options[:max_redirects],
193
- max_requests: options[:max_requests],
194
- explicit_keys: explicit_request_control_keys
196
+ max_requests: options[:max_requests]
195
197
  )
196
198
  end
197
199
 
198
- def explicit_request_control_keys
199
- keys = []
200
- keys << :strategy if options[:strategy]
201
- keys << :max_redirects unless options[:max_redirects].nil?
202
- keys << :max_requests unless options[:max_requests].nil?
203
- keys
204
- end
205
-
206
200
  def current_strategy
207
201
  options[:strategy]&.to_sym || :auto
208
202
  end
@@ -249,7 +243,8 @@ module Html2rss
249
243
  items_selector: options[:items_selector],
250
244
  max_redirects: options[:max_redirects],
251
245
  max_requests: options[:max_requests],
252
- local_file_path:
246
+ local_file_path:,
247
+ limit: options[:limit]&.to_i
253
248
  )
254
249
  end
255
250
 
@@ -6,6 +6,8 @@ module Html2rss
6
6
  class Config
7
7
  # Runtime source of truth for validating auto-source config values.
8
8
  AutoSourceContract = Dry::Schema.Params do # rubocop:disable Metrics/BlockLength
9
+ optional(:limit).filled(:integer, gt?: 0)
10
+
9
11
  optional(:scraper).hash do # rubocop:disable Metrics/BlockLength
10
12
  optional(:wordpress_api).hash do
11
13
  optional(:enabled).filled(:bool)
@@ -43,6 +43,39 @@ module Html2rss
43
43
  request_config.is_a?(Hash) && request_config.key?(key)
44
44
  end
45
45
 
46
+ ##
47
+ # Builds controls for public API shortcuts ({Html2rss.auto_source}, etc.).
48
+ # Strategy is explicit only when non-nil and not the configured default.
49
+ #
50
+ # @param strategy [Symbol, nil]
51
+ # @param max_redirects [Integer, nil]
52
+ # @param max_requests [Integer, nil]
53
+ # @return [RequestControls]
54
+ def self.from_shortcut(strategy:, max_redirects: nil, max_requests: nil)
55
+ keys = []
56
+ keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
57
+ keys << :max_redirects unless max_redirects.nil?
58
+ keys << :max_requests unless max_requests.nil?
59
+ new(strategy:, max_redirects:, max_requests:, explicit_keys: keys)
60
+ end
61
+
62
+ ##
63
+ # Builds controls from CLI option values. Strategy is explicit when the
64
+ # option was provided (truthy string/symbol), matching Thor option presence.
65
+ #
66
+ # @param strategy [Symbol, String, nil]
67
+ # @param max_redirects [Integer, nil]
68
+ # @param max_requests [Integer, nil]
69
+ # @return [RequestControls]
70
+ def self.from_cli_options(strategy:, max_redirects: nil, max_requests: nil)
71
+ strategy_sym = strategy&.to_sym
72
+ keys = []
73
+ keys << :strategy if strategy
74
+ keys << :max_redirects unless max_redirects.nil?
75
+ keys << :max_requests unless max_requests.nil?
76
+ new(strategy: strategy_sym, max_redirects:, max_requests:, explicit_keys: keys)
77
+ end
78
+
46
79
  private_class_method :explicit_keys_for, :request_value_for, :request_key?
47
80
 
48
81
  ##
@@ -24,6 +24,8 @@ module Html2rss
24
24
  optional(:language).maybe(:string, format?: LANGUAGE_FORMAT_REGEX)
25
25
  optional(:ttl).maybe(:integer, gt?: 0)
26
26
  optional(:time_zone).maybe(:string)
27
+ optional(:author).maybe(:string)
28
+ optional(:image).maybe(:string, format?: URI_REGEXP)
27
29
  end
28
30
 
29
31
  # Contract for a stylesheet entry in `stylesheets`.
@@ -132,15 +134,20 @@ module Html2rss
132
134
 
133
135
  # URL validation delegated to Url class
134
136
  rule(:channel) do
135
- next unless values[:channel]&.key?(:url)
136
-
137
- url_string = values[:channel][:url]
138
- next if url_string.nil? || url_string.empty?
137
+ if (url_string = values.dig(:channel, :url)) && !url_string.empty?
138
+ begin
139
+ Html2rss::Url.for_channel(url_string)
140
+ rescue ArgumentError => error
141
+ key(%i[channel url]).failure(error.message)
142
+ end
143
+ end
139
144
 
140
- begin
141
- Html2rss::Url.for_channel(url_string)
142
- rescue ArgumentError => error
143
- key(%i[channel url]).failure(error.message)
145
+ if (image_string = values.dig(:channel, :image)) && !image_string.empty?
146
+ begin
147
+ Html2rss::Url.from_absolute(image_string)
148
+ rescue ArgumentError => error
149
+ key(%i[channel image]).failure(error.message)
150
+ end
144
151
  end
145
152
  end
146
153
  end
@@ -120,11 +120,15 @@ module Html2rss
120
120
  # @param url [String] source page URL
121
121
  # @param items_selector [String, nil] optional selector hint for item extraction
122
122
  # @param request_controls [Html2rss::Config::RequestControls, nil] explicit request controls to write
123
+ # @param limit [Integer, nil] max articles to keep in the auto-sourced feed
123
124
  # @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
124
- def auto_source_config(url:, items_selector: nil, request_controls: nil)
125
+ def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil)
126
+ auto_source = AutoSource::DEFAULT_CONFIG
127
+ auto_source = auto_source.merge(limit:) unless limit.nil?
128
+
125
129
  config = {
126
130
  channel: default_config[:channel].merge(url:),
127
- auto_source: AutoSource::DEFAULT_CONFIG
131
+ auto_source:
128
132
  }
129
133
 
130
134
  request_controls ||= RequestControls.new
@@ -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
@@ -72,27 +79,43 @@ module Html2rss
72
79
  RSS::Maker.make('2.0') do |maker|
73
80
  Stylesheet.add(maker, stylesheets)
74
81
 
75
- make_channel(maker.channel)
82
+ make_channel(maker)
76
83
  make_items(maker)
77
84
  end
78
85
  end
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) }
86
97
  end
87
98
 
99
+ # rubocop:disable Metrics/AbcSize
88
100
  def make_channel(maker)
101
+ channel_maker = maker.channel
89
102
  %i[language title description ttl].each do |key|
90
- maker.public_send(:"#{key}=", channel.public_send(key))
103
+ channel_maker.public_send(:"#{key}=", channel.public_send(key))
91
104
  end
92
105
 
93
- maker.link = channel.url.to_s
94
- maker.generator = generator
95
- maker.updated = channel.last_build_date
106
+ channel_maker.managingEditor = channel.author if channel.author
107
+ channel_maker.author = channel.author if channel.author
108
+ channel_maker.link = channel.url.to_s
109
+ channel_maker.generator = generator
110
+ channel_maker.updated = channel.last_build_date
111
+
112
+ make_image(maker.image) if channel.image
113
+ end
114
+ # rubocop:enable Metrics/AbcSize
115
+
116
+ def make_image(image_maker)
117
+ image_maker.url = channel.image.to_s
118
+ image_maker.title = channel.title.to_s
96
119
  end
97
120
 
98
121
  def make_items(maker)
@@ -100,17 +123,6 @@ module Html2rss
100
123
  maker.items.new_item { |item_maker| self.class.add_item(article, item_maker) }
101
124
  end
102
125
  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
126
  end
115
127
  end
116
128
  end