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
data/lib/simple-rss.rb
CHANGED
|
@@ -10,18 +10,25 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
10
10
|
# @rbs!
|
|
11
11
|
# include Enumerable[Hash[Symbol, untyped]]
|
|
12
12
|
|
|
13
|
-
VERSION = "2.
|
|
13
|
+
VERSION = "2.3.0".freeze
|
|
14
14
|
|
|
15
15
|
# @rbs @items: Array[Hash[Symbol, untyped]]
|
|
16
16
|
# @rbs @source: String
|
|
17
17
|
# @rbs @options: Hash[Symbol, untyped]
|
|
18
|
+
# @rbs @json_feed: JsonFeed?
|
|
18
19
|
# @rbs @etag: String?
|
|
19
20
|
# @rbs @last_modified: String?
|
|
21
|
+
# @rbs @entry_contexts: Hash[Hash[Symbol, untyped], Hash[Symbol, untyped]]
|
|
20
22
|
|
|
21
23
|
attr_reader :items #: Array[Hash[Symbol, untyped]]
|
|
22
24
|
attr_reader :source #: String
|
|
23
25
|
attr_reader :etag #: String?
|
|
24
26
|
attr_reader :last_modified #: String?
|
|
27
|
+
attr_reader :source_url #: String?
|
|
28
|
+
attr_reader :raw_json #: Hash[String, untyped]?
|
|
29
|
+
attr_reader :home_page_url, :feed_url, :favicon, :next_url, :user_comment #: String?
|
|
30
|
+
attr_reader :authors, :hubs #: untyped
|
|
31
|
+
attr_reader :expired #: bool?
|
|
25
32
|
alias entries items #: Array[Hash[Symbol, untyped]]
|
|
26
33
|
|
|
27
34
|
@@feed_tags = %i[
|
|
@@ -63,6 +70,11 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
63
70
|
@items = [] #: Array[Hash[Symbol, untyped]]
|
|
64
71
|
@options = {} #: Hash[Symbol, untyped]
|
|
65
72
|
@options.update(options)
|
|
73
|
+
@source_url = options[:source_url]
|
|
74
|
+
@json_feed = nil
|
|
75
|
+
@raw_json = nil
|
|
76
|
+
@entry_contexts = {} #: Hash[Hash[Symbol, untyped], Hash[Symbol, untyped]]
|
|
77
|
+
@entry_contexts.compare_by_identity
|
|
66
78
|
|
|
67
79
|
parse
|
|
68
80
|
end
|
|
@@ -73,6 +85,22 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
73
85
|
end
|
|
74
86
|
alias feed channel
|
|
75
87
|
|
|
88
|
+
# @rbs (?source_url: String?, ?mappings: Hash[Symbol, untyped]) -> Array[NormalizedEntry]
|
|
89
|
+
def normalized_entries(source_url: nil, mappings: {})
|
|
90
|
+
json_feed = @json_feed
|
|
91
|
+
raise ArgumentError, "XML mappings are not supported for JSON Feed" if json_feed && !mappings.empty?
|
|
92
|
+
return items.map { |item| json_feed.normalized_entry(item, source_url: source_url || @source_url) } if json_feed
|
|
93
|
+
|
|
94
|
+
EntryNormalizer.validate_mappings(mappings)
|
|
95
|
+
feed_authors = normalized_feed_authors
|
|
96
|
+
items.map do |item|
|
|
97
|
+
context = @entry_contexts[item] || raise(SimpleRSSError, "Cannot normalize an item without its original XML source")
|
|
98
|
+
element = XmlElement.new(context[:name], context[:attributes], context[:content], context[:parent])
|
|
99
|
+
EntryNormalizer.new(element, item, source_url: source_url || @source_url, mappings: mappings,
|
|
100
|
+
raw_xml: context[:xml], feed_authors: feed_authors).entry
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
76
104
|
# Iterate over all items in the feed
|
|
77
105
|
#
|
|
78
106
|
# @rbs () { (Hash[Symbol, untyped]) -> void } -> self
|
|
@@ -95,11 +123,13 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
95
123
|
#
|
|
96
124
|
# @rbs (?Integer) -> Array[Hash[Symbol, untyped]]
|
|
97
125
|
def latest(count = 10)
|
|
98
|
-
items
|
|
126
|
+
sorted_items_by_date(items).first(count)
|
|
99
127
|
end
|
|
100
128
|
|
|
101
129
|
# @rbs () -> Symbol
|
|
102
130
|
def feed_type
|
|
131
|
+
return :json_feed if @json_feed
|
|
132
|
+
|
|
103
133
|
atom_namespaced_feed = source.match?(/<(atom:)?feed\b[^>]*xmlns(:\w+)?=['"][^'"]*atom/i)
|
|
104
134
|
return :atom if atom_namespaced_feed
|
|
105
135
|
return :rss2 if source.match?(/<rss[^>]*version=['"]2/i)
|
|
@@ -111,6 +141,8 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
111
141
|
|
|
112
142
|
# @rbs () -> bool
|
|
113
143
|
def valid?
|
|
144
|
+
return true if @json_feed
|
|
145
|
+
|
|
114
146
|
return false if items.empty?
|
|
115
147
|
|
|
116
148
|
title_value = instance_variable_get(:@title)
|
|
@@ -123,8 +155,8 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
123
155
|
# @rbs (Time) -> Array[Hash[Symbol, untyped]]
|
|
124
156
|
def items_since(time)
|
|
125
157
|
items.select do |item|
|
|
126
|
-
|
|
127
|
-
|
|
158
|
+
date = item_date(item)
|
|
159
|
+
date && date > time
|
|
128
160
|
end
|
|
129
161
|
end
|
|
130
162
|
|
|
@@ -152,7 +184,8 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
152
184
|
# @rbs (*SimpleRSS) -> Array[Hash[Symbol, untyped]]
|
|
153
185
|
def merge(*feeds)
|
|
154
186
|
all_items = [items, *feeds.map(&:items)].flatten
|
|
155
|
-
|
|
187
|
+
keyed_items, unkeyed_items = all_items.partition { |item| !item_key(item).nil? }
|
|
188
|
+
dedupe_items(sorted_items_by_date(keyed_items) + unkeyed_items)
|
|
156
189
|
end
|
|
157
190
|
|
|
158
191
|
# @rbs (SimpleRSS) -> Hash[Symbol, Array[Hash[Symbol, untyped]]]
|
|
@@ -194,7 +227,8 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
194
227
|
|
|
195
228
|
# @rbs (?Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
196
229
|
def as_json(_options = {})
|
|
197
|
-
|
|
230
|
+
raw_json = @raw_json
|
|
231
|
+
hash = raw_json ? raw_json.transform_keys(&:to_sym) : {} #: Hash[Symbol, untyped]
|
|
198
232
|
|
|
199
233
|
@@feed_tags.each do |tag|
|
|
200
234
|
tag_cleaned = clean_tag(tag)
|
|
@@ -219,6 +253,8 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
219
253
|
|
|
220
254
|
# @rbs (?format: Symbol) -> String
|
|
221
255
|
def to_xml(format: :rss2)
|
|
256
|
+
raise SimpleRSSError, "JSON Feed to XML conversion is not supported" if @json_feed
|
|
257
|
+
|
|
222
258
|
case format
|
|
223
259
|
when :rss2 then to_rss2_xml
|
|
224
260
|
when :atom then to_atom_xml
|
|
@@ -278,85 +314,68 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
278
314
|
require "net/http"
|
|
279
315
|
require "uri"
|
|
280
316
|
|
|
281
|
-
|
|
282
|
-
response =
|
|
317
|
+
require_relative "simple-rss/http_client"
|
|
318
|
+
response, final_uri = HTTPClient.new(options).get(url)
|
|
283
319
|
|
|
284
320
|
return nil if response.is_a?(Net::HTTPNotModified)
|
|
285
321
|
|
|
286
322
|
raise SimpleRSSError, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
287
323
|
|
|
288
|
-
body = response.body.force_encoding(Encoding::UTF_8)
|
|
289
|
-
feed = parse(body, options)
|
|
324
|
+
body = (response.body || "").force_encoding(Encoding::UTF_8)
|
|
325
|
+
feed = parse(body, options.merge(source_url: final_uri.to_s))
|
|
290
326
|
feed.instance_variable_set(:@etag, response["ETag"])
|
|
291
327
|
feed.instance_variable_set(:@last_modified, response["Last-Modified"])
|
|
292
328
|
feed
|
|
293
329
|
end
|
|
294
330
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
http = build_http(uri, options)
|
|
300
|
-
request = build_request(uri, options)
|
|
301
|
-
|
|
302
|
-
response = http.request(request)
|
|
303
|
-
handle_redirect(response, options) || response
|
|
331
|
+
# @rbs (String, ?Hash[Symbol, untyped]) -> Array[Hash[Symbol, untyped]]
|
|
332
|
+
def discover(url, options = {})
|
|
333
|
+
require_relative "simple-rss/discovery"
|
|
334
|
+
Discovery.new(options).discover(url)
|
|
304
335
|
end
|
|
336
|
+
end
|
|
305
337
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
timeout = options[:timeout]
|
|
313
|
-
if timeout
|
|
314
|
-
http.open_timeout = timeout
|
|
315
|
-
http.read_timeout = timeout
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
http
|
|
319
|
-
end
|
|
338
|
+
DATE_TAGS = %i[pubDate lastBuildDate published updated expirationDate modified dc:date].freeze
|
|
339
|
+
STRIP_HTML_TAGS = %i[author contributor skipHours skipDays].freeze
|
|
340
|
+
ATOM_NAMESPACE = "http://www.w3.org/2005/Atom".freeze
|
|
341
|
+
RSS_NAMESPACES = [nil, "", "http://purl.org/rss/1.0/", "http://my.netscape.com/rdf/simple/0.9/"].freeze
|
|
342
|
+
XML_TAG_PATTERN = %r{<!--.*?-->|<!\[CDATA\[.*?\]\]>|<\?.*?\?>|<(/?)([\w:.-]+)((?:[^<>"']|"[^"]*"|'[^']*')*)>}m
|
|
320
343
|
|
|
321
|
-
|
|
322
|
-
def build_request(uri, options)
|
|
323
|
-
request = Net::HTTP::Get.new(uri)
|
|
324
|
-
request["User-Agent"] = "SimpleRSS/#{VERSION}"
|
|
344
|
+
private
|
|
325
345
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
346
|
+
# @rbs () -> Array[XmlElement]
|
|
347
|
+
def normalized_feed_authors
|
|
348
|
+
document = XmlElement.new("", "", @source, { namespaces: {}, base_urls: [] })
|
|
349
|
+
feed = document.children.find { |element| element.matches?("feed", ATOM_NAMESPACE) }
|
|
350
|
+
return [] unless feed
|
|
329
351
|
|
|
330
|
-
|
|
331
|
-
|
|
352
|
+
feed.children.select { |element| element.matches?("author", ATOM_NAMESPACE) }
|
|
353
|
+
end
|
|
332
354
|
|
|
333
|
-
|
|
355
|
+
# @rbs () -> void
|
|
356
|
+
def parse
|
|
357
|
+
prefix = @source.b.sub(/\A\xEF\xBB\xBF/n, "").lstrip
|
|
358
|
+
return parse_xml unless prefix.match?(/\A(?:[\{\["0-9-]|true\b|false\b|null\b)/n)
|
|
359
|
+
|
|
360
|
+
json_feed = JsonFeed.new(@source)
|
|
361
|
+
@json_feed = json_feed
|
|
362
|
+
@raw_json = json_feed.document
|
|
363
|
+
JsonFeed::FEED_FIELDS.each do |field|
|
|
364
|
+
instance_variable_set("@#{field}", json_feed.document[field])
|
|
365
|
+
self.class.attr_reader(field)
|
|
334
366
|
end
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
location = response["Location"]
|
|
342
|
-
return nil unless location
|
|
343
|
-
|
|
344
|
-
redirects = (options[:_redirects] || 0) + 1
|
|
345
|
-
raise SimpleRSSError, "Too many redirects" if redirects > 5
|
|
346
|
-
|
|
347
|
-
new_options = options.merge(_redirects: redirects)
|
|
348
|
-
perform_fetch(URI.parse(location), new_options)
|
|
367
|
+
@link = json_feed.document["home_page_url"]
|
|
368
|
+
self.class.attr_reader(:link)
|
|
369
|
+
@items = json_feed.items
|
|
370
|
+
@items.each do |item|
|
|
371
|
+
item.define_singleton_method(:method_missing) { |name, *_args| self[name] }
|
|
372
|
+
add_item_media_helpers(item)
|
|
349
373
|
end
|
|
350
374
|
end
|
|
351
375
|
|
|
352
|
-
DATE_TAGS = %i[pubDate lastBuildDate published updated expirationDate modified dc:date].freeze
|
|
353
|
-
STRIP_HTML_TAGS = %i[author contributor skipHours skipDays].freeze
|
|
354
|
-
|
|
355
|
-
private
|
|
356
|
-
|
|
357
376
|
# @rbs () -> void
|
|
358
|
-
def
|
|
359
|
-
raise SimpleRSSError, "Poorly formatted feed" unless @source =~ %r{<(channel|feed).*?>.*?</(channel|feed)
|
|
377
|
+
def parse_xml
|
|
378
|
+
raise SimpleRSSError, "Poorly formatted feed" unless @source =~ %r{<(channel|feed).*?>.*?</(channel|feed)>|<(channel|feed)\b[^>]*?/\s*>}mi
|
|
360
379
|
|
|
361
380
|
# Feed's title and link
|
|
362
381
|
feed_content = Regexp.last_match(1) if @source =~ %r{(.*?)<(rss:|atom:)?(item|entry).*?>.*?</(rss:|atom:)?(item|entry)>}mi
|
|
@@ -396,12 +415,29 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
396
415
|
end
|
|
397
416
|
|
|
398
417
|
# RSS items' title, link, and description
|
|
399
|
-
|
|
418
|
+
namespace_contexts = entry_contexts
|
|
419
|
+
entry_pattern = %r{<(rss:|atom:)?(item|entry)([\s][^>]*)?>(.*?)</(rss:|atom:)?(item|entry)>}mi
|
|
420
|
+
position = 0
|
|
421
|
+
while (match = entry_pattern.match(@source, position))
|
|
422
|
+
position = match.end(0)
|
|
400
423
|
item = {} #: Hash[Symbol, untyped]
|
|
424
|
+
parent_context = namespace_contexts[match.begin(0)]
|
|
425
|
+
namespaces = parent_context && parent_context[:namespaces].merge(namespace_attributes(match[3].to_s))
|
|
426
|
+
@entry_contexts[item] = {
|
|
427
|
+
name: "#{match[1]}#{match[2]}", attributes: match[3].to_s, content: match[4].to_s,
|
|
428
|
+
parent: parent_context || { namespaces: {}, base_urls: [] }, xml: match[0]
|
|
429
|
+
}
|
|
401
430
|
@@item_tags.each do |tag|
|
|
402
431
|
next if tag.to_s.strip.empty?
|
|
403
432
|
|
|
404
|
-
|
|
433
|
+
if tag == :category
|
|
434
|
+
next unless namespaces
|
|
435
|
+
|
|
436
|
+
parse_category_tag(item, match[4].to_s, namespaces, element_namespace("#{match[1]}#{match[2]}", namespaces))
|
|
437
|
+
next
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
parse_item_tag(item, tag, match[4], match[3])
|
|
405
441
|
end
|
|
406
442
|
item.define_singleton_method(:method_missing) { |name, *_args| self[name] }
|
|
407
443
|
add_item_media_helpers(item)
|
|
@@ -444,15 +480,120 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
444
480
|
|
|
445
481
|
# @rbs (Hash[Symbol, untyped], String, String) -> void
|
|
446
482
|
def parse_rel_tag(item, tag_str, content)
|
|
447
|
-
tag, rel = tag_str.split("+")
|
|
483
|
+
tag, rel = tag_str.split("+", 2)
|
|
448
484
|
return unless tag && rel
|
|
449
485
|
|
|
450
|
-
|
|
451
|
-
|
|
486
|
+
value = if tag == "link"
|
|
487
|
+
link_relation_href(content, rel)
|
|
488
|
+
else
|
|
489
|
+
content =~ %r{<(rss:|atom:)?#{tag}(.*?)rel=['"]#{rel}['"](.*?)>(.*?)</(rss:|atom:)?#{tag}>}mi ||
|
|
490
|
+
content =~ %r{<(rss:|atom:)?#{tag}(.*?)rel=['"]#{rel}['"](.*?)/\s*>}mi
|
|
491
|
+
|
|
492
|
+
return unless Regexp.last_match(3) || Regexp.last_match(4)
|
|
493
|
+
|
|
494
|
+
clean_content(tag.to_sym, Regexp.last_match(3), Regexp.last_match(4))
|
|
495
|
+
end
|
|
496
|
+
return if value.nil?
|
|
497
|
+
|
|
498
|
+
item[clean_tag("#{tag}+#{rel}")] = value
|
|
499
|
+
item[clean_tag("#{tag}_#{rel}")] = value
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# @rbs (String, String) -> String?
|
|
503
|
+
def link_relation_href(content, relation)
|
|
504
|
+
attributes = entry_link_attributes(content).find { |link| link["rel"]&.casecmp?(relation) }
|
|
505
|
+
return unless attributes
|
|
506
|
+
|
|
507
|
+
attributes["href"]
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# @rbs (String) -> Array[Hash[String, String]]
|
|
511
|
+
def entry_link_attributes(content)
|
|
512
|
+
child_elements(content).filter_map do |tag, attributes, _body|
|
|
513
|
+
next unless %w[link atom:link rss:link].include?(tag.downcase)
|
|
452
514
|
|
|
453
|
-
|
|
515
|
+
xml_attributes(attributes).transform_keys(&:downcase)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
454
518
|
|
|
455
|
-
|
|
519
|
+
# @rbs (String) -> Array[[String, String, String?]]
|
|
520
|
+
def child_elements(content)
|
|
521
|
+
XmlElement.child_elements(content)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# @rbs (Hash[Symbol, untyped], String, Hash[String, String], String?) -> void
|
|
525
|
+
def parse_category_tag(item, content, namespaces, entry_namespace)
|
|
526
|
+
values = child_elements(content).filter_map do |tag, raw_attributes, body|
|
|
527
|
+
next unless tag.split(":").last&.casecmp?("category")
|
|
528
|
+
|
|
529
|
+
attributes = xml_attributes(raw_attributes)
|
|
530
|
+
namespace = element_namespace(tag, namespaces.merge(namespace_attributes(raw_attributes)))
|
|
531
|
+
if namespace == ATOM_NAMESPACE
|
|
532
|
+
term = CGI.unescapeHTML(attributes["term"].to_s).strip
|
|
533
|
+
next term unless term.empty?
|
|
534
|
+
|
|
535
|
+
next
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
next if entry_namespace == ATOM_NAMESPACE || !RSS_NAMESPACES.include?(namespace)
|
|
539
|
+
next if tag.include?(":") && namespace.nil?
|
|
540
|
+
next if body.nil? && (array_tag?(:category) || !raw_attributes.rstrip.end_with?("/"))
|
|
541
|
+
|
|
542
|
+
unescape(body.to_s)
|
|
543
|
+
end
|
|
544
|
+
return if values.empty?
|
|
545
|
+
|
|
546
|
+
item[:category] = array_tag?(:category) ? values : values.first
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# @rbs () -> Hash[Integer, Hash[Symbol, untyped]]
|
|
550
|
+
def entry_contexts
|
|
551
|
+
contexts = {} #: Hash[Integer, Hash[Symbol, untyped]]
|
|
552
|
+
scopes = [{ namespaces: {}, base_urls: [] }] #: Array[Hash[Symbol, untyped]]
|
|
553
|
+
position = 0
|
|
554
|
+
|
|
555
|
+
while (token = XML_TAG_PATTERN.match(@source, position))
|
|
556
|
+
position = token.end(0)
|
|
557
|
+
attributes = token[3]
|
|
558
|
+
next unless attributes
|
|
559
|
+
|
|
560
|
+
if token[1] == "/"
|
|
561
|
+
scopes.pop if scopes.size > 1
|
|
562
|
+
next
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
parent = scopes.fetch(-1)
|
|
566
|
+
tag = token[2].to_s.split(":").last
|
|
567
|
+
contexts[token.begin(0).to_i] = parent if %w[item entry].include?(tag&.downcase)
|
|
568
|
+
base_url = xml_attributes(attributes)["xml:base"]
|
|
569
|
+
base_urls = parent[:base_urls].dup
|
|
570
|
+
base_urls << CGI.unescapeHTML(base_url) if base_url
|
|
571
|
+
context = {
|
|
572
|
+
namespaces: parent[:namespaces].merge(namespace_attributes(attributes)),
|
|
573
|
+
base_urls: base_urls
|
|
574
|
+
}
|
|
575
|
+
scopes << context unless attributes.rstrip.end_with?("/")
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
contexts
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# @rbs (String) -> Hash[String, String]
|
|
582
|
+
def namespace_attributes(attributes)
|
|
583
|
+
xml_attributes(attributes)
|
|
584
|
+
.select { |name, _value| name == "xmlns" || name.start_with?("xmlns:") }
|
|
585
|
+
.transform_values { |value| CGI.unescapeHTML(value) }
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# @rbs (String, Hash[String, String]) -> String?
|
|
589
|
+
def element_namespace(tag, namespaces)
|
|
590
|
+
key = tag.include?(":") ? "xmlns:#{tag.split(":").first}" : "xmlns"
|
|
591
|
+
namespaces[key]
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# @rbs (String) -> Hash[String, String]
|
|
595
|
+
def xml_attributes(attributes)
|
|
596
|
+
XmlElement.attributes(attributes)
|
|
456
597
|
end
|
|
457
598
|
|
|
458
599
|
# @rbs (String, String?) -> void
|
|
@@ -579,8 +720,10 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
579
720
|
|
|
580
721
|
# @rbs (Array[Hash[Symbol, untyped]]) -> Array[Hash[Symbol, untyped]]
|
|
581
722
|
def sorted_items_by_date(item_list)
|
|
582
|
-
|
|
583
|
-
|
|
723
|
+
item_list.sort_by.with_index do |item, index|
|
|
724
|
+
date = item_date(item)
|
|
725
|
+
[date ? -date.to_r : Float::INFINITY, index]
|
|
726
|
+
end
|
|
584
727
|
end
|
|
585
728
|
|
|
586
729
|
# @rbs (Array[Hash[Symbol, untyped]]) -> Array[Hash[Symbol, untyped]]
|
|
@@ -612,10 +755,7 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
612
755
|
|
|
613
756
|
# @rbs (Hash[Symbol, untyped]) -> Time?
|
|
614
757
|
def item_date(item)
|
|
615
|
-
|
|
616
|
-
return date if date.is_a?(Time)
|
|
617
|
-
|
|
618
|
-
nil
|
|
758
|
+
[item[:pubDate], item[:updated], item[:published]].find { |date| date.is_a?(Time) }
|
|
619
759
|
end
|
|
620
760
|
|
|
621
761
|
# @rbs (Hash[Symbol, untyped]) -> Array[String]
|
|
@@ -763,5 +903,13 @@ class SimpleRSS # rubocop:disable Metrics/ClassLength
|
|
|
763
903
|
end
|
|
764
904
|
end
|
|
765
905
|
|
|
906
|
+
require_relative "simple-rss/xml_element"
|
|
907
|
+
require_relative "simple-rss/normalized_entry"
|
|
908
|
+
require_relative "simple-rss/json_feed"
|
|
909
|
+
require_relative "simple-rss/json_entry_normalizer"
|
|
910
|
+
require_relative "simple-rss/entry_normalizer"
|
|
911
|
+
|
|
766
912
|
class SimpleRSSError < StandardError # rubocop:disable Style/OneClassPerFile
|
|
767
913
|
end
|
|
914
|
+
|
|
915
|
+
require_relative "simple-rss/request_errors"
|
data/simple-rss.gemspec
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Gem::Specification.new do |s|
|
|
2
2
|
s.name = "simple-rss"
|
|
3
|
-
s.version = "2.
|
|
4
|
-
s.
|
|
5
|
-
s.summary = "A simple, flexible, extensible, and liberal RSS and Atom reader for Ruby. It is designed to be backwards compatible with the standard RSS parser, but will never do RSS generation."
|
|
3
|
+
s.version = "2.3.0"
|
|
4
|
+
s.summary = "A flexible RSS, Atom, and JSON Feed reader for Ruby."
|
|
6
5
|
s.email = "lucas@rufy.com"
|
|
7
6
|
s.homepage = "https://github.com/cardmagic/simple-rss"
|
|
8
|
-
s.
|
|
7
|
+
s.metadata["changelog_uri"] = "https://github.com/cardmagic/simple-rss/blob/master/CHANGELOG.md"
|
|
8
|
+
s.description = "Parse RSS, Atom, and JSON Feed with normalized entries, HTTP fetching, website feed discovery, and JSON/XML serialization."
|
|
9
9
|
s.authors = ["Lucas Carlson"]
|
|
10
|
-
s.files = Dir["lib/**/*", "test/**/*", "LICENSE", "README.md", "Rakefile", "simple-rss.gemspec"]
|
|
10
|
+
s.files = Dir["lib/**/*", "examples/**/*", "test/**/*", "LICENSE", "README.md", "CHANGELOG.md", "Rakefile", "simple-rss.gemspec"]
|
|
11
11
|
s.required_ruby_version = ">= 3.1"
|
|
12
12
|
s.add_development_dependency "rake"
|
|
13
13
|
s.add_development_dependency "rdoc"
|