simple-rss 2.1.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 +437 -71
- 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/feed_merging_and_diffing_test.rb +140 -0
- data/test/base/feedbag_integration_test.rb +21 -0
- data/test/base/fetch_integration_test.rb +25 -0
- data/test/base/fetch_test.rb +0 -27
- data/test/base/filtering_and_validation_test.rb +187 -0
- data/test/base/json_feed_test.rb +372 -0
- data/test/base/media_and_enclosure_helpers_test.rb +84 -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 +46 -10
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
|
|
3
|
+
class NormalizedEntriesTest < Test::Unit::TestCase
|
|
4
|
+
def test_equivalent_rss_and_atom_entries_share_one_interface
|
|
5
|
+
entries = %w[rss atom].map { |format| fixture(format).normalized_entries.first }
|
|
6
|
+
|
|
7
|
+
entries.each do |entry|
|
|
8
|
+
assert_equal "opaque:%2F:001", entry.identifier
|
|
9
|
+
assert_equal "Ruby & feeds", entry.title
|
|
10
|
+
assert_equal "https://example.com/blog/article?one=1&two=2", entry.url
|
|
11
|
+
assert_equal Time.utc(2026, 9, 12, 10), entry.published_at
|
|
12
|
+
assert_equal Time.utc(2026, 9, 13, 11), entry.updated_at
|
|
13
|
+
assert_equal "<p>Full & complete.</p>", entry.content_html
|
|
14
|
+
assert_nil entry.content_text
|
|
15
|
+
assert_equal "<p>A summary.</p>", entry.summary
|
|
16
|
+
assert_equal :html, entry.summary_type
|
|
17
|
+
assert_equal %w[ruby feeds], entry.categories
|
|
18
|
+
assert_equal(["https://example.com/audio/one.mp3", "https://example.com/images/two.png"], entry.attachments.map { |attachment| attachment[:url] })
|
|
19
|
+
assert_equal(%w[audio/mpeg image/png], entry.attachments.map { |attachment| attachment[:media_type] })
|
|
20
|
+
assert_equal([1234, 4321], entry.attachments.map { |attachment| attachment[:size_in_bytes] })
|
|
21
|
+
assert_empty entry.issues
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_normalization_preserves_raw_access_serialization_and_configuration
|
|
26
|
+
feed = fixture("atom")
|
|
27
|
+
before = [feed.source.dup, feed.to_hash, feed.to_json, feed.to_xml, SimpleRSS.item_tags.dup, SimpleRSS.feed_tags.dup]
|
|
28
|
+
|
|
29
|
+
first = feed.normalized_entries.first
|
|
30
|
+
second = feed.normalized_entries.first
|
|
31
|
+
|
|
32
|
+
assert_equal first.to_h, second.to_h
|
|
33
|
+
assert_equal feed.first, first.raw
|
|
34
|
+
assert_not_same feed.first, first.raw
|
|
35
|
+
assert_equal before, [feed.source, feed.to_hash, feed.to_json, feed.to_xml, SimpleRSS.item_tags, SimpleRSS.feed_tags]
|
|
36
|
+
assert_raise(FrozenError) { first.categories << "changed" }
|
|
37
|
+
assert_raise(FrozenError) { first.raw[:title].replace("changed") }
|
|
38
|
+
assert_equal "Ruby & feeds", feed.first.title
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_link_selection_honors_default_and_iana_relations_and_preserves_metadata
|
|
42
|
+
entry = atom_entry(<<~XML)
|
|
43
|
+
<link rel="self" href="https://example.com/api/1"/>
|
|
44
|
+
<link type="application/pdf" href="https://example.com/article.pdf"/>
|
|
45
|
+
<link rel="http://www.iana.org/assignments/relation/alternate" type="text/html; charset=utf-8"
|
|
46
|
+
title="Web & mobile" hreflang="en" href="https://example.com/article">
|
|
47
|
+
<extra href="https://wrong.example.com/"/>
|
|
48
|
+
</link>
|
|
49
|
+
XML
|
|
50
|
+
|
|
51
|
+
assert_equal "https://example.com/article", entry.url
|
|
52
|
+
assert_equal 3, entry.links.size
|
|
53
|
+
assert_equal "Web & mobile", entry.links.last[:raw][:attributes]["title"]
|
|
54
|
+
assert_equal "en", entry.links.last[:raw][:attributes]["hreflang"]
|
|
55
|
+
assert_include entry.links.last[:raw][:content], "wrong.example.com"
|
|
56
|
+
assert_nil atom_entry('<link rel="self" href="https://example.com/api/1"/>').url
|
|
57
|
+
assert_equal "https://example.com/default", atom_entry('<link href="https://example.com/default"/>').url
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_relative_urls_use_source_url_and_each_xml_base_without_changing_identifiers
|
|
61
|
+
source = <<~XML
|
|
62
|
+
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="../blog/">
|
|
63
|
+
<title>Base URLs</title>
|
|
64
|
+
<entry xml:base="posts/">
|
|
65
|
+
<id>../opaque%2Fid</id>
|
|
66
|
+
<link xml:base="../../articles/" href="one?tag=ruby&page=2#section"/>
|
|
67
|
+
<link rel="enclosure" href="//cdn.example.com/one.mp3"/>
|
|
68
|
+
</entry>
|
|
69
|
+
</feed>
|
|
70
|
+
XML
|
|
71
|
+
feed = SimpleRSS.parse(source, source_url: "https://example.com/feeds/current.xml")
|
|
72
|
+
entry = feed.normalized_entries.first
|
|
73
|
+
|
|
74
|
+
assert_equal "https://example.com/articles/one?tag=ruby&page=2#section", entry.url
|
|
75
|
+
assert_equal "https://cdn.example.com/one.mp3", entry.attachments.first[:url]
|
|
76
|
+
assert_equal "../opaque%2Fid", entry.identifier
|
|
77
|
+
assert_equal "https://override.example.com/articles/one?tag=ruby&page=2#section",
|
|
78
|
+
feed.normalized_entries(source_url: "https://override.example.com/feeds/current.xml").first.url
|
|
79
|
+
assert_equal "https://example.com/feeds/current.xml", feed.source_url
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_relative_and_invalid_urls_remain_inspectable_without_a_usable_base
|
|
83
|
+
entry = atom_entry('<link href="../article"/>')
|
|
84
|
+
assert_equal "../article", entry.url
|
|
85
|
+
assert_include entry.issues, { field: :url, code: :relative_url_without_base, value: "../article", source: "link" }
|
|
86
|
+
|
|
87
|
+
entry = atom_entry('<link href="http://[broken"/>')
|
|
88
|
+
assert_equal "http://[broken", entry.url
|
|
89
|
+
assert_equal :invalid_url, entry.issues.first[:code]
|
|
90
|
+
|
|
91
|
+
entry = atom_entry('<link xml:base="http://[broken" href="article"/>', source_url: "https://example.com/")
|
|
92
|
+
assert_equal "article", entry.url
|
|
93
|
+
assert_include entry.issues.map { |issue| issue[:field] }, :base_url
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_publication_and_update_dates_remain_distinct_and_invalid_dates_are_preserved
|
|
97
|
+
entry = atom_entry("<published>not-a-date</published><updated>2026-09-13T11:00:00Z</updated>")
|
|
98
|
+
|
|
99
|
+
assert_nil entry.published_at
|
|
100
|
+
assert_equal Time.utc(2026, 9, 13, 11), entry.updated_at
|
|
101
|
+
assert_equal entry.updated_at, entry.effective_at
|
|
102
|
+
assert_equal "not-a-date", entry.raw[:published]
|
|
103
|
+
assert_include entry.issues, { field: :published_at, code: :invalid_date, value: "not-a-date", source: "published" }
|
|
104
|
+
assert_nil atom_entry("<updated>broken</updated>").effective_at
|
|
105
|
+
|
|
106
|
+
entry = rss_entry("<pubDate>broken</pubDate><dc:date>2026-09-12T10:00:00Z</dc:date><modified>broken too</modified>")
|
|
107
|
+
assert_equal Time.utc(2026, 9, 12, 10), entry.published_at
|
|
108
|
+
assert_nil entry.updated_at
|
|
109
|
+
assert_equal "dc:date", entry.field_sources[:published_at]
|
|
110
|
+
assert_equal 2, entry.issues.size
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_atom_content_types_preserve_text_html_cdata_and_summary_boundaries
|
|
114
|
+
entry = atom_entry("<content><b>literal & text</b></content><summary>Short version</summary>")
|
|
115
|
+
assert_equal "<b>literal & text</b>", entry.content_text
|
|
116
|
+
assert_nil entry.content_html
|
|
117
|
+
assert_equal "Short version", entry.summary
|
|
118
|
+
assert_equal :text, entry.summary_type
|
|
119
|
+
|
|
120
|
+
entry = atom_entry('<content type="html"><![CDATA[<p>A & B</p>]]></content>')
|
|
121
|
+
assert_equal "<p>A & B</p>", entry.content_html
|
|
122
|
+
assert_nil entry.content_text
|
|
123
|
+
assert_equal "content", entry.field_sources[:content_html]
|
|
124
|
+
|
|
125
|
+
entry = atom_entry('<summary type="html"><p>Summary only</p></summary>')
|
|
126
|
+
assert_nil entry.content_html
|
|
127
|
+
assert_nil entry.content_text
|
|
128
|
+
assert_equal "<p>Summary only</p>", entry.summary
|
|
129
|
+
assert_nil rss_entry("<description>Summary only</description>").content_text
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def test_external_unsupported_and_invalid_text_content_do_not_become_plain_text
|
|
133
|
+
entry = atom_entry('<content type="text/plain" src="https://example.com/full.txt">Ignored inline body</content>')
|
|
134
|
+
assert_equal "https://example.com/full.txt", entry.content_url
|
|
135
|
+
assert_nil entry.content_text
|
|
136
|
+
assert_nil entry.content_html
|
|
137
|
+
|
|
138
|
+
entry = atom_entry('<content type="application/octet-stream">YWJj</content>')
|
|
139
|
+
assert_nil entry.content_text
|
|
140
|
+
assert_nil entry.content_html
|
|
141
|
+
assert_equal "YWJj", entry.raw[:content]
|
|
142
|
+
assert_equal :unsupported_content_type, entry.issues.first[:code]
|
|
143
|
+
|
|
144
|
+
entry = atom_entry('<content type="text"><b>Actual markup</b></content>')
|
|
145
|
+
assert_nil entry.content_text
|
|
146
|
+
assert_equal :unexpected_markup, entry.issues.first[:code]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def test_xhtml_content_excludes_its_container_and_retains_its_base
|
|
150
|
+
entry = atom_entry(<<~XML)
|
|
151
|
+
<content type="xhtml" xml:base="https://example.com/articles/">
|
|
152
|
+
<div xmlns="http://www.w3.org/1999/xhtml"><p>A & B <a href="one">link</a></p></div>
|
|
153
|
+
</content>
|
|
154
|
+
XML
|
|
155
|
+
assert_equal '<p>A & B <a href="one">link</a></p>', entry.content_html
|
|
156
|
+
assert_equal "https://example.com/articles/", entry.content_base_url
|
|
157
|
+
assert_nil entry.content_text
|
|
158
|
+
assert_empty entry.issues
|
|
159
|
+
|
|
160
|
+
entry = atom_entry('<content type="xhtml"><div xmlns="urn:foreign">Wrong namespace</div></content>')
|
|
161
|
+
assert_nil entry.content_html
|
|
162
|
+
assert_equal :invalid_xhtml, entry.issues.first[:code]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def test_custom_full_text_mappings_are_local_and_take_precedence_without_global_tags
|
|
166
|
+
source = <<~XML
|
|
167
|
+
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:body="urn:example:body">
|
|
168
|
+
<channel><title>Custom content</title><item>
|
|
169
|
+
<description>Summary</description>
|
|
170
|
+
<content:encoded><![CDATA[<p>Default body</p>]]></content:encoded>
|
|
171
|
+
<full-text><![CDATA[<p>Complete body</p>]]></full-text>
|
|
172
|
+
<body:plain>Complete text</body:plain>
|
|
173
|
+
<extension><full-text>Nested body</full-text></extension>
|
|
174
|
+
</item></channel>
|
|
175
|
+
</rss>
|
|
176
|
+
XML
|
|
177
|
+
tags = [SimpleRSS.feed_tags.dup, SimpleRSS.item_tags.dup]
|
|
178
|
+
feed = SimpleRSS.parse(source)
|
|
179
|
+
entry = feed.normalized_entries(mappings: { content_html: "full-text", content_text: "{urn:example:body}plain" }).first
|
|
180
|
+
|
|
181
|
+
assert_equal "<p>Complete body</p>", entry.content_html
|
|
182
|
+
assert_equal "Complete text", entry.content_text
|
|
183
|
+
assert_equal "Summary", entry.summary
|
|
184
|
+
assert_equal "full-text", entry.field_sources[:content_html]
|
|
185
|
+
assert_equal "body:plain", entry.field_sources[:content_text]
|
|
186
|
+
assert_include entry.raw_xml, "<full-text>"
|
|
187
|
+
assert_equal "<p>Default body</p>", feed.normalized_entries.first.content_html
|
|
188
|
+
assert_equal "<p>Default body</p>", SimpleRSS.parse(source).normalized_entries.first.content_html
|
|
189
|
+
assert_equal tags, [SimpleRSS.feed_tags, SimpleRSS.item_tags]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def test_categories_preserve_labels_schemes_duplicates_and_source_aware_keywords
|
|
193
|
+
entry = fixture("atom").normalized_entries.first
|
|
194
|
+
assert_equal %w[ruby feeds], entry.categories
|
|
195
|
+
assert_equal(%w[ruby feeds ruby], entry.category_details.map { |category| category[:term] })
|
|
196
|
+
assert_equal "Ruby language", entry.category_details.first[:label]
|
|
197
|
+
assert_equal "urn:topics", entry.category_details.first[:scheme]
|
|
198
|
+
assert_equal "ruby", entry.category_details.first[:raw][:attributes]["term"]
|
|
199
|
+
|
|
200
|
+
source = <<~XML
|
|
201
|
+
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
|
|
202
|
+
<channel><title>Keywords</title><item>
|
|
203
|
+
<category>ruby, rails</category><dc:subject>ruby; feeds</dc:subject>
|
|
204
|
+
<media:keywords>audio, video</media:keywords><keywords>one | two</keywords>
|
|
205
|
+
</item></channel>
|
|
206
|
+
</rss>
|
|
207
|
+
XML
|
|
208
|
+
feed = SimpleRSS.parse(source)
|
|
209
|
+
assert_equal ["ruby, rails", "ruby; feeds", "audio, video"], feed.normalized_entries.first.categories
|
|
210
|
+
mappings = { categories: [{ tag: "dc:subject", separator: ";" }, { tag: "keywords", separator: "|" }] }
|
|
211
|
+
assert_equal ["ruby, rails", "ruby", "feeds", "audio, video", "one", "two"], feed.normalized_entries(mappings: mappings).first.categories
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def test_multiple_attachments_keep_attributes_and_invalid_numbers_with_their_urls
|
|
215
|
+
entry = rss_entry(<<~XML)
|
|
216
|
+
<enclosure url="https://example.com/one" type="audio/mpeg" length="0"/>
|
|
217
|
+
<media:group xml:base="https://example.com/media/">
|
|
218
|
+
<media:content url="two" type="video/mp4" fileSize="123" duration="12.5"/>
|
|
219
|
+
<media:content url="three" type="image/png" fileSize="no-size" duration="forever"/>
|
|
220
|
+
</media:group>
|
|
221
|
+
<description><![CDATA[<enclosure url="https://wrong.example.com/"/>]]></description>
|
|
222
|
+
XML
|
|
223
|
+
first, second, third = entry.attachments
|
|
224
|
+
assert_equal 3, entry.attachments.size
|
|
225
|
+
assert_equal 0, first[:size_in_bytes]
|
|
226
|
+
assert_equal "https://example.com/media/two", second[:url]
|
|
227
|
+
assert_equal "video/mp4", second[:media_type]
|
|
228
|
+
assert_equal 123, second[:size_in_bytes]
|
|
229
|
+
assert_equal 12.5, second[:duration_in_seconds]
|
|
230
|
+
assert_equal "https://example.com/media/three", third[:url]
|
|
231
|
+
assert_nil third[:size_in_bytes]
|
|
232
|
+
assert_nil third[:duration_in_seconds]
|
|
233
|
+
assert_equal "no-size", third[:raw][:attributes]["fileSize"]
|
|
234
|
+
assert_equal "forever", third[:raw][:attributes]["duration"]
|
|
235
|
+
assert_equal(%i[size_in_bytes duration_in_seconds], entry.issues.map { |issue| issue[:field] })
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def test_single_attachment_can_use_itunes_duration_without_applying_it_to_multiple_files
|
|
239
|
+
entry = rss_entry('<enclosure url="https://example.com/one"/><itunes:duration>1:02:03</itunes:duration>')
|
|
240
|
+
assert_equal 3723, entry.attachments.first[:duration_in_seconds]
|
|
241
|
+
assert_equal "itunes:duration", entry.attachments.first[:raw_duration][:name]
|
|
242
|
+
|
|
243
|
+
entry = rss_entry('<enclosure url="https://example.com/one"/><itunes:duration>1:99</itunes:duration>')
|
|
244
|
+
assert_nil entry.attachments.first[:duration_in_seconds]
|
|
245
|
+
assert_equal :invalid_number, entry.issues.first[:code]
|
|
246
|
+
|
|
247
|
+
entry = rss_entry('<enclosure url="https://example.com/one"/><enclosure url="https://example.com/two"/><itunes:duration>42</itunes:duration>')
|
|
248
|
+
assert_equal([nil, nil], entry.attachments.map { |attachment| attachment[:duration_in_seconds] })
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def test_nested_foreign_and_commented_metadata_cannot_supply_normalized_fields
|
|
252
|
+
entry = atom_entry(<<~XML)
|
|
253
|
+
<!-- <link href="https://wrong.example.com/comment"/><category term="comment"/> -->
|
|
254
|
+
<![CDATA[<content type="html">Wrong body</content>]]>
|
|
255
|
+
<source><id>Wrong id</id><link href="https://wrong.example.com/source"/><category term="source"/></source>
|
|
256
|
+
<content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><link href="https://wrong.example.com/body"/></div></content>
|
|
257
|
+
<id xmlns="urn:foreign">Foreign id</id>
|
|
258
|
+
<link xmlns="urn:foreign" href="https://wrong.example.com/foreign"/>
|
|
259
|
+
<category xmlns="" term="reset">Reset</category>
|
|
260
|
+
<published xmlns="urn:foreign">2026-01-01T00:00:00Z</published>
|
|
261
|
+
<id>Actual id</id><category term="actual"/>
|
|
262
|
+
XML
|
|
263
|
+
assert_equal "Actual id", entry.identifier
|
|
264
|
+
assert_nil entry.url
|
|
265
|
+
assert_nil entry.published_at
|
|
266
|
+
assert_equal ["actual"], entry.categories
|
|
267
|
+
assert_empty entry.attachments
|
|
268
|
+
assert_empty entry.links
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def test_entry_order_and_deduplication_keep_original_xml_bound_to_the_correct_hash
|
|
272
|
+
source = <<~XML
|
|
273
|
+
<rss version="2.0"><channel><title>Identical raw items</title>
|
|
274
|
+
<item><guid>same</guid><full-text>First body</full-text></item>
|
|
275
|
+
<item><guid>same</guid><full-text>Second body</full-text></item>
|
|
276
|
+
</channel></rss>
|
|
277
|
+
XML
|
|
278
|
+
feed = SimpleRSS.parse(source)
|
|
279
|
+
mappings = { content_text: "full-text" }
|
|
280
|
+
assert_equal ["First body", "Second body"], feed.normalized_entries(mappings: mappings).map(&:content_text)
|
|
281
|
+
feed.items.reverse!
|
|
282
|
+
assert_equal ["Second body", "First body"], feed.normalized_entries(mappings: mappings).map(&:content_text)
|
|
283
|
+
feed.dedupe
|
|
284
|
+
assert_equal ["Second body"], feed.normalized_entries(mappings: mappings).map(&:content_text)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def test_empty_fields_do_not_invent_identity_content_dates_or_collections
|
|
288
|
+
entry = atom_entry('<title></title><category term=" "/><link href=""/>')
|
|
289
|
+
assert_nil entry.identifier
|
|
290
|
+
assert_nil entry.url
|
|
291
|
+
assert_nil entry.title
|
|
292
|
+
assert_nil entry.published_at
|
|
293
|
+
assert_nil entry.updated_at
|
|
294
|
+
assert_nil entry.content_html
|
|
295
|
+
assert_nil entry.content_text
|
|
296
|
+
assert_nil entry.summary
|
|
297
|
+
assert_empty entry.categories
|
|
298
|
+
assert_empty entry.attachments
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def test_invalid_mapping_configuration_fails_with_clear_errors
|
|
302
|
+
feed = fixture("rss")
|
|
303
|
+
[nil, { unknown: "tag" }, { content_html: [] }, { content_text: "" }, { categories: "keywords" },
|
|
304
|
+
{ categories: [{ tag: "keywords", separator: "" }] }, { categories: [{ tag: nil }] }].each do |mappings|
|
|
305
|
+
assert_raise(ArgumentError) { feed.normalized_entries(mappings: mappings) }
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def test_rdf_item_bases_and_namespaces_do_not_inherit_from_a_sibling_channel
|
|
310
|
+
source = <<~XML
|
|
311
|
+
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/"
|
|
312
|
+
xmlns:date="http://purl.org/dc/elements/1.1/" xml:base="https://example.com/">
|
|
313
|
+
<channel xml:base="wrong/" xmlns:date="urn:foreign"><title>Example</title></channel>
|
|
314
|
+
<item><title>First</title><link>article</link><date:date>2026-09-12T10:00:00Z</date:date><date:subject>ruby</date:subject></item>
|
|
315
|
+
</rdf:RDF>
|
|
316
|
+
XML
|
|
317
|
+
entry = SimpleRSS.parse(source).normalized_entries.first
|
|
318
|
+
assert_equal "https://example.com/article", entry.url
|
|
319
|
+
assert_equal Time.utc(2026, 9, 12, 10), entry.published_at
|
|
320
|
+
assert_equal ["ruby"], entry.categories
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def test_plain_xml_text_ignores_comments_and_processing_instructions
|
|
324
|
+
entry = atom_entry("<id>opaque<!-- not identity -->id</id><content>Before<!-- not text -->after<?note ignored?></content>")
|
|
325
|
+
assert_equal "opaqueid", entry.identifier
|
|
326
|
+
assert_equal "Beforeafter", entry.content_text
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def test_atom_authors_inherit_from_source_then_feed_and_keep_uri_bases
|
|
330
|
+
source = <<~XML
|
|
331
|
+
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://example.com/">
|
|
332
|
+
<title>Authors</title>
|
|
333
|
+
<author><name>Editorial team</name><uri>about</uri></author>
|
|
334
|
+
<entry><id>one</id></entry>
|
|
335
|
+
<entry><id>two</id><source xml:base="source/"><author><name>Source team</name><uri>about</uri></author></source></entry>
|
|
336
|
+
<entry><id>three</id><author><name>Entry team</name><uri>entry</uri></author><author><name>Guest team</name></author></entry>
|
|
337
|
+
</feed>
|
|
338
|
+
XML
|
|
339
|
+
entries = SimpleRSS.parse(source).normalized_entries
|
|
340
|
+
assert_equal(["Editorial team"], entries[0].authors.map { |author| author[:name] })
|
|
341
|
+
assert_equal "https://example.com/about", entries[0].authors.first[:url]
|
|
342
|
+
assert_equal(["Source team"], entries[1].authors.map { |author| author[:name] })
|
|
343
|
+
assert_equal "https://example.com/source/about", entries[1].authors.first[:url]
|
|
344
|
+
assert_equal(["Entry team", "Guest team"], entries[2].authors.map { |author| author[:name] })
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def test_prefixed_xhtml_preserves_content_and_the_div_base_without_prefixing_html_tags
|
|
348
|
+
entry = atom_entry(<<~XML)
|
|
349
|
+
<content type="xhtml" xml:base="https://example.com/">
|
|
350
|
+
<html:div xmlns:html="http://www.w3.org/1999/xhtml" xml:base="articles/">
|
|
351
|
+
<html:p>Read <html:a href="one">this</html:a> & more.</html:p>
|
|
352
|
+
</html:div>
|
|
353
|
+
</content>
|
|
354
|
+
XML
|
|
355
|
+
assert_equal '<p>Read <a href="one">this</a> & more.</p>', entry.content_html
|
|
356
|
+
assert_equal "https://example.com/articles/", entry.content_base_url
|
|
357
|
+
assert_equal "content", entry.field_sources[:content_html]
|
|
358
|
+
assert_include entry.raw_xml, "<html:div"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def test_content_media_type_parameters_and_rss_link_attributes_do_not_change_meaning
|
|
362
|
+
entry = atom_entry('<content type="text/html; charset=utf-8"><p>Body</p></content>')
|
|
363
|
+
assert_equal "<p>Body</p>", entry.content_html
|
|
364
|
+
assert_nil entry.content_text
|
|
365
|
+
|
|
366
|
+
entry = rss_entry('<link rel="self">https://example.com/article</link>')
|
|
367
|
+
assert_equal "https://example.com/article", entry.url
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def test_media_groups_only_contribute_media_content_attachments
|
|
371
|
+
entry = rss_entry(<<~XML)
|
|
372
|
+
<media:group>
|
|
373
|
+
<enclosure url="https://wrong.example.com/nested-rss"/>
|
|
374
|
+
<link xmlns="http://www.w3.org/2005/Atom" rel="enclosure" href="https://wrong.example.com/nested-atom"/>
|
|
375
|
+
<media:content url="https://example.com/video" type="video/mp4"/>
|
|
376
|
+
</media:group>
|
|
377
|
+
XML
|
|
378
|
+
assert_equal(["https://example.com/video"], entry.attachments.map { |attachment| attachment[:url] })
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def test_invalid_attachment_duration_is_not_replaced_by_an_item_level_duration
|
|
382
|
+
entry = rss_entry(<<~XML)
|
|
383
|
+
<media:content url="https://example.com/video" duration="unknown"/>
|
|
384
|
+
<itunes:duration>42</itunes:duration>
|
|
385
|
+
XML
|
|
386
|
+
assert_nil entry.attachments.first[:duration_in_seconds]
|
|
387
|
+
assert_equal "unknown", entry.attachments.first[:raw][:attributes]["duration"]
|
|
388
|
+
assert_equal :invalid_number, entry.issues.first[:code]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
private
|
|
392
|
+
|
|
393
|
+
def fixture(format)
|
|
394
|
+
SimpleRSS.parse(File.read(File.join(__dir__, "../data/normalized_#{format}.xml")))
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def atom_entry(content, options = {})
|
|
398
|
+
SimpleRSS.parse('<feed xmlns="http://www.w3.org/2005/Atom"><title>Example</title><entry>' + content + "</entry></feed>", options).normalized_entries.first
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def rss_entry(content)
|
|
402
|
+
source = <<~XML
|
|
403
|
+
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"
|
|
404
|
+
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
|
405
|
+
<channel><title>Example</title><item>#{content}</item></channel>
|
|
406
|
+
</rss>
|
|
407
|
+
XML
|
|
408
|
+
SimpleRSS.parse(source).normalized_entries.first
|
|
409
|
+
end
|
|
410
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
require "socket"
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
class NormalizedFetchTest < Test::Unit::TestCase
|
|
7
|
+
def test_fetch_uses_the_final_response_url_after_relative_redirects
|
|
8
|
+
body = '<feed xmlns="http://www.w3.org/2005/Atom"><title>Example</title><entry><link href="article"/>' \
|
|
9
|
+
'<content type="text/plain" src="full.txt"/></entry></feed>'
|
|
10
|
+
responses = [[302, { "Location" => "../feeds/final.xml" }, ""], [200, { "Content-Type" => "application/atom+xml" }, body]]
|
|
11
|
+
with_server(responses) do |base_url, requests|
|
|
12
|
+
feed = SimpleRSS.fetch("#{base_url}/start/feed.xml", timeout: 1)
|
|
13
|
+
entry = feed.normalized_entries.first
|
|
14
|
+
|
|
15
|
+
assert_equal "#{base_url}/feeds/final.xml", feed.source_url
|
|
16
|
+
assert_equal "#{base_url}/feeds/article", entry.url
|
|
17
|
+
assert_equal "#{base_url}/feeds/full.txt", entry.content_url
|
|
18
|
+
assert_nil entry.content_text
|
|
19
|
+
assert_equal ["GET /start/feed.xml HTTP/1.1", "GET /feeds/final.xml HTTP/1.1"], requests.map(&:first)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_fetch_retains_conditional_get_and_parse_options
|
|
24
|
+
body = '<rss version="2.0"><channel><title>Example</title><item><link>article</link><category>ruby</category>' \
|
|
25
|
+
"<category>feeds</category></item></channel></rss>"
|
|
26
|
+
headers = { "ETag" => '"version-1"', "Last-Modified" => "Sat, 12 Sep 2026 10:00:00 GMT" }
|
|
27
|
+
with_server([[200, headers, body], [304, headers, ""]]) do |base_url, requests|
|
|
28
|
+
feed = SimpleRSS.fetch("#{base_url}/feed.xml", timeout: 1, array_tags: [:category])
|
|
29
|
+
|
|
30
|
+
assert_equal '"version-1"', feed.etag
|
|
31
|
+
assert_equal headers["Last-Modified"], feed.last_modified
|
|
32
|
+
assert_equal %w[ruby feeds], feed.first.category
|
|
33
|
+
assert_equal "#{base_url}/article", feed.normalized_entries.first.url
|
|
34
|
+
assert_nil SimpleRSS.fetch("#{base_url}/feed.xml", timeout: 1, etag: feed.etag, last_modified: feed.last_modified)
|
|
35
|
+
assert_include requests.last, 'If-None-Match: "version-1"'
|
|
36
|
+
assert_include requests.last, "If-Modified-Since: #{feed.last_modified}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_readable_io_and_explicit_source_url_work_without_fetching
|
|
41
|
+
source = StringIO.new('<rss version="2.0"><channel><title>Example</title><item><link>article</link></item></channel></rss>')
|
|
42
|
+
feed = SimpleRSS.parse(source, source_url: "https://example.com/feed.xml")
|
|
43
|
+
|
|
44
|
+
assert_equal "https://example.com/article", feed.normalized_entries.first.url
|
|
45
|
+
assert_nil feed.etag
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_fetched_source_url_wins_over_shared_parse_options_without_mutating_them
|
|
49
|
+
body = '<rss version="2.0"><channel><title>Example</title><item><link>article</link></item></channel></rss>'
|
|
50
|
+
responses = [[302, { "Location" => "/feeds/final.xml" }, ""], [200, {}, body]] * 2
|
|
51
|
+
with_server(responses) do |base_url, requests|
|
|
52
|
+
[nil, "https://wrong.example.com/old.xml"].each do |source_url|
|
|
53
|
+
options = { source_url: source_url, timeout: 1 }
|
|
54
|
+
feed = SimpleRSS.fetch("#{base_url}/initial.xml", options)
|
|
55
|
+
|
|
56
|
+
assert_equal "#{base_url}/feeds/final.xml", feed.source_url
|
|
57
|
+
assert_equal "#{base_url}/feeds/article", feed.normalized_entries.first.url
|
|
58
|
+
assert_equal source_url, options[:source_url]
|
|
59
|
+
assert_equal "https://override.example.com/article", feed.normalized_entries(source_url: "https://override.example.com/feed.xml").first.url
|
|
60
|
+
end
|
|
61
|
+
assert_equal 4, requests.size
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_json_fetch_detects_the_body_across_content_types_and_preserves_conditional_get
|
|
66
|
+
%w[application/feed+json application/json text/plain application/rss+xml].each do |content_type|
|
|
67
|
+
body = JSON.generate(version: "https://jsonfeed.org/version/1.1", title: "Example",
|
|
68
|
+
next_url: "/older.json", items: [{ id: "1", url: "article", content_text: "Hello" }])
|
|
69
|
+
headers = { "Content-Type" => content_type, "ETag" => '"json-1"', "Last-Modified" => "Sat, 12 Sep 2026 10:00:00 GMT" }
|
|
70
|
+
with_server([[200, headers, body], [304, headers, ""]]) do |base_url, requests|
|
|
71
|
+
feed = SimpleRSS.fetch("#{base_url}/feed.json", timeout: 1)
|
|
72
|
+
assert_equal :json_feed, feed.feed_type
|
|
73
|
+
assert_equal "#{base_url}/article", feed.normalized_entries.first.url
|
|
74
|
+
assert_equal "/older.json", feed.next_url
|
|
75
|
+
assert_equal '"json-1"', feed.etag
|
|
76
|
+
assert_equal headers["Last-Modified"], feed.last_modified
|
|
77
|
+
assert_equal 1, requests.size
|
|
78
|
+
assert_include requests.first.find { |header| header.start_with?("Accept:") }, "application/feed+json"
|
|
79
|
+
assert_nil SimpleRSS.fetch("#{base_url}/feed.json", timeout: 1, etag: feed.etag, last_modified: feed.last_modified)
|
|
80
|
+
assert_include requests.last, 'If-None-Match: "json-1"'
|
|
81
|
+
assert_include requests.last, "If-Modified-Since: #{headers["Last-Modified"]}"
|
|
82
|
+
assert_equal 2, requests.size
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def test_json_fetch_handles_redirects_and_custom_accept_without_fetching_linked_resources
|
|
88
|
+
body = JSON.generate(version: "https://jsonfeed.org/version/1", title: "Example", next_url: "/older.json",
|
|
89
|
+
items: [{ id: "1", url: "article", content_text: "Hello",
|
|
90
|
+
attachments: [{ url: "episode.mp3", mime_type: "audio/mpeg" }] }])
|
|
91
|
+
responses = [[302, { "Location" => "../feeds/final.json" }, ""], [200, {}, body]]
|
|
92
|
+
with_server(responses) do |base_url, requests|
|
|
93
|
+
feed = SimpleRSS.fetch("#{base_url}/start/feed", timeout: 1, headers: { "Accept" => "application/json" })
|
|
94
|
+
entry = feed.normalized_entries.first
|
|
95
|
+
assert_equal "#{base_url}/feeds/final.json", feed.source_url
|
|
96
|
+
assert_equal "#{base_url}/feeds/article", entry.url
|
|
97
|
+
assert_equal "#{base_url}/feeds/episode.mp3", entry.attachments.first[:url]
|
|
98
|
+
assert_equal "/older.json", feed.next_url
|
|
99
|
+
assert_equal ["GET /start/feed HTTP/1.1", "GET /feeds/final.json HTTP/1.1"], requests.map(&:first)
|
|
100
|
+
requests.each { |request| assert_include request, "Accept: application/json" }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def with_server(responses)
|
|
107
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
108
|
+
base_url = "http://127.0.0.1:#{server.addr[1]}"
|
|
109
|
+
requests = []
|
|
110
|
+
worker = Thread.new do
|
|
111
|
+
responses.each do |status, headers, body|
|
|
112
|
+
client = server.accept
|
|
113
|
+
request = []
|
|
114
|
+
while (line = client.gets)
|
|
115
|
+
break if line == "\r\n"
|
|
116
|
+
|
|
117
|
+
request << line.strip
|
|
118
|
+
end
|
|
119
|
+
requests << request
|
|
120
|
+
response_headers = headers.merge("Content-Length" => body.bytesize.to_s, "Connection" => "close")
|
|
121
|
+
client.write("HTTP/1.1 #{status} Test\r\n" + response_headers.map { |name, value| "#{name}: #{value}\r\n" }.join + "\r\n" + body)
|
|
122
|
+
client.close
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
yield base_url, requests
|
|
126
|
+
worker.value
|
|
127
|
+
ensure
|
|
128
|
+
worker&.kill
|
|
129
|
+
worker&.join
|
|
130
|
+
server&.close
|
|
131
|
+
end
|
|
132
|
+
end
|