simple-rss 2.2.0 → 2.3.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/CHANGELOG.md +174 -0
- data/README.md +492 -8
- data/Rakefile +1 -1
- data/examples/digest.rb +19 -0
- data/examples/discover.rb +14 -0
- data/examples/feedbag.rb +10 -0
- data/lib/simple-rss/discovery.rb +107 -0
- data/lib/simple-rss/entry_normalizer.rb +356 -0
- data/lib/simple-rss/http_client.rb +216 -0
- data/lib/simple-rss/json_entry_normalizer.rb +147 -0
- data/lib/simple-rss/json_feed.rb +176 -0
- data/lib/simple-rss/normalized_entry.rb +79 -0
- data/lib/simple-rss/request_errors.rb +21 -0
- data/lib/simple-rss/request_policy.rb +72 -0
- data/lib/simple-rss/xml_element.rb +136 -0
- data/lib/simple-rss.rb +225 -77
- data/simple-rss.gemspec +5 -5
- data/test/base/category_parsing_test.rb +217 -0
- data/test/base/date_ordering_test.rb +95 -0
- data/test/base/discovery_dependency_test.rb +29 -0
- data/test/base/discovery_test.rb +151 -0
- data/test/base/discovery_transport_test.rb +413 -0
- data/test/base/enumerable_test.rb +16 -0
- data/test/base/feedbag_integration_test.rb +21 -0
- data/test/base/json_feed_test.rb +372 -0
- data/test/base/normalized_entries_test.rb +410 -0
- data/test/base/normalized_fetch_test.rb +132 -0
- data/test/base/relation_links_test.rb +162 -0
- data/test/data/atom_categories.xml +23 -0
- data/test/data/atom_nested_link.xml +13 -0
- data/test/data/discovery.html +28 -0
- data/test/data/json_feed_1.json +75 -0
- data/test/data/json_feed_1_1.json +78 -0
- data/test/data/mixed_dates.xml +55 -0
- data/test/data/normalized_atom.xml +22 -0
- data/test/data/normalized_rss.xml +21 -0
- data/test/data/rss_categories.xml +15 -0
- data/test/support/http_server.rb +44 -0
- data/test/support/replace_method.rb +14 -0
- metadata +42 -10
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require_relative "http_client"
|
|
4
|
+
|
|
5
|
+
class SimpleRSS::Discovery
|
|
6
|
+
MEDIA_TYPES = {
|
|
7
|
+
"application/rss+xml" => :rss, "application/rdf+xml" => :rss,
|
|
8
|
+
"application/atom+xml" => :atom, "application/feed+json" => :json_feed, "application/json" => :json_feed
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
# @rbs @options: Hash[Symbol, untyped]
|
|
12
|
+
# @rbs @parser: untyped
|
|
13
|
+
|
|
14
|
+
# @rbs (Hash[Symbol, untyped]) -> void
|
|
15
|
+
def initialize(options)
|
|
16
|
+
@options = { network_policy: :public }.merge(options)
|
|
17
|
+
require "nokogiri"
|
|
18
|
+
@parser = Object.const_get(:Nokogiri)
|
|
19
|
+
unless @parser.const_defined?(:HTML5)
|
|
20
|
+
raise SimpleRSS::DiscoveryDependencyError, "Discovery requires Nokogiri HTML5 support (available on CRuby)"
|
|
21
|
+
end
|
|
22
|
+
rescue LoadError
|
|
23
|
+
raise SimpleRSS::DiscoveryDependencyError, 'Install the optional "nokogiri" gem (>= 1.16, < 2) to use SimpleRSS.discover'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @rbs (String) -> Array[Hash[Symbol, untyped]]
|
|
27
|
+
def discover(url)
|
|
28
|
+
raise SimpleRSS::PolicyError, "Expected a website URL string" unless url.is_a?(String)
|
|
29
|
+
|
|
30
|
+
url = "https:#{url}" if url.start_with?("//")
|
|
31
|
+
url = "https://#{url}" unless url.match?(/\A[a-z][a-z\d+.-]*:/i)
|
|
32
|
+
response, uri = SimpleRSS::HTTPClient.new(@options).get(url)
|
|
33
|
+
raise SimpleRSS::HTTPError, response.code.to_i unless response.is_a?(Net::HTTPSuccess)
|
|
34
|
+
|
|
35
|
+
candidates(response.body.to_s, uri, response.content_type, response.type_params["charset"])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @rbs (String, untyped, String?, String?) -> Array[Hash[Symbol, untyped]]
|
|
41
|
+
def candidates(body, uri, media_type, encoding)
|
|
42
|
+
prefix = body.b.sub(/\A\xEF\xBB\xBF/n, "").lstrip
|
|
43
|
+
return [feed_candidate(body, uri, :json_feed)] if prefix.start_with?("{", "[")
|
|
44
|
+
|
|
45
|
+
document = @parser::XML.parse(body, uri.to_s, nil, @parser::XML::ParseOptions::NONET | @parser::XML::ParseOptions::RECOVER)
|
|
46
|
+
root = document.root
|
|
47
|
+
format = root && xml_format(root)
|
|
48
|
+
return [feed_candidate(body, uri, format)] if format
|
|
49
|
+
|
|
50
|
+
unless root&.name&.casecmp?("html") || %w[text/html application/xhtml+xml].include?(media_type) || prefix.match?(/\A(?:<!doctype\s+html|<html\b|<head\b)/i)
|
|
51
|
+
raise SimpleRSS::DiscoveryError, "Response is not a recognized feed or HTML page"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
html_candidates(body, uri, encoding)
|
|
55
|
+
rescue SimpleRSS::RequestError, SimpleRSS::DiscoveryError
|
|
56
|
+
raise
|
|
57
|
+
rescue SimpleRSSError, ArgumentError, EncodingError => e
|
|
58
|
+
raise SimpleRSS::DiscoveryError, "Cannot parse discovery response: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @rbs (untyped) -> Symbol?
|
|
62
|
+
def xml_format(root)
|
|
63
|
+
return :rss if root.name == "rss" && root.namespace.nil?
|
|
64
|
+
return :atom if root.name == "feed" && [SimpleRSS::ATOM_NAMESPACE, "http://purl.org/atom/ns#"].include?(root.namespace&.href)
|
|
65
|
+
return :rss if root.name == "RDF" && root.namespace&.href == "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @rbs (String, untyped, Symbol) -> Hash[Symbol, untyped]
|
|
71
|
+
def feed_candidate(body, uri, format)
|
|
72
|
+
feed = SimpleRSS.parse(body, source_url: uri.to_s)
|
|
73
|
+
title = feed.instance_variable_get(:@title)
|
|
74
|
+
{ url: uri.to_s, title: title, format: format, media_type: MEDIA_TYPES.key(format), source: :document, verified: true }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @rbs (String, untyped, String?) -> Array[Hash[Symbol, untyped]]
|
|
78
|
+
def html_candidates(body, uri, encoding)
|
|
79
|
+
document = @parser::HTML5.parse(body, uri.to_s, encoding, max_tree_depth: 128, max_attributes: 128)
|
|
80
|
+
head = document.at_css("html > head")
|
|
81
|
+
return [] unless head
|
|
82
|
+
|
|
83
|
+
base = resolve_url(head.at_xpath("./base[@href]")&.[]("href"), uri) || uri
|
|
84
|
+
candidates = head.xpath("./link[@rel][@type][@href]").filter_map do |link|
|
|
85
|
+
next unless link["rel"].downcase.split.include?("alternate")
|
|
86
|
+
|
|
87
|
+
media_type = link["type"].split(";", 2).first.to_s.strip.downcase
|
|
88
|
+
format = MEDIA_TYPES[media_type]
|
|
89
|
+
next unless format
|
|
90
|
+
|
|
91
|
+
target = resolve_url(link["href"], base)
|
|
92
|
+
next unless target
|
|
93
|
+
|
|
94
|
+
{ url: target.to_s, title: link["title"], format: format, media_type: media_type, source: :html_link, verified: false }
|
|
95
|
+
end
|
|
96
|
+
candidates.uniq { |candidate| candidate[:url] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @rbs (String?, untyped) -> untyped
|
|
100
|
+
def resolve_url(value, base)
|
|
101
|
+
return if value.nil? || value.strip.empty?
|
|
102
|
+
|
|
103
|
+
SimpleRSS::RequestPolicy.parse_url(URI.join(base.to_s, value.strip).to_s)
|
|
104
|
+
rescue URI::Error, SimpleRSS::PolicyError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
class SimpleRSS::EntryNormalizer
|
|
6
|
+
ATOM = SimpleRSS::ATOM_NAMESPACE
|
|
7
|
+
CONTENT = "http://purl.org/rss/1.0/modules/content/".freeze
|
|
8
|
+
DUBLIN_CORE = "http://purl.org/dc/elements/1.1/".freeze
|
|
9
|
+
MEDIA = "http://search.yahoo.com/mrss/".freeze
|
|
10
|
+
ITUNES = "http://www.itunes.com/dtds/podcast-1.0.dtd".freeze
|
|
11
|
+
XHTML = "http://www.w3.org/1999/xhtml".freeze
|
|
12
|
+
|
|
13
|
+
# @rbs (Hash[Symbol, untyped]) -> void
|
|
14
|
+
def self.validate_mappings(mappings)
|
|
15
|
+
allowed = %i[content_html content_text categories]
|
|
16
|
+
raise ArgumentError, "mappings must be a Hash with keys #{allowed.join(", ")}" unless mappings.is_a?(Hash) && (mappings.keys - allowed).empty?
|
|
17
|
+
|
|
18
|
+
%i[content_html content_text].each do |field|
|
|
19
|
+
next unless mappings.key?(field)
|
|
20
|
+
raise ArgumentError, "#{field} mapping must be a nonempty XML tag name" unless mappings[field].is_a?(String) && !mappings[field].strip.empty?
|
|
21
|
+
end
|
|
22
|
+
return unless mappings.key?(:categories)
|
|
23
|
+
|
|
24
|
+
categories = mappings[:categories]
|
|
25
|
+
raise ArgumentError, "categories mapping must be an array of tag/separator records" unless categories.is_a?(Array)
|
|
26
|
+
|
|
27
|
+
categories.each do |mapping|
|
|
28
|
+
valid = mapping.is_a?(Hash) && (mapping.keys - %i[tag separator]).empty? && mapping[:tag].is_a?(String) && !mapping[:tag].strip.empty?
|
|
29
|
+
valid &&= !mapping.key?(:separator) || (mapping[:separator].is_a?(String) && !mapping[:separator].empty?)
|
|
30
|
+
raise ArgumentError, "category mappings require a tag and an optional nonempty separator" unless valid
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @rbs (SimpleRSS::XmlElement, Hash[Symbol, untyped], Hash[Symbol, untyped]) -> void
|
|
35
|
+
def initialize(element, raw, options)
|
|
36
|
+
@element = element
|
|
37
|
+
@children = element.children
|
|
38
|
+
@source_url = options[:source_url] #: String?
|
|
39
|
+
@mappings = options.fetch(:mappings) #: Hash[Symbol, untyped]
|
|
40
|
+
@feed_authors = options.fetch(:feed_authors) #: Array[SimpleRSS::XmlElement]
|
|
41
|
+
@values = { raw: raw, raw_xml: options[:raw_xml], field_sources: {}, issues: [] } #: Hash[Symbol, untyped]
|
|
42
|
+
@field_elements = {} #: Hash[Symbol, SimpleRSS::XmlElement]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @rbs () -> SimpleRSS::NormalizedEntry
|
|
46
|
+
def entry
|
|
47
|
+
read_identity
|
|
48
|
+
read_links
|
|
49
|
+
read_dates
|
|
50
|
+
read_content
|
|
51
|
+
read_categories
|
|
52
|
+
read_attachments
|
|
53
|
+
read_authors
|
|
54
|
+
SimpleRSS::NormalizedEntry.new(@values)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# @rbs () -> bool
|
|
60
|
+
def atom?
|
|
61
|
+
@element.namespace == ATOM
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @rbs (String) -> Array[SimpleRSS::XmlElement]
|
|
65
|
+
def core_elements(name)
|
|
66
|
+
@children.select { |element| atom? ? element.matches?(name, ATOM) : element.rss?(name) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @rbs (String, String) -> Array[SimpleRSS::XmlElement]
|
|
70
|
+
def extension_elements(name, namespace)
|
|
71
|
+
@children.select { |element| element.matches?(name, namespace) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @rbs (Symbol, untyped, SimpleRSS::XmlElement) -> void
|
|
75
|
+
def assign(field, value, element)
|
|
76
|
+
return if value.nil? || value == ""
|
|
77
|
+
|
|
78
|
+
@values[field] = value
|
|
79
|
+
@values[:field_sources][field] = element.name
|
|
80
|
+
@field_elements[field] = element
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @rbs (Symbol, Symbol, untyped, SimpleRSS::XmlElement) -> nil
|
|
84
|
+
def issue(field, code, value, element)
|
|
85
|
+
@values[:issues] << { field: field, code: code, value: value, source: element.name }
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @rbs () -> void
|
|
90
|
+
def read_identity
|
|
91
|
+
identifier = core_elements(atom? ? "id" : "guid").find { |element| !element.text.empty? }
|
|
92
|
+
assign(:identifier, identifier.text, identifier) if identifier
|
|
93
|
+
title = core_elements("title").first
|
|
94
|
+
assign(:title, title.text, title) if title
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @rbs () -> void
|
|
98
|
+
def read_links
|
|
99
|
+
elements = @children.select { |element| element.matches?("link", ATOM) || (!atom? && element.rss?("link")) }
|
|
100
|
+
links = elements.map do |element|
|
|
101
|
+
href = element.namespace == ATOM ? element.attributes["href"] : element.text
|
|
102
|
+
relation = element.namespace == ATOM ? element.attributes.fetch("rel", "alternate") : "alternate"
|
|
103
|
+
relation = relation.delete_prefix("http://www.iana.org/assignments/relation/")
|
|
104
|
+
{ url: resolve_url(href, element, :url), rel: relation, media_type: element.attributes["type"], raw: element.raw }
|
|
105
|
+
end
|
|
106
|
+
@values[:links] = links
|
|
107
|
+
candidates = links.each_index.select { |index| links[index][:rel] == "alternate" && links[index][:url] }
|
|
108
|
+
selected = candidates.min_by do |index|
|
|
109
|
+
link = links[index]
|
|
110
|
+
media_type = link[:media_type].to_s.split(";").first.to_s.downcase.strip
|
|
111
|
+
[!atom? && elements.fetch(index).rss?("link") ? 0 : 1, media_type_priority(media_type), index]
|
|
112
|
+
end
|
|
113
|
+
assign(:url, links[selected][:url], elements.fetch(selected)) if selected
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @rbs (String) -> Integer
|
|
117
|
+
def media_type_priority(media_type)
|
|
118
|
+
return 0 if %w[text/html application/xhtml+xml].include?(media_type)
|
|
119
|
+
return 1 if media_type.empty?
|
|
120
|
+
|
|
121
|
+
2
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @rbs (String?, SimpleRSS::XmlElement, Symbol) -> String?
|
|
125
|
+
def resolve_url(value, element, field)
|
|
126
|
+
return if value.nil? || value.strip.empty?
|
|
127
|
+
|
|
128
|
+
reference = value.strip
|
|
129
|
+
return reference if URI.parse(reference).absolute?
|
|
130
|
+
|
|
131
|
+
base = base_url(element)
|
|
132
|
+
unless base
|
|
133
|
+
issue(field, :relative_url_without_base, value, element)
|
|
134
|
+
return reference
|
|
135
|
+
end
|
|
136
|
+
URI.join(base, reference).to_s
|
|
137
|
+
rescue URI::Error
|
|
138
|
+
issue(field, :invalid_url, value, element)
|
|
139
|
+
value
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @rbs (SimpleRSS::XmlElement) -> String?
|
|
143
|
+
def base_url(element)
|
|
144
|
+
candidates = [@source_url, *element.base_urls].compact
|
|
145
|
+
start = candidates.rindex { |value| URI.parse(value).absolute? }
|
|
146
|
+
return unless start
|
|
147
|
+
|
|
148
|
+
base = candidates.fetch(start)
|
|
149
|
+
candidates.drop(start + 1).each { |value| base = URI.join(base, value).to_s }
|
|
150
|
+
base
|
|
151
|
+
rescue URI::Error
|
|
152
|
+
issue(:base_url, :invalid_url, candidates, element)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @rbs () -> void
|
|
156
|
+
def read_dates
|
|
157
|
+
published = atom? ? core_elements("published") : core_elements("pubDate") + extension_elements("date", DUBLIN_CORE)
|
|
158
|
+
updated = extension_elements("updated", ATOM)
|
|
159
|
+
updated += core_elements("modified") unless atom?
|
|
160
|
+
read_date(:published_at, published)
|
|
161
|
+
read_date(:updated_at, updated)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @rbs (Symbol, Array[SimpleRSS::XmlElement]) -> void
|
|
165
|
+
def read_date(field, elements)
|
|
166
|
+
elements.each do |element|
|
|
167
|
+
next if element.text.empty?
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
assign(field, Time.parse(element.text), element)
|
|
171
|
+
break
|
|
172
|
+
rescue ArgumentError, RangeError
|
|
173
|
+
issue(field, :invalid_date, element.text, element)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @rbs () -> void
|
|
179
|
+
def read_content
|
|
180
|
+
%i[content_html content_text].each do |field|
|
|
181
|
+
selector = @mappings[field]
|
|
182
|
+
element = selector && @children.find { |child| child.selected?(selector) && !child.text.empty? }
|
|
183
|
+
assign(field, element.text, element) if element
|
|
184
|
+
end
|
|
185
|
+
encoded = extension_elements("encoded", CONTENT).find { |element| !element.text.empty? }
|
|
186
|
+
assign(:content_html, encoded.text, encoded) if encoded && !@values[:content_html]
|
|
187
|
+
read_atom_content
|
|
188
|
+
full_content = @field_elements[:content_html] || @field_elements[:content_text]
|
|
189
|
+
@values[:content_base_url] = base_url(content_container(full_content)) if full_content
|
|
190
|
+
summary = core_elements(atom? ? "summary" : "description").first
|
|
191
|
+
return unless summary
|
|
192
|
+
|
|
193
|
+
value, type = atom? ? text_construct(summary, :summary) : [summary.text, :html]
|
|
194
|
+
assign(:summary, value, summary)
|
|
195
|
+
@values[:summary_type] = type if @values[:summary]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @rbs () -> void
|
|
199
|
+
def read_atom_content
|
|
200
|
+
content = extension_elements("content", ATOM).first
|
|
201
|
+
return unless content
|
|
202
|
+
return assign(:content_url, resolve_url(content.attributes["src"], content, :content_url), content) if content.attributes["src"]
|
|
203
|
+
|
|
204
|
+
value, type = text_construct(content, :content)
|
|
205
|
+
field = type == :html ? :content_html : :content_text
|
|
206
|
+
assign(field, value, content) unless @values[field]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @rbs (SimpleRSS::XmlElement) -> SimpleRSS::XmlElement
|
|
210
|
+
def content_container(element)
|
|
211
|
+
type = element.attributes["type"].to_s.split(";", 2).first.to_s.strip.downcase
|
|
212
|
+
return element unless %w[xhtml application/xhtml+xml].include?(type)
|
|
213
|
+
|
|
214
|
+
element.children.find { |child| child.matches?("div", XHTML) } || element
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @rbs (SimpleRSS::XmlElement, Symbol) -> [String?, Symbol?]
|
|
218
|
+
def text_construct(element, field)
|
|
219
|
+
type = element.attributes.fetch("type", "text").split(";", 2).first.to_s.strip.downcase
|
|
220
|
+
case type
|
|
221
|
+
when "text", "text/plain"
|
|
222
|
+
return [element.text, :text] if element.children.empty?
|
|
223
|
+
|
|
224
|
+
issue(field, :unexpected_markup, element.content, element)
|
|
225
|
+
when "html", "text/html"
|
|
226
|
+
return [element.text, :html]
|
|
227
|
+
when "xhtml", "application/xhtml+xml"
|
|
228
|
+
children = element.children
|
|
229
|
+
return [children.first.xhtml_content, :html] if children.size == 1 && children.first.matches?("div", XHTML)
|
|
230
|
+
|
|
231
|
+
issue(field, :invalid_xhtml, element.content, element)
|
|
232
|
+
else
|
|
233
|
+
issue(field, :unsupported_content_type, type, element)
|
|
234
|
+
end
|
|
235
|
+
[nil, nil]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# @rbs () -> void
|
|
239
|
+
def read_categories
|
|
240
|
+
details = @children.flat_map do |element|
|
|
241
|
+
mappings = @mappings[:categories] || [] #: Array[Hash[Symbol, untyped]]
|
|
242
|
+
mapping = mappings.find { |candidate| element.selected?(candidate[:tag]) }
|
|
243
|
+
term = category_term(element)
|
|
244
|
+
next [] unless mapping || term
|
|
245
|
+
|
|
246
|
+
terms = [term]
|
|
247
|
+
terms = mapping[:separator] ? element.text.split(mapping[:separator]) : [element.text] if mapping
|
|
248
|
+
terms.filter_map do |value|
|
|
249
|
+
next if value.nil? || value.strip.empty?
|
|
250
|
+
|
|
251
|
+
{
|
|
252
|
+
term: value.strip, label: element.attributes["label"], scheme: element.attributes["scheme"] || element.attributes["domain"],
|
|
253
|
+
source: element.name, raw: element.raw
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
@values[:category_details] = details
|
|
258
|
+
@values[:categories] = details.map { |category| category[:term] }.uniq
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# @rbs (SimpleRSS::XmlElement) -> String?
|
|
262
|
+
def category_term(element)
|
|
263
|
+
return element.attributes["term"] if element.matches?("category", ATOM)
|
|
264
|
+
return element.text if !atom? && element.rss?("category")
|
|
265
|
+
return element.text if element.matches?("subject", DUBLIN_CORE)
|
|
266
|
+
return element.text if element.matches?("keywords", MEDIA) || element.matches?("keywords", ITUNES)
|
|
267
|
+
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# @rbs () -> void
|
|
272
|
+
def read_attachments
|
|
273
|
+
elements = @children.flat_map do |element|
|
|
274
|
+
next [element] unless element.matches?("group", MEDIA)
|
|
275
|
+
|
|
276
|
+
element.children.select { |child| child.matches?("content", MEDIA) }
|
|
277
|
+
end
|
|
278
|
+
attachments = elements.filter_map do |element|
|
|
279
|
+
attributes = element.attributes
|
|
280
|
+
if element.matches?("content", MEDIA)
|
|
281
|
+
attachment(element, attributes["url"], attributes["fileSize"], attributes["duration"])
|
|
282
|
+
elsif !atom? && element.rss?("enclosure")
|
|
283
|
+
attachment(element, attributes["url"], attributes["length"], nil)
|
|
284
|
+
elsif element.matches?("link", ATOM) && attributes["rel"].to_s.delete_prefix("http://www.iana.org/assignments/relation/") == "enclosure"
|
|
285
|
+
attachment(element, attributes["href"], attributes["length"], nil)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
duration = extension_elements("duration", ITUNES).first
|
|
289
|
+
if attachments.size == 1 && duration && !attachments.first[:raw][:attributes].key?("duration")
|
|
290
|
+
attachments.first[:duration_in_seconds] = duration_seconds(duration.text, duration)
|
|
291
|
+
attachments.first[:raw_duration] = duration.raw
|
|
292
|
+
end
|
|
293
|
+
@values[:attachments] = attachments
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# @rbs (SimpleRSS::XmlElement, String?, String?, String?) -> Hash[Symbol, untyped]?
|
|
297
|
+
def attachment(element, url, size, duration)
|
|
298
|
+
resolved_url = resolve_url(url, element, :attachment_url)
|
|
299
|
+
return unless resolved_url
|
|
300
|
+
|
|
301
|
+
{
|
|
302
|
+
url: resolved_url, media_type: element.attributes["type"], size_in_bytes: size_in_bytes(size, element),
|
|
303
|
+
duration_in_seconds: duration_seconds(duration, element), source: element.name, raw: element.raw
|
|
304
|
+
}
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# @rbs (String?, SimpleRSS::XmlElement) -> Integer?
|
|
308
|
+
def size_in_bytes(value, element)
|
|
309
|
+
return if value.nil?
|
|
310
|
+
return value.to_i if value.match?(/\A\d+\z/)
|
|
311
|
+
|
|
312
|
+
issue(:size_in_bytes, :invalid_number, value, element)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# @rbs (String?, SimpleRSS::XmlElement) -> (Integer | Float)?
|
|
316
|
+
def duration_seconds(value, element)
|
|
317
|
+
return if value.nil?
|
|
318
|
+
return value.to_f if value.match?(/\A\d+(?:\.\d+)?\z/) && value.to_f.finite?
|
|
319
|
+
|
|
320
|
+
unless element.matches?("duration", ITUNES) && value.match?(/\A\d+:\d{2}(?::\d{2})?\z/)
|
|
321
|
+
return issue(:duration_in_seconds, :invalid_number, value, element)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
parts = value.split(":").map(&:to_i)
|
|
325
|
+
return issue(:duration_in_seconds, :invalid_number, value, element) unless parts.drop(1).all? { |part| part < 60 }
|
|
326
|
+
|
|
327
|
+
parts.reduce(0) { |total, part| (total * 60) + part }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# @rbs () -> void
|
|
331
|
+
def read_authors
|
|
332
|
+
@values[:authors] = author_elements.map do |element|
|
|
333
|
+
next { name: element.text, email: nil, url: nil, raw: element.raw } if element.matches?("creator", DUBLIN_CORE)
|
|
334
|
+
next { name: nil, email: element.text, url: nil, raw: element.raw } unless element.namespace == ATOM
|
|
335
|
+
|
|
336
|
+
children = element.children
|
|
337
|
+
name = children.find { |child| child.matches?("name", ATOM) }
|
|
338
|
+
email = children.find { |child| child.matches?("email", ATOM) }
|
|
339
|
+
uri = children.find { |child| child.matches?("uri", ATOM) }
|
|
340
|
+
{ name: name&.text, email: email&.text, url: uri && resolve_url(uri.text, uri, :author_url), raw: element.raw }
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# @rbs () -> Array[SimpleRSS::XmlElement]
|
|
345
|
+
def author_elements
|
|
346
|
+
elements = core_elements("author")
|
|
347
|
+
return elements + extension_elements("creator", DUBLIN_CORE) unless atom?
|
|
348
|
+
return elements unless elements.empty?
|
|
349
|
+
|
|
350
|
+
source = core_elements("source").first
|
|
351
|
+
return @feed_authors unless source
|
|
352
|
+
|
|
353
|
+
elements = source.children.select { |element| element.matches?("author", ATOM) }
|
|
354
|
+
elements.empty? ? @feed_authors : elements
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require_relative "request_errors"
|
|
7
|
+
require_relative "request_policy"
|
|
8
|
+
|
|
9
|
+
class SimpleRSS::HTTPClient
|
|
10
|
+
ACCEPT = "application/feed+json, application/rss+xml, application/atom+xml, application/json, application/xml, text/xml, */*".freeze
|
|
11
|
+
REDIRECT_CODES = %w[301 302 303 307 308].freeze
|
|
12
|
+
CROSS_ORIGIN_HEADERS = %w[accept accept-language user-agent].freeze
|
|
13
|
+
PROTECTED_HEADERS = %w[host connection proxy-authorization proxy-connection accept-encoding range transfer-encoding content-length te trailer upgrade
|
|
14
|
+
expect].freeze
|
|
15
|
+
|
|
16
|
+
# @rbs @options: Hash[Symbol, untyped]
|
|
17
|
+
# @rbs @headers: Hash[untyped, untyped]
|
|
18
|
+
# @rbs @policy: SimpleRSS::RequestPolicy?
|
|
19
|
+
# @rbs @remaining_bytes: Integer
|
|
20
|
+
# @rbs @remaining_wire_bytes: Integer
|
|
21
|
+
# @rbs @redirect_limit: Integer
|
|
22
|
+
# @rbs @timeout: untyped
|
|
23
|
+
|
|
24
|
+
# @rbs (Hash[Symbol, untyped]) -> void
|
|
25
|
+
def initialize(options)
|
|
26
|
+
@options = options.dup
|
|
27
|
+
@headers = (@options[:headers] || {}).dup
|
|
28
|
+
@policy = @options.key?(:network_policy) ? SimpleRSS::RequestPolicy.new(@options[:network_policy]) : nil
|
|
29
|
+
@remaining_bytes = @options.fetch(:max_bytes, 2 * 1024 * 1024)
|
|
30
|
+
@remaining_wire_bytes = @remaining_bytes
|
|
31
|
+
@redirect_limit = @options.fetch(:max_redirects, 5)
|
|
32
|
+
@timeout = @options.fetch(:timeout, @policy ? 10 : nil)
|
|
33
|
+
validate_options
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @rbs (String) -> [untyped, untyped]
|
|
37
|
+
def get(url)
|
|
38
|
+
return perform(URI.parse(url)) unless @policy
|
|
39
|
+
|
|
40
|
+
Timeout.timeout(@timeout, SimpleRSS::RequestTimeout, "HTTP operation exceeded its timeout") do
|
|
41
|
+
perform(SimpleRSS::RequestPolicy.parse_url(url))
|
|
42
|
+
end
|
|
43
|
+
rescue Timeout::Error
|
|
44
|
+
raise unless @policy
|
|
45
|
+
|
|
46
|
+
raise SimpleRSS::RequestTimeout, "HTTP operation exceeded its timeout"
|
|
47
|
+
rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Resolv::ResolvError, Zlib::Error => e
|
|
48
|
+
raise unless @policy
|
|
49
|
+
|
|
50
|
+
raise SimpleRSS::RequestError, "HTTP transport failed: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# @rbs () -> void
|
|
56
|
+
def validate_options
|
|
57
|
+
if !@policy && (@options.key?(:max_bytes) || @options.key?(:max_redirects))
|
|
58
|
+
raise ArgumentError, "max_bytes and max_redirects require network_policy"
|
|
59
|
+
end
|
|
60
|
+
return unless @policy
|
|
61
|
+
|
|
62
|
+
unless @timeout.is_a?(Numeric) && @timeout.real? && @timeout.finite? && @timeout.positive?
|
|
63
|
+
raise ArgumentError, "timeout must be a positive finite number"
|
|
64
|
+
end
|
|
65
|
+
unless @remaining_bytes.is_a?(Integer) && @remaining_bytes.positive?
|
|
66
|
+
raise ArgumentError, "max_bytes must be a positive integer"
|
|
67
|
+
end
|
|
68
|
+
unless @redirect_limit.is_a?(Integer) && @redirect_limit >= 0
|
|
69
|
+
raise ArgumentError, "max_redirects must be a nonnegative integer"
|
|
70
|
+
end
|
|
71
|
+
raise ArgumentError, "headers must be a hash" unless @headers.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
@headers.each do |name, value|
|
|
74
|
+
if PROTECTED_HEADERS.include?(name.to_s.downcase)
|
|
75
|
+
raise ArgumentError, "The transport controls the #{name} header"
|
|
76
|
+
end
|
|
77
|
+
unless name.to_s.match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/) && value.is_a?(String) && !value.match?(/[\r\n\x00]/)
|
|
78
|
+
raise ArgumentError, "Invalid request header"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @rbs (untyped) -> [untyped, untyped]
|
|
84
|
+
def perform(uri)
|
|
85
|
+
visited = {} #: Hash[String, bool]
|
|
86
|
+
redirects = @policy ? 0 : (@options[:_redirects] || 0)
|
|
87
|
+
redirect_error = @policy ? SimpleRSS::RedirectError : SimpleRSSError
|
|
88
|
+
loop do
|
|
89
|
+
raise SimpleRSS::RedirectError, "Redirect loop detected" if @policy && visited[uri.to_s]
|
|
90
|
+
|
|
91
|
+
visited[uri.to_s] = true
|
|
92
|
+
response = request(uri)
|
|
93
|
+
return [response, uri] unless redirect?(response)
|
|
94
|
+
|
|
95
|
+
location = response["Location"]
|
|
96
|
+
return [response, uri] unless location
|
|
97
|
+
|
|
98
|
+
redirects += 1
|
|
99
|
+
raise redirect_error, "Too many redirects" if redirects > @redirect_limit
|
|
100
|
+
|
|
101
|
+
next_uri = URI.join(uri.to_s, location)
|
|
102
|
+
next_uri = SimpleRSS::RequestPolicy.parse_url(next_uri.to_s) if @policy
|
|
103
|
+
strip_credentials if @policy && origin(uri) != origin(next_uri)
|
|
104
|
+
uri = next_uri
|
|
105
|
+
end
|
|
106
|
+
rescue URI::Error
|
|
107
|
+
raise unless @policy
|
|
108
|
+
|
|
109
|
+
raise SimpleRSS::PolicyError, "Malformed redirect URL"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @rbs (untyped) -> bool
|
|
113
|
+
def redirect?(response)
|
|
114
|
+
return false if @options[:follow_redirects] == false
|
|
115
|
+
return REDIRECT_CODES.include?(response.code) if @policy
|
|
116
|
+
|
|
117
|
+
response.is_a?(Net::HTTPRedirection)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @rbs (untyped) -> untyped
|
|
121
|
+
def request(uri)
|
|
122
|
+
http = build_http(uri)
|
|
123
|
+
request = Net::HTTP::Get.new(uri)
|
|
124
|
+
request["Accept"] = ACCEPT
|
|
125
|
+
request["User-Agent"] = "SimpleRSS/#{SimpleRSS::VERSION}"
|
|
126
|
+
request["If-None-Match"] = @options[:etag] if @options[:etag]
|
|
127
|
+
request["If-Modified-Since"] = @options[:last_modified] if @options[:last_modified]
|
|
128
|
+
@headers.each { |name, value| request[name] = value }
|
|
129
|
+
return http.request(request) unless @policy
|
|
130
|
+
|
|
131
|
+
request["Accept-Encoding"] = "gzip, deflate, identity"
|
|
132
|
+
http.request(request) { |response| read_response(response) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @rbs (untyped) -> untyped
|
|
136
|
+
def build_http(uri)
|
|
137
|
+
host = uri.hostname || raise(SimpleRSSError, "Invalid URL: missing host")
|
|
138
|
+
policy = @policy
|
|
139
|
+
http = policy ? Net::HTTP.new(host, uri.port, nil) : Net::HTTP.new(host, uri.port)
|
|
140
|
+
http.use_ssl = uri.scheme == "https"
|
|
141
|
+
if @timeout
|
|
142
|
+
http.open_timeout = @timeout
|
|
143
|
+
http.read_timeout = @timeout
|
|
144
|
+
end
|
|
145
|
+
return http unless policy
|
|
146
|
+
|
|
147
|
+
http.ipaddr = policy.address(uri)
|
|
148
|
+
http.write_timeout = @timeout
|
|
149
|
+
http.max_retries = 0
|
|
150
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
151
|
+
http.verify_hostname = true
|
|
152
|
+
http
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @rbs (untyped) -> void
|
|
156
|
+
def read_response(response)
|
|
157
|
+
return unless response.class.body_permitted?
|
|
158
|
+
|
|
159
|
+
encoding = response["Content-Encoding"].to_s.downcase
|
|
160
|
+
unless ["", "identity", "none", "gzip", "x-gzip", "deflate"].include?(encoding)
|
|
161
|
+
raise SimpleRSS::RequestError, "Unsupported Content-Encoding"
|
|
162
|
+
end
|
|
163
|
+
raise SimpleRSS::RequestError, "Partial HTTP responses are not supported" if response["Content-Range"] || response.code == "206"
|
|
164
|
+
|
|
165
|
+
body = +"".b
|
|
166
|
+
wire_bytes = 0
|
|
167
|
+
inflater = Zlib::Inflate.new(32 + Zlib::MAX_WBITS) if %w[gzip x-gzip deflate].include?(encoding)
|
|
168
|
+
response.read_body do |chunk|
|
|
169
|
+
if chunk.bytesize > @remaining_wire_bytes
|
|
170
|
+
raise SimpleRSS::ResponseTooLarge, "HTTP response bodies exceeded max_bytes"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@remaining_wire_bytes -= chunk.bytesize
|
|
174
|
+
wire_bytes += chunk.bytesize
|
|
175
|
+
unless inflater
|
|
176
|
+
append_body(body, chunk)
|
|
177
|
+
next
|
|
178
|
+
end
|
|
179
|
+
inflater.inflate(chunk) { |decoded| append_body(body, decoded) }
|
|
180
|
+
raise SimpleRSS::RequestError, "Trailing data in compressed HTTP response" if inflater.total_in != wire_bytes
|
|
181
|
+
end
|
|
182
|
+
raise SimpleRSS::RequestError, "Incomplete compressed HTTP response" if inflater && !inflater.finished?
|
|
183
|
+
|
|
184
|
+
declared_length = response.content_length
|
|
185
|
+
if declared_length && !response.chunked? && wire_bytes != declared_length
|
|
186
|
+
raise SimpleRSS::RequestError, "Incomplete HTTP response body"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
response.delete("Content-Encoding")
|
|
190
|
+
response["Content-Length"] = body.bytesize.to_s if declared_length
|
|
191
|
+
response.body = body
|
|
192
|
+
ensure
|
|
193
|
+
inflater&.close
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @rbs (String, String) -> nil
|
|
197
|
+
def append_body(body, chunk)
|
|
198
|
+
raise SimpleRSS::ResponseTooLarge, "HTTP response bodies exceeded max_bytes" if chunk.bytesize > @remaining_bytes
|
|
199
|
+
|
|
200
|
+
@remaining_bytes -= chunk.bytesize
|
|
201
|
+
body << chunk
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# @rbs (untyped) -> Array[untyped]
|
|
206
|
+
def origin(uri)
|
|
207
|
+
[uri.scheme.downcase, uri.hostname.downcase, uri.port]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @rbs () -> void
|
|
211
|
+
def strip_credentials
|
|
212
|
+
@headers = @headers.select { |name, _value| CROSS_ORIGIN_HEADERS.include?(name.to_s.downcase) }
|
|
213
|
+
@options.delete(:etag)
|
|
214
|
+
@options.delete(:last_modified)
|
|
215
|
+
end
|
|
216
|
+
end
|