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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/html2rss/article.rb +42 -35
- data/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +0 -379
- 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/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper.rb +81 -44
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +101 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +66 -0
- data/lib/html2rss/auto_source.rb +74 -21
- data/lib/html2rss/channel.rb +159 -79
- data/lib/html2rss/cli.rb +8 -13
- data/lib/html2rss/config/auto_source_contract.rb +2 -0
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/validator.rb +15 -8
- data/lib/html2rss/config.rb +6 -2
- 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 +36 -24
- data/lib/html2rss/feed_builder.rb +9 -17
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +71 -28
- data/lib/html2rss/feed_pipeline.rb +66 -55
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +279 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +79 -0
- data/lib/html2rss/link_destination/path_classifier.rb +205 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -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/faraday_strategy.rb +42 -1
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- 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/scoring/anchor_score.rb +34 -0
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +83 -0
- data/lib/html2rss/scoring/engine.rb +101 -0
- data/lib/html2rss/scoring/link_resolver.rb +72 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +45 -0
- data/lib/html2rss/scoring/score.rb +30 -0
- data/lib/html2rss/scoring.rb +33 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
- data/lib/html2rss/selectors.rb +26 -24
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +41 -29
- data/schema/html2rss-config.schema.json +20 -0
- metadata +45 -19
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- 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
|
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.
|
|
191
|
-
strategy: options[:strategy]
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
103
|
+
channel_maker.public_send(:"#{key}=", channel.public_send(key))
|
|
91
104
|
end
|
|
92
105
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|