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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/html2rss/article.rb +42 -35
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +14 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/channel.rb +159 -79
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +20 -4
- data/lib/html2rss/feed_builder/json_feed.rb +22 -2
- data/lib/html2rss/feed_builder/rss.rb +19 -19
- data/lib/html2rss/feed_builder.rb +9 -17
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
- data/lib/html2rss/feed_pipeline.rb +63 -56
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -5
- data/lib/html2rss/request_service/botasaurus_strategy.rb +13 -4
- data/lib/html2rss/request_service/budget.rb +7 -2
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/strategy.rb +22 -0
- data/lib/html2rss/request_session.rb +4 -0
- data/lib/html2rss/selectors.rb +26 -4
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +15 -3
- metadata +5 -3
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
data/lib/html2rss/channel.rb
CHANGED
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
56
|
+
# Description template used when no explicit or discovered description exists.
|
|
57
|
+
def default_description_template = 'Latest items from %<url>s'
|
|
31
58
|
|
|
32
|
-
|
|
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
|
-
|
|
61
|
+
def title_for(response, overrides)
|
|
62
|
+
return overrides[:title] if overrides[:title]
|
|
37
63
|
|
|
38
|
-
|
|
64
|
+
title = parsed_title(response)
|
|
65
|
+
return title if title
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
url_for(response).channel_titleized
|
|
68
|
+
end
|
|
42
69
|
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
return language_code[0]
|
|
76
|
+
title.gsub(/\s+/, ' ').strip
|
|
63
77
|
end
|
|
64
78
|
|
|
65
|
-
|
|
79
|
+
def description_for(response, overrides)
|
|
80
|
+
return overrides[:description] unless overrides[:description].to_s.empty?
|
|
66
81
|
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
description = meta_description(response)
|
|
83
|
+
return format(default_description_template, url: url_for(response)) if description.to_s.empty?
|
|
69
84
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return overrides[:author] if overrides[:author]
|
|
85
|
+
description
|
|
86
|
+
end
|
|
73
87
|
|
|
74
|
-
|
|
88
|
+
def meta_description(response)
|
|
89
|
+
return unless html_response?(response)
|
|
75
90
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
107
|
+
def language_for(response, overrides)
|
|
108
|
+
return overrides[:language] if overrides[:language]
|
|
81
109
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
if (language_code = headers(response)['content-language']&.match(/^([a-z]{2})/))
|
|
111
|
+
return language_code[0]
|
|
112
|
+
end
|
|
85
113
|
|
|
86
|
-
|
|
114
|
+
return unless html_response?(response)
|
|
87
115
|
|
|
88
|
-
|
|
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
|
-
|
|
175
|
+
def marshal_dump = [title, url, description, language, ttl, last_build_date, image, author]
|
|
96
176
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
183
|
+
def normalize_image(value)
|
|
184
|
+
return value if value.is_a?(Url)
|
|
185
|
+
return if value.nil?
|
|
108
186
|
|
|
109
|
-
|
|
110
|
-
|
|
187
|
+
Url.sanitize(value.to_s)
|
|
188
|
+
end
|
|
111
189
|
|
|
112
|
-
|
|
113
|
-
return if
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
+
if (description = ItemPresentation.description_for(article))
|
|
58
|
+
return content_for(description)
|
|
59
|
+
end
|
|
57
60
|
|
|
58
|
-
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|