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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +492 -8
  4. data/Rakefile +1 -1
  5. data/examples/digest.rb +19 -0
  6. data/examples/discover.rb +14 -0
  7. data/examples/feedbag.rb +10 -0
  8. data/lib/simple-rss/discovery.rb +107 -0
  9. data/lib/simple-rss/entry_normalizer.rb +356 -0
  10. data/lib/simple-rss/http_client.rb +216 -0
  11. data/lib/simple-rss/json_entry_normalizer.rb +147 -0
  12. data/lib/simple-rss/json_feed.rb +176 -0
  13. data/lib/simple-rss/normalized_entry.rb +79 -0
  14. data/lib/simple-rss/request_errors.rb +21 -0
  15. data/lib/simple-rss/request_policy.rb +72 -0
  16. data/lib/simple-rss/xml_element.rb +136 -0
  17. data/lib/simple-rss.rb +225 -77
  18. data/simple-rss.gemspec +5 -5
  19. data/test/base/category_parsing_test.rb +217 -0
  20. data/test/base/date_ordering_test.rb +95 -0
  21. data/test/base/discovery_dependency_test.rb +29 -0
  22. data/test/base/discovery_test.rb +151 -0
  23. data/test/base/discovery_transport_test.rb +413 -0
  24. data/test/base/enumerable_test.rb +16 -0
  25. data/test/base/feedbag_integration_test.rb +21 -0
  26. data/test/base/json_feed_test.rb +372 -0
  27. data/test/base/normalized_entries_test.rb +410 -0
  28. data/test/base/normalized_fetch_test.rb +132 -0
  29. data/test/base/relation_links_test.rb +162 -0
  30. data/test/data/atom_categories.xml +23 -0
  31. data/test/data/atom_nested_link.xml +13 -0
  32. data/test/data/discovery.html +28 -0
  33. data/test/data/json_feed_1.json +75 -0
  34. data/test/data/json_feed_1_1.json +78 -0
  35. data/test/data/mixed_dates.xml +55 -0
  36. data/test/data/normalized_atom.xml +22 -0
  37. data/test/data/normalized_rss.xml +21 -0
  38. data/test/data/rss_categories.xml +15 -0
  39. data/test/support/http_server.rb +44 -0
  40. data/test/support/replace_method.rb +14 -0
  41. 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.2.0".freeze
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.sort_by { |item| item[:pubDate] || item[:updated] || Time.at(0) }.reverse.first(count)
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
- item_date = item[:pubDate] || item[:updated] || item[:published]
127
- item_date.is_a?(Time) && item_date > time
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
- dedupe_items(sorted_items_by_date(all_items))
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
- hash = {} #: Hash[Symbol, untyped]
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
- uri = URI.parse(url)
282
- response = perform_fetch(uri, options)
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
- private
296
-
297
- # @rbs (untyped, Hash[Symbol, untyped]) -> untyped
298
- def perform_fetch(uri, options)
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
- # @rbs (untyped, Hash[Symbol, untyped]) -> untyped
307
- def build_http(uri, options)
308
- host = uri.host || raise(SimpleRSSError, "Invalid URL: missing host")
309
- http = Net::HTTP.new(host, uri.port)
310
- http.use_ssl = uri.scheme == "https"
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
- # @rbs (untyped, Hash[Symbol, untyped]) -> untyped
322
- def build_request(uri, options)
323
- request = Net::HTTP::Get.new(uri)
324
- request["User-Agent"] = "SimpleRSS/#{VERSION}"
344
+ private
325
345
 
326
- # Conditional GET headers
327
- request["If-None-Match"] = options[:etag] if options[:etag]
328
- request["If-Modified-Since"] = options[:last_modified] if options[:last_modified]
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
- # Custom headers
331
- options[:headers]&.each { |key, value| request[key] = value }
352
+ feed.children.select { |element| element.matches?("author", ATOM_NAMESPACE) }
353
+ end
332
354
 
333
- request
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
- # @rbs (untyped, Hash[Symbol, untyped]) -> untyped
337
- def handle_redirect(response, options)
338
- return nil unless response.is_a?(Net::HTTPRedirection)
339
- return nil if options[:follow_redirects] == false
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 parse
359
- raise SimpleRSSError, "Poorly formatted feed" unless @source =~ %r{<(channel|feed).*?>.*?</(channel|feed)>}mi
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
- @source.scan(%r{<(rss:|atom:)?(item|entry)([\s][^>]*)?>(.*?)</(rss:|atom:)?(item|entry)>}mi) do |match|
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
- parse_item_tag(item, tag, match[3], match[2])
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
- content =~ %r{<(rss:|atom:)?#{tag}(.*?)rel=['"]#{rel}['"](.*?)>(.*?)</(rss:|atom:)?#{tag}>}mi ||
451
- content =~ %r{<(rss:|atom:)?#{tag}(.*?)rel=['"]#{rel}['"](.*?)/\s*>}mi
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
- return unless Regexp.last_match(3) || Regexp.last_match(4)
515
+ xml_attributes(attributes).transform_keys(&:downcase)
516
+ end
517
+ end
454
518
 
455
- item[clean_tag("#{tag}+#{rel}")] = clean_content(tag.to_sym, Regexp.last_match(3), Regexp.last_match(4))
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
- keyed_items, unkeyed_items = item_list.partition { |item| !item_key(item).nil? }
583
- keyed_items.sort_by { |item| item_date(item) || Time.at(0) }.reverse + unkeyed_items
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
- date = item[:pubDate] || item[:updated] || item[:published]
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.2.0"
4
- s.date = "2025-12-28"
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.description = "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."
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"