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,217 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
|
|
3
|
+
class CategoryParsingTest < Test::Unit::TestCase
|
|
4
|
+
def test_atom_terms_are_collected_and_filterable
|
|
5
|
+
source = <<~XML
|
|
6
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
7
|
+
<title>Categories</title>
|
|
8
|
+
<entry>
|
|
9
|
+
<title>Ruby news</title>
|
|
10
|
+
<category term="ruby" label="Ruby Language"/>
|
|
11
|
+
<category term="rails" label="Rails"/>
|
|
12
|
+
</entry>
|
|
13
|
+
</feed>
|
|
14
|
+
XML
|
|
15
|
+
feed = SimpleRSS.parse(source, array_tags: [:category])
|
|
16
|
+
|
|
17
|
+
assert_equal %w[ruby rails], feed.first.category
|
|
18
|
+
assert_equal [feed.first], feed.items_by_category("ruby")
|
|
19
|
+
assert_equal "ruby", SimpleRSS.parse(source).first.category
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_atom_attribute_variants_preserve_order_duplicates_and_source
|
|
23
|
+
source = File.read(File.join(__dir__, "../data/atom_categories.xml"))
|
|
24
|
+
feed = SimpleRSS.parse(source, array_tags: [:category])
|
|
25
|
+
|
|
26
|
+
assert_equal ["ruby", "rails", "Ruby & RSS", "ruby"], feed.first.category
|
|
27
|
+
assert_equal [feed.first], feed.items_by_category("RAILS")
|
|
28
|
+
assert_equal [], feed.items_by_category("Human label")
|
|
29
|
+
assert_nil feed[1].category
|
|
30
|
+
assert_equal ["sports"], feed[2].category
|
|
31
|
+
assert_equal source, feed.source
|
|
32
|
+
assert_equal "ruby", SimpleRSS.parse(source).first.category
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_atom_scalar_mode_skips_missing_and_blank_terms
|
|
36
|
+
feed = parse_entry(<<~XML)
|
|
37
|
+
<category label="Label only">Not a term</category>
|
|
38
|
+
<category term="   "/>
|
|
39
|
+
<category term="first"/>
|
|
40
|
+
<category term="second"/>
|
|
41
|
+
XML
|
|
42
|
+
|
|
43
|
+
assert_equal "first", feed.first.category
|
|
44
|
+
assert_equal [feed.first], feed.items_by_category("first")
|
|
45
|
+
assert_equal [], feed.items_by_category("second")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_categories_exclude_nested_metadata_content_comments_and_cdata
|
|
49
|
+
feed = parse_entry(<<~XML, array_tags: [:category])
|
|
50
|
+
<source xml:base="https://example.com/"><category term="source"/></source>
|
|
51
|
+
<content type="xhtml"><div><category term="content"/></div></content>
|
|
52
|
+
<extension><category term="extension"/></extension>
|
|
53
|
+
<!-- <category term="comment"/> -->
|
|
54
|
+
<![CDATA[<category term="cdata"/>]]>
|
|
55
|
+
<?example <category term="instruction"/> ?>
|
|
56
|
+
<category term="direct"><category term="nested"/></category>
|
|
57
|
+
XML
|
|
58
|
+
|
|
59
|
+
assert_equal ["direct"], feed.first.category
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_categories_do_not_borrow_terms_from_neighboring_elements_or_entries
|
|
63
|
+
source = <<~XML
|
|
64
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
65
|
+
<title>Boundaries</title>
|
|
66
|
+
<category term="feed"/>
|
|
67
|
+
<entry>
|
|
68
|
+
<title>First</title>
|
|
69
|
+
<category label="Missing"><extension term="nested"/></category>
|
|
70
|
+
<extension term="neighbor"/>
|
|
71
|
+
</entry>
|
|
72
|
+
<entry><title>Second</title><category term="second"/></entry>
|
|
73
|
+
</feed>
|
|
74
|
+
XML
|
|
75
|
+
|
|
76
|
+
[SimpleRSS.parse(source), SimpleRSS.parse(source, array_tags: [:category])].each do |feed|
|
|
77
|
+
assert_nil feed.first.category
|
|
78
|
+
assert_equal [], feed.items_by_category("neighbor")
|
|
79
|
+
assert_equal [feed[1]], feed.items_by_category("second")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_namespace_prefixes_resolve_at_feed_entry_and_category_scope
|
|
84
|
+
source = <<~XML
|
|
85
|
+
<feed xmlns:atom="http://www.w3.org/2005/Atom" xmlns:Topic="http://www.w3.org/2005/Atom">
|
|
86
|
+
<title>Namespace scopes</title>
|
|
87
|
+
<atom:entry xmlns:local="http://www.w3.org/2005/Atom">
|
|
88
|
+
<title>First</title>
|
|
89
|
+
<Topic:category term="feed-scope"/>
|
|
90
|
+
<local:category term="entry-scope"/>
|
|
91
|
+
<inline:category xmlns:inline="http://www.w3.org/2005/Atom" term="category-scope"/>
|
|
92
|
+
<category xmlns="http://www.w3.org/2005/Atom" term="local-default"/>
|
|
93
|
+
<category term="unqualified">Not Atom</category>
|
|
94
|
+
</atom:entry>
|
|
95
|
+
</feed>
|
|
96
|
+
XML
|
|
97
|
+
feed = SimpleRSS.parse(source, array_tags: [:category])
|
|
98
|
+
|
|
99
|
+
assert_equal %w[feed-scope entry-scope category-scope local-default], feed.first.category
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_namespace_rebinding_and_default_resets_do_not_leak_categories
|
|
103
|
+
source = <<~XML
|
|
104
|
+
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:topic="http://www.w3.org/2005/Atom">
|
|
105
|
+
<title>Rebinding</title>
|
|
106
|
+
<entry xmlns:topic="urn:other">
|
|
107
|
+
<title>First</title>
|
|
108
|
+
<topic:category term="foreign"/>
|
|
109
|
+
<category xmlns="urn:other" term="other">Foreign text</category>
|
|
110
|
+
<category xmlns="" term="reset">Unqualified text</category>
|
|
111
|
+
<topic:category xmlns:topic="http://www.w3.org/2005/Atom" term="restored"/>
|
|
112
|
+
</entry>
|
|
113
|
+
<entry><title>Second</title><topic:category term="inherited"/></entry>
|
|
114
|
+
</feed>
|
|
115
|
+
XML
|
|
116
|
+
feed = SimpleRSS.parse(source, array_tags: [:category])
|
|
117
|
+
|
|
118
|
+
assert_equal ["restored"], feed.first.category
|
|
119
|
+
assert_equal ["inherited"], feed[1].category
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_exact_element_and_attribute_names_are_required
|
|
123
|
+
feed = parse_entry(<<~XML, array_tags: [:category])
|
|
124
|
+
<categoryish term="wrong-element"/>
|
|
125
|
+
<category data-term="wrong-attribute"/>
|
|
126
|
+
<category label="term='quoted'"/>
|
|
127
|
+
<category xmlns:other="urn:other" other:term="wrong-namespace"/>
|
|
128
|
+
<unknown:category term="undeclared"/>
|
|
129
|
+
<category term="correct"/>
|
|
130
|
+
XML
|
|
131
|
+
|
|
132
|
+
assert_equal ["correct"], feed.first.category
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def test_rss_text_cdata_scalar_and_array_filters_remain_compatible
|
|
136
|
+
source = File.read(File.join(__dir__, "../data/rss_categories.xml"))
|
|
137
|
+
scalar_feed = SimpleRSS.parse(source)
|
|
138
|
+
array_feed = SimpleRSS.parse(source, array_tags: [:category])
|
|
139
|
+
|
|
140
|
+
assert_equal "Technology", scalar_feed.first.category
|
|
141
|
+
assert_equal ["Technology", "Ruby & RSS", "Technology"], array_feed.first.category
|
|
142
|
+
assert_equal [scalar_feed.first], scalar_feed.items_by_category("tech")
|
|
143
|
+
assert_equal [array_feed.first], array_feed.items_by_category("ruby")
|
|
144
|
+
assert_equal ["Sports"], array_feed[1].category
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def test_rss_categories_exclude_embedded_markup_and_accept_atom_extensions
|
|
148
|
+
source = <<~XML
|
|
149
|
+
<rss version="2.0" xmlns:topic="http://www.w3.org/2005/Atom">
|
|
150
|
+
<channel>
|
|
151
|
+
<title>Mixed categories</title>
|
|
152
|
+
<item>
|
|
153
|
+
<title>First</title>
|
|
154
|
+
<description><![CDATA[<category>Embedded</category>]]></description>
|
|
155
|
+
<extension><category>Nested</category></extension>
|
|
156
|
+
<category term="ignored">RSS text</category>
|
|
157
|
+
<topic:category term="Atom term"/>
|
|
158
|
+
<category xmlns="urn:other">Foreign text</category>
|
|
159
|
+
</item>
|
|
160
|
+
</channel>
|
|
161
|
+
</rss>
|
|
162
|
+
XML
|
|
163
|
+
feed = SimpleRSS.parse(source, array_tags: [:category])
|
|
164
|
+
|
|
165
|
+
assert_equal ["RSS text", "Atom term"], feed.first.category
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def test_rss_namespaces_and_empty_category_behavior_are_preserved
|
|
169
|
+
source = <<~XML
|
|
170
|
+
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">
|
|
171
|
+
<channel><title>RSS 1.0</title></channel>
|
|
172
|
+
<item><title>First</title><category>Ruby</category><category></category></item>
|
|
173
|
+
<item><title>Second</title><category/></item>
|
|
174
|
+
</rdf:RDF>
|
|
175
|
+
XML
|
|
176
|
+
feed = SimpleRSS.parse(source, array_tags: [:category])
|
|
177
|
+
|
|
178
|
+
assert_equal ["Ruby", ""], feed.first.category
|
|
179
|
+
assert_nil feed[1].category
|
|
180
|
+
assert_equal "", SimpleRSS.parse(source)[1].category
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def test_namespace_declarations_in_comments_do_not_override_the_feed
|
|
184
|
+
source = <<~XML
|
|
185
|
+
<!-- <feed xmlns="urn:other"> -->
|
|
186
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
187
|
+
<title>Actual feed</title>
|
|
188
|
+
<entry><title>First</title><category term="actual"/></entry>
|
|
189
|
+
</feed>
|
|
190
|
+
XML
|
|
191
|
+
|
|
192
|
+
assert_equal "actual", SimpleRSS.parse(source).first.category
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def test_rss_channel_namespaces_do_not_apply_to_sibling_rdf_items
|
|
196
|
+
source = <<~XML
|
|
197
|
+
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">
|
|
198
|
+
<channel xmlns:topic="http://www.w3.org/2005/Atom"><title>RSS</title></channel>
|
|
199
|
+
<item><title>First</title><topic:category term="out-of-scope"/><category>RSS text</category></item>
|
|
200
|
+
</rdf:RDF>
|
|
201
|
+
XML
|
|
202
|
+
|
|
203
|
+
assert_equal ["RSS text"], SimpleRSS.parse(source, array_tags: [:category]).first.category
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def parse_entry(content, options = {})
|
|
209
|
+
source = <<~XML
|
|
210
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
211
|
+
<title>Categories</title>
|
|
212
|
+
<entry><title>Entry</title>#{content}</entry>
|
|
213
|
+
</feed>
|
|
214
|
+
XML
|
|
215
|
+
SimpleRSS.parse(source, options)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
|
|
3
|
+
class DateOrderingTest < Test::Unit::TestCase
|
|
4
|
+
def setup
|
|
5
|
+
@feed = SimpleRSS.parse(File.read(File.join(__dir__, "../data/mixed_dates.xml")))
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def test_latest_uses_valid_fallbacks_and_preserves_equal_date_order
|
|
9
|
+
assert_equal [
|
|
10
|
+
"Updated first", "Updated second", "Fractional", "Published", "Publication",
|
|
11
|
+
"Historical", "Missing first", "Blank", "Invalid"
|
|
12
|
+
], @feed.latest.map(&:title)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_latest_limits_results_without_changing_original_entries
|
|
16
|
+
original = @feed.to_hash
|
|
17
|
+
|
|
18
|
+
assert_equal ["Updated first", "Updated second", "Fractional"], @feed.latest(3).map(&:title)
|
|
19
|
+
assert_equal [], @feed.latest(0)
|
|
20
|
+
assert_same @feed.items[3], @feed.latest.first
|
|
21
|
+
assert_equal original, @feed.to_hash
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_latest_sorts_atom_entries_with_only_published_dates
|
|
25
|
+
feed = SimpleRSS.parse <<~XML
|
|
26
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
27
|
+
<title>Publication dates</title>
|
|
28
|
+
<entry><title>Newer</title><published>2026-09-03T00:00:00Z</published></entry>
|
|
29
|
+
<entry><title>Older</title><published>2026-09-01T00:00:00Z</published></entry>
|
|
30
|
+
</feed>
|
|
31
|
+
XML
|
|
32
|
+
|
|
33
|
+
assert_equal %w[Newer Older], feed.latest.map(&:title)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_items_since_uses_valid_fallbacks_and_excludes_undated_entries
|
|
37
|
+
assert_equal ["Fractional", "Updated first", "Published", "Updated second"],
|
|
38
|
+
@feed.items_since(Time.iso8601("2026-09-01T00:00:00Z")).map(&:title)
|
|
39
|
+
assert_equal ["Fractional", "Updated first", "Updated second"],
|
|
40
|
+
@feed.items_since(Time.iso8601("2026-09-02T00:00:00Z")).map(&:title)
|
|
41
|
+
assert_equal [], @feed.items_since(Time.iso8601("2026-09-04T00:00:00Z"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_merge_sorts_dated_entries_before_undated_identified_entries
|
|
45
|
+
assert_equal [
|
|
46
|
+
"Updated first", "Updated second", "Fractional", "Published", "Publication",
|
|
47
|
+
"Historical", "Blank", "Invalid", "Missing first"
|
|
48
|
+
], @feed.merge.map(&:title)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_merge_uses_fallback_dates_to_dedupe_and_keeps_unidentified_entries_last
|
|
52
|
+
other_feed = SimpleRSS.parse <<~XML
|
|
53
|
+
<rss version="2.0">
|
|
54
|
+
<channel>
|
|
55
|
+
<title>Other feed</title>
|
|
56
|
+
<item>
|
|
57
|
+
<guid>updated-first</guid>
|
|
58
|
+
<title>Replacement</title>
|
|
59
|
+
<pubDate>not-a-date</pubDate>
|
|
60
|
+
<published>2026-09-04T00:00:00Z</published>
|
|
61
|
+
</item>
|
|
62
|
+
<item><title>Unidentified newest</title><pubDate>2030-01-01T00:00:00Z</pubDate></item>
|
|
63
|
+
</channel>
|
|
64
|
+
</rss>
|
|
65
|
+
XML
|
|
66
|
+
original = @feed.to_hash
|
|
67
|
+
other_original = other_feed.to_hash
|
|
68
|
+
|
|
69
|
+
assert_equal [
|
|
70
|
+
"Replacement", "Updated second", "Fractional", "Published", "Publication",
|
|
71
|
+
"Historical", "Blank", "Invalid", "Missing first", "Unidentified newest"
|
|
72
|
+
], SimpleRSS.merge(@feed, other_feed).map(&:title)
|
|
73
|
+
assert_equal original, @feed.to_hash
|
|
74
|
+
assert_equal other_original, other_feed.to_hash
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_merge_keeps_the_first_duplicate_when_dates_are_equal
|
|
78
|
+
other_feed = SimpleRSS.parse <<~XML
|
|
79
|
+
<rss version="2.0">
|
|
80
|
+
<channel>
|
|
81
|
+
<title>Other feed</title>
|
|
82
|
+
<item>
|
|
83
|
+
<guid>updated-second</guid>
|
|
84
|
+
<title>Later duplicate</title>
|
|
85
|
+
<updated>2026-09-03T00:00:00.000000002Z</updated>
|
|
86
|
+
</item>
|
|
87
|
+
</channel>
|
|
88
|
+
</rss>
|
|
89
|
+
XML
|
|
90
|
+
|
|
91
|
+
duplicates = @feed.merge(other_feed).select { |item| item[:guid] == "updated-second" }
|
|
92
|
+
|
|
93
|
+
assert_equal ["Updated second"], duplicates.map(&:title)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
class DiscoveryDependencyTest < Test::Unit::TestCase
|
|
5
|
+
def test_core_parsing_works_without_loading_nokogiri_and_discovery_explains_the_dependency
|
|
6
|
+
script = <<~RUBY_SCRIPT
|
|
7
|
+
require "simple-rss"
|
|
8
|
+
abort "Nokogiri loaded by core" if defined?(Nokogiri)
|
|
9
|
+
Kernel.prepend(Module.new do
|
|
10
|
+
def require(path)
|
|
11
|
+
raise LoadError, "optional dependency is unavailable" if path == "nokogiri"
|
|
12
|
+
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
end)
|
|
16
|
+
feed = SimpleRSS.parse('<rss version="2.0"><channel><title>Example</title></channel></rss>')
|
|
17
|
+
abort "Core parsing failed" unless feed.title == "Example"
|
|
18
|
+
begin
|
|
19
|
+
SimpleRSS.discover("http://127.0.0.1/", timeout: 0.1)
|
|
20
|
+
abort "Expected a dependency error"
|
|
21
|
+
rescue SimpleRSS::DiscoveryDependencyError => error
|
|
22
|
+
puts error.message
|
|
23
|
+
end
|
|
24
|
+
RUBY_SCRIPT
|
|
25
|
+
output, status = Open3.capture2e(RbConfig.ruby, "-Ilib", "-e", script)
|
|
26
|
+
assert status.success?, output
|
|
27
|
+
assert_include output, 'Install the optional "nokogiri" gem'
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
require_relative "../support/http_server"
|
|
3
|
+
|
|
4
|
+
class DiscoveryTest < Test::Unit::TestCase
|
|
5
|
+
include HTTPServer
|
|
6
|
+
|
|
7
|
+
def test_discovers_advertised_feeds_without_fetching_them
|
|
8
|
+
html = '<html><head><link rel="alternate" type="application/rss+xml" title="News" href="/feed.xml"></head></html>'
|
|
9
|
+
with_server([[200, { "Content-Type" => "text/html" }, html]]) do |url, requests|
|
|
10
|
+
candidates = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
|
|
11
|
+
assert_equal [{ url: "#{url}/feed.xml", title: "News", format: :rss, media_type: "application/rss+xml", source: :html_link, verified: false }], candidates
|
|
12
|
+
assert_equal 1, requests.size
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_acceptance_corpus_preserves_formats_order_titles_and_distinct_queries
|
|
17
|
+
html = File.read(File.join(__dir__, "../data/discovery.html"))
|
|
18
|
+
with_server([[200, { "Content-Type" => "text/html; charset=utf-8" }, html]]) do |url, requests|
|
|
19
|
+
candidates = SimpleRSS.discover("#{url}/blog/index.html", network_policy: :unrestricted, timeout: 1)
|
|
20
|
+
assert_equal(["news.xml?edition=1&lang=en", "atom.xml", "feed.json", "legacy.json", "feed.rdf", "news.xml?edition=2&lang=en"], candidates.map { |candidate| candidate[:url].delete_prefix("#{url}/syndication/") })
|
|
21
|
+
assert_equal(%i[rss atom json_feed json_feed rss rss], candidates.map { |candidate| candidate[:format] })
|
|
22
|
+
assert_equal "News & updates", candidates.first[:title]
|
|
23
|
+
assert_equal "application/rss+xml", candidates.first[:media_type]
|
|
24
|
+
assert_equal [false], candidates.map { |candidate| candidate[:verified] }.uniq
|
|
25
|
+
assert_equal [:html_link], candidates.map { |candidate| candidate[:source] }.uniq
|
|
26
|
+
assert_equal 1, requests.size
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_direct_empty_feeds_are_verified_without_instance_valid
|
|
31
|
+
sources = {
|
|
32
|
+
rss: '<rss version="2.0"><channel><title>RSS</title></channel></rss>',
|
|
33
|
+
atom: '<feed xmlns="http://www.w3.org/2005/Atom"><title>Atom</title></feed>',
|
|
34
|
+
json_feed: '{"version":"https://jsonfeed.org/version/1.1","title":"JSON Feed","items":[]}'
|
|
35
|
+
}
|
|
36
|
+
sources.each do |format, body|
|
|
37
|
+
with_server([[200, { "Content-Type" => "text/plain" }, body]]) do |url, requests|
|
|
38
|
+
candidate = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).first
|
|
39
|
+
assert_equal "#{url}/", candidate[:url]
|
|
40
|
+
assert_equal format, candidate[:format]
|
|
41
|
+
assert_equal :document, candidate[:source]
|
|
42
|
+
assert_equal true, candidate[:verified]
|
|
43
|
+
assert_equal 1, requests.size
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_empty_self_closing_feed_containers_are_recognized
|
|
49
|
+
['<rss version="2.0"><channel/></rss>', '<feed xmlns="http://www.w3.org/2005/Atom"/>'].each do |body|
|
|
50
|
+
with_server([[200, {}, body]]) do |url, _requests|
|
|
51
|
+
candidates = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
|
|
52
|
+
assert_equal true, candidates.first[:verified]
|
|
53
|
+
assert_empty SimpleRSS.parse(body).normalized_entries
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_rdf_feed_and_xhtml_metadata_are_supported
|
|
59
|
+
rdf = '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/"><channel><title>RDF</title></channel></rdf:RDF>'
|
|
60
|
+
with_server([[200, {}, rdf]]) do |url, _requests|
|
|
61
|
+
candidate = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).first
|
|
62
|
+
assert_equal :rss, candidate[:format]
|
|
63
|
+
assert_equal "RDF", candidate[:title]
|
|
64
|
+
end
|
|
65
|
+
xhtml = '<html xmlns="http://www.w3.org/1999/xhtml"><head><link rel="alternate" type="application/atom+xml" href="feed.xml" /></head><body /></html>'
|
|
66
|
+
with_server([[200, { "Content-Type" => "application/xhtml+xml" }, xhtml]]) do |url, _requests|
|
|
67
|
+
assert_equal(["#{url}/feed.xml"], SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).map { |candidate| candidate[:url] })
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_redirects_base_urls_and_protocol_relative_links_preserve_queries
|
|
72
|
+
html = '<html><head><base href="../feeds/"><link rel=alternate type=application/rss+xml href="news.xml?edition=1#items"><link rel=alternate type=application/atom+xml href="//example.com/atom?q=1#entry"></head></html>'
|
|
73
|
+
responses = [[302, { "Location" => "../pages/index?view=1#head" }, ""], [200, { "Content-Type" => "text/html" }, html]]
|
|
74
|
+
with_server(responses) do |url, requests|
|
|
75
|
+
candidates = SimpleRSS.discover("#{url}/start/page", network_policy: :unrestricted, timeout: 1)
|
|
76
|
+
assert_equal(["#{url}/feeds/news.xml?edition=1", "http://example.com/atom?q=1"], candidates.map { |candidate| candidate[:url] })
|
|
77
|
+
assert_equal ["GET /start/page HTTP/1.1", "GET /pages/index?view=1 HTTP/1.1"], requests.map(&:first)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_invalid_links_are_ignored_and_invalid_first_base_uses_document_url
|
|
82
|
+
html = '<html><head><base href="javascript:wrong"><base href="https://wrong.example.com/">' \
|
|
83
|
+
'<link rel=alternate type=application/rss+xml href="valid.xml">' \
|
|
84
|
+
'<link rel=alternate type=application/rss+xml href="javascript:wrong">' \
|
|
85
|
+
'<link rel=alternate type=application/rss+xml href="file:///tmp/feed.xml">' \
|
|
86
|
+
'<link rel=alternate type=application/rss+xml href="http://user:password@example.com/feed">' \
|
|
87
|
+
'<link rel=alternate type=application/rss+xml href="http://[broken">' \
|
|
88
|
+
'<link rel=alternate type=application/rss+xml href=" "></head></html>'
|
|
89
|
+
with_server([[200, { "Content-Type" => "text/html" }, html]]) do |url, requests|
|
|
90
|
+
assert_equal(["#{url}/valid.xml"], SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).map { |candidate| candidate[:url] })
|
|
91
|
+
assert_equal 1, requests.size
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_omitted_head_tags_and_html_entities_are_parsed_as_html
|
|
96
|
+
html = '<!doctype html><title>Example</title><link rel=alternate type=application/rss+xml href="/feed?q=1&b=2" title="Café & ☀"><p>Post</p>'
|
|
97
|
+
with_server([[200, { "Content-Type" => "text/html; charset=utf-8" }, html]]) do |url, _requests|
|
|
98
|
+
candidate = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).first
|
|
99
|
+
assert_equal "#{url}/feed?q=1&b=2", candidate[:url]
|
|
100
|
+
assert_equal "Café & ☀", candidate[:title]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def test_no_feeds_is_distinct_from_http_and_parse_failures
|
|
105
|
+
with_server([[200, { "Content-Type" => "text/html" }, "<p>No feeds here</p>"]]) do |url, _requests|
|
|
106
|
+
assert_empty SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
|
|
107
|
+
end
|
|
108
|
+
with_server([[404, { "Content-Type" => "text/html" }, "<p>Not found</p>"]]) do |url, _requests|
|
|
109
|
+
error = assert_raise(SimpleRSS::HTTPError) { SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1) }
|
|
110
|
+
assert_equal 404, error.status_code
|
|
111
|
+
end
|
|
112
|
+
['{"version":', '{"hello":"world"}', '<rss version="2.0">broken</rss>', "not a feed", ""].each do |body|
|
|
113
|
+
with_server([[200, { "Content-Type" => "application/octet-stream" }, body]]) do |url, _requests|
|
|
114
|
+
assert_raise(SimpleRSS::DiscoveryError, body) { SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def test_feed_markup_in_html_scripts_does_not_become_a_direct_feed
|
|
120
|
+
html = '<html><head><script type="text/plain"><rss version="2.0"><channel><title>Wrong</title></channel></rss></script></head><body></body></html>'
|
|
121
|
+
with_server([[200, { "Content-Type" => "application/rss+xml" }, html]]) do |url, _requests|
|
|
122
|
+
assert_empty SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def test_foreign_namespaces_do_not_fabricate_verified_feeds
|
|
127
|
+
['<feed xmlns="urn:component"/>', '<rss xmlns="urn:component"><channel/></rss>'].each do |body|
|
|
128
|
+
with_server([[200, { "Content-Type" => "text/html" }, body]]) do |url, _requests|
|
|
129
|
+
assert_empty SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def test_parser_limits_fail_clearly
|
|
135
|
+
html = "<html><head></head><body>" + ("<div>" * 140)
|
|
136
|
+
with_server([[200, { "Content-Type" => "text/html" }, html]]) do |url, _requests|
|
|
137
|
+
assert_raise(SimpleRSS::DiscoveryError) { SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1) }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_advertised_private_urls_are_only_unverified_metadata
|
|
142
|
+
html = '<html><head><link rel=alternate type=application/rss+xml href="http://127.0.0.1:9/feed"></head></html>'
|
|
143
|
+
with_server([[200, { "Content-Type" => "text/html" }, html]]) do |url, requests|
|
|
144
|
+
candidate = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).first
|
|
145
|
+
assert_equal false, candidate[:verified]
|
|
146
|
+
assert_equal "http://127.0.0.1:9/feed", candidate[:url]
|
|
147
|
+
assert_raise(SimpleRSS::PolicyError) { SimpleRSS.fetch(candidate[:url], network_policy: :public) }
|
|
148
|
+
assert_equal 1, requests.size
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|