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/README.md
CHANGED
|
@@ -4,20 +4,38 @@
|
|
|
4
4
|
[](https://github.com/cardmagic/simple-rss/actions/workflows/ruby.yml)
|
|
5
5
|
[](https://opensource.org/licenses/LGPL-3.0)
|
|
6
6
|
|
|
7
|
-
A simple, flexible, extensible, and liberal RSS and
|
|
7
|
+
A simple, flexible, extensible, and liberal RSS, Atom, and JSON Feed reader for Ruby. Designed to be backwards compatible with Ruby's standard RSS parser while handling malformed feeds gracefully.
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- Parses
|
|
11
|
+
- Parses RSS, Atom, and JSON Feed 1.0/1.1
|
|
12
12
|
- Tolerant of malformed XML (regex-based parsing)
|
|
13
13
|
- Built-in URL fetching with conditional GET support (ETags, Last-Modified)
|
|
14
|
+
- Explicit website feed discovery with request limits and destination policies
|
|
14
15
|
- JSON and XML serialization
|
|
15
16
|
- Extensible tag definitions
|
|
16
|
-
-
|
|
17
|
+
- No mandatory runtime gem dependencies; website discovery uses optional Nokogiri
|
|
17
18
|
|
|
18
|
-
## What's New in 2.0
|
|
19
|
+
## What's New in 2.3.0
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
- **Website discovery** - Find advertised RSS, Atom, and JSON feeds with
|
|
22
|
+
`SimpleRSS.discover("example.com")`. Bare domains default to HTTPS, and
|
|
23
|
+
requests have destination checks, timeouts, and size limits. Existing `fetch`
|
|
24
|
+
callers can opt into these request controls with `network_policy`.
|
|
25
|
+
- **Normalized entries** - Use `normalized_entries` for consistent URLs, dates,
|
|
26
|
+
content, authors, categories, and attachments while retaining raw feed data.
|
|
27
|
+
- **JSON Feed** - Parse JSON Feed 1.0 and 1.1 through the existing `parse` and
|
|
28
|
+
`fetch` APIs, with the same normalized entry interface as RSS and Atom.
|
|
29
|
+
- **Parser fixes** - Correct Atom category terms and relation links, handle
|
|
30
|
+
malformed dates during ordering, and accept self-closing empty feeds.
|
|
31
|
+
|
|
32
|
+
See the [2.3.0 release notes](CHANGELOG.md#230---2026-09-14) for compatibility details.
|
|
33
|
+
|
|
34
|
+
## Earlier 2.x Features
|
|
35
|
+
|
|
36
|
+
See the [changelog](CHANGELOG.md) for release history and unreleased changes.
|
|
37
|
+
|
|
38
|
+
The 2.x releases add:
|
|
21
39
|
|
|
22
40
|
- **URL Fetching** - One-liner feed fetching with `SimpleRSS.fetch(url)`. Supports timeouts, custom headers, and automatic redirect following.
|
|
23
41
|
|
|
@@ -25,7 +43,7 @@ Version 2.0 is a major update with powerful new capabilities:
|
|
|
25
43
|
|
|
26
44
|
- **JSON Serialization** - Export feeds with `to_json`, `to_hash`, and Rails-compatible `as_json`. Time objects serialize to ISO 8601.
|
|
27
45
|
|
|
28
|
-
- **XML Serialization** - Convert
|
|
46
|
+
- **XML Serialization** - Convert parsed XML feeds to clean RSS 2.0 or Atom XML with `to_xml(format: :rss2)` or `to_xml(format: :atom)`.
|
|
29
47
|
|
|
30
48
|
- **Array Tags** - Collect all occurrences of a tag (like multiple categories) with the `array_tags:` option.
|
|
31
49
|
|
|
@@ -95,6 +113,189 @@ feed = SimpleRSS.fetch(
|
|
|
95
113
|
# Returns nil if feed hasn't changed (304 Not Modified)
|
|
96
114
|
```
|
|
97
115
|
|
|
116
|
+
### Discovering Feeds from a Website
|
|
117
|
+
|
|
118
|
+
Add Nokogiri to applications that use discovery. Parsing and ordinary fetching
|
|
119
|
+
work without it:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
gem "simple-rss"
|
|
123
|
+
gem "nokogiri", ">= 1.16", "< 2"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`discover` uses Nokogiri's HTML5 parser, which requires CRuby. Missing HTML5
|
|
127
|
+
support raises `SimpleRSS::DiscoveryDependencyError` before making a request.
|
|
128
|
+
|
|
129
|
+
Bare domains and paths default to HTTPS: `SimpleRSS.discover("example.com/blog")`
|
|
130
|
+
requests `https://example.com/blog`. Protocol-relative inputs such as
|
|
131
|
+
`//example.com/blog` also use HTTPS. Explicit HTTP/HTTPS URLs are preserved;
|
|
132
|
+
other schemes remain unsupported. Include the scheme when specifying a port.
|
|
133
|
+
Discovery does not retry over HTTP if HTTPS fails.
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
require "simple-rss"
|
|
137
|
+
|
|
138
|
+
candidates = SimpleRSS.discover("https://example.com/blog")
|
|
139
|
+
# => [{ url: "https://example.com/feed.xml", title: "News",
|
|
140
|
+
# format: :rss, media_type: "application/rss+xml",
|
|
141
|
+
# source: :html_link, verified: false }, ...]
|
|
142
|
+
|
|
143
|
+
candidate = candidates.first
|
|
144
|
+
if candidate
|
|
145
|
+
feed = SimpleRSS.fetch(candidate.fetch(:url), network_policy: :public)
|
|
146
|
+
feed.normalized_entries.each { |entry| puts entry.title || entry.identifier }
|
|
147
|
+
else
|
|
148
|
+
puts "No advertised feeds found."
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The application chooses among candidates. HTML results follow document order;
|
|
153
|
+
duplicate normalized URLs keep the first record. Fragments are removed, queries
|
|
154
|
+
are preserved, and relative/protocol-relative references use the final response
|
|
155
|
+
URL plus the first direct `head` base URL, when usable. Malformed, credentialed,
|
|
156
|
+
and non-HTTP link URLs are ignored. An invalid first base falls back to the
|
|
157
|
+
response URL; later base tags do not override it.
|
|
158
|
+
|
|
159
|
+
Only direct `head` links with an `alternate` relation token and a supported
|
|
160
|
+
media type are considered. Names, relation tokens, and media types are
|
|
161
|
+
case-insensitive; quoted/unquoted attributes and HTML entities follow HTML5
|
|
162
|
+
parsing. Supported types are `application/rss+xml`, `application/rdf+xml`,
|
|
163
|
+
`application/atom+xml`, `application/feed+json`, and `application/json`.
|
|
164
|
+
Scripts, comments, styles, templates, noscript content, and body links are
|
|
165
|
+
excluded. HTML parsing limits tree depth and attributes per element to 128.
|
|
166
|
+
|
|
167
|
+
| Candidate field | Meaning |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `url` | Absolute HTTP/HTTPS URL, without a fragment |
|
|
170
|
+
| `title` | Advertised title or parsed feed title; may be nil |
|
|
171
|
+
| `format` | `:rss`, `:atom`, or `:json_feed` |
|
|
172
|
+
| `media_type` | Advertised supported type, or canonical type for a direct feed |
|
|
173
|
+
| `source` | `:html_link` for an advertisement, `:document` for a direct feed |
|
|
174
|
+
| `verified` | Whether this response was successfully parsed as a recognized feed |
|
|
175
|
+
|
|
176
|
+
An advertised type is a hint, and advertised destinations are not resolved or
|
|
177
|
+
fetched. They may be unreachable or prohibited by the application's policy.
|
|
178
|
+
Use the same network policy when fetching the chosen URL. A direct RSS, Atom,
|
|
179
|
+
or JSON Feed response returns one verified candidate at its final URL, even
|
|
180
|
+
when empty. Verification means SimpleRSS parsed it, not that it passed a full
|
|
181
|
+
standards validator. RSS/Atom root recognition and JSON structure take
|
|
182
|
+
precedence over server Content-Type. Empty self-closing RSS channels and Atom
|
|
183
|
+
feeds are also parseable.
|
|
184
|
+
|
|
185
|
+
Discovery makes one request plus permitted redirects. It never guesses paths,
|
|
186
|
+
executes scripts, fetches candidate feeds, follows pagination, or crawls links.
|
|
187
|
+
The [discovery example](examples/discover.rb) prints every candidate:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
ruby -Ilib examples/discover.rb https://example.com/blog
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### Request limits and destination policy
|
|
194
|
+
|
|
195
|
+
Discovery defaults to a 10-second total HTTP/DNS budget, at most five redirects,
|
|
196
|
+
and a 2 MiB body budget. The byte budget covers both transferred and decompressed
|
|
197
|
+
body data, accumulated across the redirect chain. Streaming stops when either
|
|
198
|
+
budget is exceeded. Gzip and zlib-wrapped deflate are supported as single streams;
|
|
199
|
+
truncated streams, trailing compressed data, unsupported content encodings, and
|
|
200
|
+
partial responses fail explicitly. The time budget includes connection, TLS,
|
|
201
|
+
response reads, DNS resolution, and redirects; HTML/feed parsing follows the
|
|
202
|
+
bounded download.
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
candidates = SimpleRSS.discover(
|
|
206
|
+
"https://example.com/blog",
|
|
207
|
+
timeout: 5,
|
|
208
|
+
max_bytes: 1_048_576,
|
|
209
|
+
max_redirects: 3,
|
|
210
|
+
headers: { "User-Agent" => "Example Feed Reader", "Accept-Language" => "en" }
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The default `network_policy: :public` checks every resolved address at every
|
|
215
|
+
hop and pins an approved address for the actual connection. Mixed public/private
|
|
216
|
+
DNS answers are rejected. TLS still verifies the original hostname; environment
|
|
217
|
+
proxies are disabled for policy-controlled requests. The conservative policy
|
|
218
|
+
excludes IPv4 private, loopback, link-local, shared, documentation, benchmark,
|
|
219
|
+
multicast, and reserved blocks. IPv6 permits global unicast `2000::/3`, excluding
|
|
220
|
+
special-purpose, documentation, and 6to4 ranges. Mapped/translated addresses and
|
|
221
|
+
other IPv6 ranges are excluded. See the
|
|
222
|
+
[IANA IPv4](https://www.iana.org/assignments/iana-ipv4-special-registry/) and
|
|
223
|
+
[IPv6 registries](https://www.iana.org/assignments/iana-ipv6-special-registry/).
|
|
224
|
+
|
|
225
|
+
For an application-controlled internal feed, provide a policy that returns true
|
|
226
|
+
for each permitted address:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
require "ipaddr"
|
|
230
|
+
|
|
231
|
+
internal_policy = lambda do |uri, address|
|
|
232
|
+
uri.hostname == "feeds.internal.example" &&
|
|
233
|
+
IPAddr.new("10.20.0.0/24").include?(address)
|
|
234
|
+
end
|
|
235
|
+
candidates = SimpleRSS.discover("https://feeds.internal.example/",
|
|
236
|
+
network_policy: internal_policy)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`network_policy: :unrestricted` deliberately allows any destination address while
|
|
240
|
+
retaining URL checks, pinning, time/byte limits, TLS verification, and redirect
|
|
241
|
+
rules. Keep this choice in application configuration. Policies receive a URI and
|
|
242
|
+
an IPAddr; invalid policy names raise `ArgumentError`.
|
|
243
|
+
|
|
244
|
+
On cross-origin redirects, custom headers are reduced to `Accept`,
|
|
245
|
+
`Accept-Language`, and `User-Agent`. Authorization, cookies, custom credential
|
|
246
|
+
headers, and conditional validators are removed and are not restored on a later
|
|
247
|
+
redirect back. Same-origin redirects retain them. A changed scheme or port is a
|
|
248
|
+
changed origin. URL credentials and unsupported destination schemes are rejected.
|
|
249
|
+
`Host`, proxy/connection/framing headers, `Range`, and `Accept-Encoding` are
|
|
250
|
+
transport-controlled and cannot be supplied in policy mode. `follow_redirects:
|
|
251
|
+
false` reports the initial redirect as an HTTP error during discovery.
|
|
252
|
+
|
|
253
|
+
Existing `fetch(url, options)` behavior is retained unless `network_policy` is
|
|
254
|
+
explicitly supplied. Opting in uses the same transport and defaults as discovery,
|
|
255
|
+
without requiring Nokogiri. `max_bytes` and `max_redirects` require a network
|
|
256
|
+
policy. Ordinary `fetch` still expects a feed and never performs discovery.
|
|
257
|
+
Parsing a supplied string or IO remains network-free.
|
|
258
|
+
|
|
259
|
+
| Outcome | Result |
|
|
260
|
+
| --- | --- |
|
|
261
|
+
| Successful HTML page with no supported advertisements | `[]` |
|
|
262
|
+
| Non-success HTTP status, including an unsolicited 304 | `SimpleRSS::HTTPError`, with `status_code` |
|
|
263
|
+
| Rejected URL or destination | `SimpleRSS::PolicyError` |
|
|
264
|
+
| Redirect loop or limit | `SimpleRSS::RedirectError` |
|
|
265
|
+
| Timeout | `SimpleRSS::RequestTimeout` |
|
|
266
|
+
| Body size limit | `SimpleRSS::ResponseTooLarge` |
|
|
267
|
+
| DNS, connection, TLS, compression, or HTTP transport failure | `SimpleRSS::RequestError` |
|
|
268
|
+
| Unrecognized or unparseable response, or HTML parser limit | `SimpleRSS::DiscoveryError` |
|
|
269
|
+
| Missing optional parser | `SimpleRSS::DiscoveryDependencyError` |
|
|
270
|
+
|
|
271
|
+
These errors inherit from `SimpleRSSError`. `fetch` retains its existing
|
|
272
|
+
`SimpleRSSError` for non-success HTTP statuses and returns nil on conditional
|
|
273
|
+
304 responses, including with an explicit network policy.
|
|
274
|
+
|
|
275
|
+
#### Feedbag alternative
|
|
276
|
+
|
|
277
|
+
Applications already using [Feedbag](https://github.com/damog/feedbag) can keep
|
|
278
|
+
it for discovery and pass its results to SimpleRSS:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
require "feedbag"
|
|
282
|
+
require "simple-rss"
|
|
283
|
+
|
|
284
|
+
Feedbag.find("https://example.com/blog", open_timeout: 10, read_timeout: 10).each do |url|
|
|
285
|
+
feed = SimpleRSS.fetch(url, network_policy: :public)
|
|
286
|
+
puts feed.title
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Install the separate `feedbag` gem for this recipe; it is not a SimpleRSS runtime
|
|
291
|
+
dependency. The [Feedbag example](examples/feedbag.rb) and integration test cover
|
|
292
|
+
this workflow. Feedbag owns its discovery transport, URL heuristics, and error
|
|
293
|
+
handling; SimpleRSS's network policy applies only to the subsequent `fetch`.
|
|
294
|
+
The built-in API provides candidate metadata and explicit error/limit semantics
|
|
295
|
+
for applications that need them. Its acceptance corpus is in
|
|
296
|
+
[test/data/discovery.html](test/data/discovery.html), with discovery and transport
|
|
297
|
+
cases under `test/base/`.
|
|
298
|
+
|
|
98
299
|
### Accessing Feed Data
|
|
99
300
|
|
|
100
301
|
SimpleRSS provides both RSS and Atom style accessors:
|
|
@@ -158,10 +359,260 @@ total = feed.count
|
|
|
158
359
|
feed[0].title # first item
|
|
159
360
|
feed[-1].title # last item
|
|
160
361
|
|
|
161
|
-
# Get the n most recent items
|
|
362
|
+
# Get the n most recent items
|
|
162
363
|
feed.latest(10)
|
|
163
364
|
```
|
|
164
365
|
|
|
366
|
+
`latest`, `items_since`, and merge ordering use the first successfully parsed date
|
|
367
|
+
from `pubDate`, `updated`, and `published`, in that order. Invalid date strings
|
|
368
|
+
remain available in the original fields. `latest` places entries without a usable
|
|
369
|
+
date after dated entries, including dates before 1970. Equal dates and undated
|
|
370
|
+
entries retain their source order, and `latest` does not modify the feed.
|
|
371
|
+
|
|
372
|
+
`items_since(time)` returns entries strictly newer than the given time in source
|
|
373
|
+
order, excluding entries without a usable date. Merging sorts identified entries
|
|
374
|
+
by the same date rules and keeps the newest entry for each identity. Equal dates
|
|
375
|
+
keep the first occurrence. Entries without an identity remain at the end in input
|
|
376
|
+
order, regardless of their dates.
|
|
377
|
+
|
|
378
|
+
### Normalized Entries
|
|
379
|
+
|
|
380
|
+
Use `normalized_entries` when an importer or digest should handle RSS, Atom, and JSON Feed
|
|
381
|
+
through the same fields:
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
feed = SimpleRSS.parse(xml, source_url: "https://example.com/feed.xml")
|
|
385
|
+
entry = feed.normalized_entries.first
|
|
386
|
+
|
|
387
|
+
entry.identifier # Publisher's opaque ID/GUID, or nil
|
|
388
|
+
entry.url # Article URL, preferring an Atom HTML alternate
|
|
389
|
+
entry.published_at # Time or nil; never filled from an update timestamp
|
|
390
|
+
entry.updated_at # Time or nil
|
|
391
|
+
entry.content_html # Full HTML content, when supplied
|
|
392
|
+
entry.content_text # Full plain text, when supplied
|
|
393
|
+
entry.summary # Separate synopsis; see summary_type (:html or :text)
|
|
394
|
+
entry.categories # Nonempty terms, unique in first-seen order
|
|
395
|
+
entry.attachments # Associated URL, media_type, size_in_bytes, duration_in_seconds
|
|
396
|
+
entry.authors # Name, email, URL, and source metadata
|
|
397
|
+
entry.issues # Inspect missing bases, invalid dates/numbers, unsupported content
|
|
398
|
+
entry.raw # Frozen copy of the original item hash
|
|
399
|
+
entry.raw_xml # Original entry XML, including unconfigured extensions
|
|
400
|
+
entry.field_sources # Which XML tag supplied each normalized scalar field
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
This is an optional, immutable view of the original XML. `items`, `entries`,
|
|
404
|
+
iteration, custom tags, `latest`, `merge`, `diff`, and all existing serialization
|
|
405
|
+
methods keep their current behavior. Normalization neither changes global tag
|
|
406
|
+
configuration nor mutates or freezes raw items. Each call returns fresh snapshots;
|
|
407
|
+
raw item edits are preserved in `raw` but do not rewrite the XML-derived fields.
|
|
408
|
+
Reordering or deduplicating `items` preserves the association with each item's
|
|
409
|
+
original XML. Inserting an unrelated hash into `items` cannot provide that source
|
|
410
|
+
and raises `SimpleRSSError` when normalized.
|
|
411
|
+
|
|
412
|
+
| Normalized field | RSS mapping | Atom 1.0 mapping |
|
|
413
|
+
| --- | --- | --- |
|
|
414
|
+
| `identifier` | `guid`, without URL decoding or a generated fallback | `id`, without URL decoding or a generated fallback |
|
|
415
|
+
| `url` | Item `link`, then an Atom alternate extension | Alternate `link` (`rel` defaults to alternate); HTML/XHTML first, untyped second, other alternatives last; never a self/API fallback |
|
|
416
|
+
| `published_at` | First parseable `pubDate`, then Dublin Core `date` | `published` |
|
|
417
|
+
| `updated_at` | Atom `updated` extension, then `modified` | `updated` |
|
|
418
|
+
| `content_html` | Explicit mapping, then `content:encoded`, then an HTML/XHTML Atom content extension | Explicit mapping, then `content:encoded` if supplied, then HTML/XHTML `content` |
|
|
419
|
+
| `content_text` | Explicit mapping or a plain-text Atom content extension | Explicit mapping, then plain-text `content` |
|
|
420
|
+
| `summary` | `description`, treated as HTML-capable synopsis | `summary`, honoring text/HTML/XHTML type |
|
|
421
|
+
| `categories` | Repeated category text plus Dublin Core subjects and unsplit Media RSS/iTunes keywords | Category terms plus the same recognized extensions |
|
|
422
|
+
| `attachments` | Each `enclosure` and Media RSS `content` (direct or in a direct Media RSS group) | Each enclosure `link` and the same Media RSS elements |
|
|
423
|
+
| `authors` | Each `author` as its unparsed email value; Dublin Core `creator` as a name | Entry authors, otherwise source authors, otherwise feed authors |
|
|
424
|
+
|
|
425
|
+
Namespaces are resolved by their declared URI; standard extension prefixes may
|
|
426
|
+
vary. Ordinary metadata must be a direct child of its entry. Nested article
|
|
427
|
+
markup, comments, and unrelated source metadata cannot replace entry fields.
|
|
428
|
+
Missing scalars are `nil`; missing collections are empty arrays. Dates use Ruby's
|
|
429
|
+
permissive `Time.parse`: invalid values produce `nil` and an issue, with the source
|
|
430
|
+
retained. `effective_at` returns `published_at || updated_at` for a normalized
|
|
431
|
+
entry; existing raw-item date ordering is unchanged.
|
|
432
|
+
|
|
433
|
+
URLs use the applicable ancestor and element `xml:base` declarations, resolved
|
|
434
|
+
against `source_url`. `fetch` always supplies the final response URL after
|
|
435
|
+
redirects, even if its options include a different or nil `source_url`;
|
|
436
|
+
`normalized_entries(source_url: "https://example.com/feed.xml")` can override it
|
|
437
|
+
for one call. Relative redirects are resolved against the current request URL.
|
|
438
|
+
Without a usable base, relative values are preserved and reported in `issues`.
|
|
439
|
+
Invalid URI syntax is likewise preserved with an issue. Identifiers are never
|
|
440
|
+
resolved as URLs. Normalization performs no HTTP requests or article scraping.
|
|
441
|
+
|
|
442
|
+
Content is not sanitized, and HTML is never implicitly stripped into plain text.
|
|
443
|
+
XML entities are decoded once outside CDATA; CDATA content is retained literally.
|
|
444
|
+
Atom XHTML drops its enclosing XHTML `div` and removes XHTML namespace prefixes
|
|
445
|
+
from element names, preserving the original in `raw_xml`. `content_base_url`
|
|
446
|
+
provides the effective base for the selected HTML content (or plain text when
|
|
447
|
+
HTML is absent); links inside content are not rewritten. External Atom content
|
|
448
|
+
is exposed as `content_url` without fetching it. Unsupported content types remain
|
|
449
|
+
in raw data with an issue. Render HTML only through your application's usual
|
|
450
|
+
sanitization policy.
|
|
451
|
+
|
|
452
|
+
`category_details` preserves duplicate category records, labels, schemes/domains,
|
|
453
|
+
and their raw attributes/content even when `categories` removes repeated terms.
|
|
454
|
+
`links` preserves all entry link records with resolved `url`, `rel`, `media_type`,
|
|
455
|
+
and raw metadata. Each attachment includes `source` and `raw`; invalid numeric
|
|
456
|
+
values stay there and produce a `nil` normalized number plus an issue, rather
|
|
457
|
+
than becoming zero. An item-level iTunes duration applies only when there is one
|
|
458
|
+
attachment; its original element is then available as `raw_duration`.
|
|
459
|
+
|
|
460
|
+
Custom content and keyword rules belong to an individual normalization call:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
entries = feed.normalized_entries(mappings: {
|
|
464
|
+
content_html: "full-text",
|
|
465
|
+
content_text: "{urn:example:content}plain",
|
|
466
|
+
categories: [
|
|
467
|
+
{ tag: "dc:subject", separator: ";" },
|
|
468
|
+
{ tag: "media:keywords", separator: "," }
|
|
469
|
+
]
|
|
470
|
+
})
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Content mappings select the first nonempty direct element and take precedence
|
|
474
|
+
over the defaults for that representation. A selector is an exact XML qualified
|
|
475
|
+
name or `{namespace-uri}local-name`, not XPath. Hyphenated names such as
|
|
476
|
+
`full-text` need no generated accessor or global tag registration. Category
|
|
477
|
+
mappings override the selected element's default term extraction; a separator
|
|
478
|
+
is literal and optional. Unmapped subject/keyword strings are never guessed to
|
|
479
|
+
be comma- or semicolon-delimited. Unknown mapping keys and malformed mapping
|
|
480
|
+
options raise `ArgumentError`.
|
|
481
|
+
|
|
482
|
+
For migration, replace format-specific expressions such as
|
|
483
|
+
`item[:link_alternate] || item[:link]` with `entry.url`, while retaining
|
|
484
|
+
`entry.raw` for existing custom fields. The runnable [digest example](examples/digest.rb)
|
|
485
|
+
reads all three formats without testing which one it received:
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
ruby -Ilib examples/digest.rb test/data/normalized_rss.xml
|
|
489
|
+
ruby -Ilib examples/digest.rb test/data/normalized_atom.xml
|
|
490
|
+
ruby -Ilib examples/digest.rb test/data/json_feed_1_1.json
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
The mapping follows the [Atom specification](https://www.rfc-editor.org/rfc/rfc4287.html),
|
|
494
|
+
[RSS specification](https://www.rssboard.org/rss-specification), and
|
|
495
|
+
[XML Base rules](https://www.w3.org/TR/xmlbase/). It is a tolerant extraction view,
|
|
496
|
+
not a standards validator. JSON Feed has the separate rules below.
|
|
497
|
+
|
|
498
|
+
### JSON Feed Parsing
|
|
499
|
+
|
|
500
|
+
JSON Feed 1.0 and 1.1 use the same `parse`, IO, and `fetch` entry points:
|
|
501
|
+
|
|
502
|
+
```ruby
|
|
503
|
+
require "simple-rss"
|
|
504
|
+
require "json"
|
|
505
|
+
|
|
506
|
+
source = JSON.generate(
|
|
507
|
+
version: "https://jsonfeed.org/version/1.1",
|
|
508
|
+
title: "Example",
|
|
509
|
+
authors: [{ name: "Example Editor" }],
|
|
510
|
+
items: [{
|
|
511
|
+
id: "post:42",
|
|
512
|
+
url: "https://example.com/posts/42",
|
|
513
|
+
content_text: "Hello from JSON Feed",
|
|
514
|
+
date_published: "2026-09-12T10:00:00Z",
|
|
515
|
+
tags: ["ruby", "feeds"]
|
|
516
|
+
}]
|
|
517
|
+
)
|
|
518
|
+
feed = SimpleRSS.parse(source)
|
|
519
|
+
entry = feed.normalized_entries.first
|
|
520
|
+
|
|
521
|
+
feed.feed_type # => :json_feed
|
|
522
|
+
entry.identifier # => "post:42"
|
|
523
|
+
entry.content_text # => "Hello from JSON Feed"
|
|
524
|
+
entry.authors.first[:name] # => "Example Editor" (inherited)
|
|
525
|
+
entry.categories # => ["ruby", "feeds"]
|
|
526
|
+
entry.published_at # => a Time
|
|
527
|
+
entry.raw["id"] # => "post:42"
|
|
528
|
+
|
|
529
|
+
feed = SimpleRSS.fetch("https://example.com/feed.json", timeout: 10)
|
|
530
|
+
feed.next_url # Pagination metadata only; never fetched automatically
|
|
531
|
+
feed.raw_json # Frozen original JSON document, including extensions
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
`fetch` detects the response body independently of Content-Type and sends an
|
|
535
|
+
Accept header covering all three formats. Custom headers can override Accept.
|
|
536
|
+
ETag, Last-Modified, redirects, and `nil` for HTTP 304 work as for XML feeds.
|
|
537
|
+
|
|
538
|
+
| Normalized field | JSON Feed mapping |
|
|
539
|
+
| --- | --- |
|
|
540
|
+
| `identifier` | `id`, preserved as an opaque string; numeric IDs become strings |
|
|
541
|
+
| `url`, `external_url` | Separate permalink and linkblog destination; no ID fallback |
|
|
542
|
+
| `published_at`, `updated_at` | `date_published`, `date_modified`, parsed separately as RFC 3339 |
|
|
543
|
+
| `content_html`, `content_text` | Corresponding fields, without decoding HTML entities or deriving one from the other |
|
|
544
|
+
| `summary`, `summary_type` | Plain text `summary`, with type `:text` |
|
|
545
|
+
| `image`, `banner_image` | Corresponding image URLs |
|
|
546
|
+
| `categories`, `category_details` | Nonblank `tags`, trimmed and deduplicated for categories; duplicate details retained |
|
|
547
|
+
| `authors` | Item authors, otherwise feed authors; includes `name`, `url`, `avatar`, and raw metadata |
|
|
548
|
+
| `language` | 1.1 item language, otherwise feed language |
|
|
549
|
+
| `attachments` | All attachments with URL, MIME type as `media_type`, title, size, duration, and raw metadata |
|
|
550
|
+
|
|
551
|
+
In 1.0, authors come from singular `author`. In 1.1, `authors` takes precedence
|
|
552
|
+
over deprecated `author` within the same object; an item's authors take
|
|
553
|
+
precedence over the feed's. An explicit empty `authors` array prevents
|
|
554
|
+
inheritance. The later `authors` and `language` fields are retained as raw data
|
|
555
|
+
but not normalized in a 1.0 document. Matching attachment titles preserve the
|
|
556
|
+
publisher's grouping of alternate formats. `external_url`, `image`,
|
|
557
|
+
`banner_image`, and `language` are additive normalized fields; XML entries
|
|
558
|
+
currently return `nil` for these fields.
|
|
559
|
+
|
|
560
|
+
Relative JSON URLs resolve against the supplied/fetched `source_url`, or the
|
|
561
|
+
JSON `feed_url` when no source URL is supplied. Per-call `source_url` overrides
|
|
562
|
+
still work. Missing bases and invalid URLs remain inspectable through `issues`
|
|
563
|
+
and raw data, using the same issue codes as XML. `content_base_url` is the item
|
|
564
|
+
URL when available, otherwise the source URL or feed URL. Content links are not
|
|
565
|
+
rewritten; no articles, attachments, hubs, or pagination URLs are fetched.
|
|
566
|
+
|
|
567
|
+
Parsing requires a supported version, string feed title, an items array, and
|
|
568
|
+
objects with nonblank string/numeric IDs and at least one string content field.
|
|
569
|
+
Titleless items and empty feeds are supported. Malformed JSON, missing required
|
|
570
|
+
fields, malformed author/tag/attachment structures, and wrong known field types
|
|
571
|
+
raise `SimpleRSSError` with a field path. When supplied, `expired` must be a
|
|
572
|
+
JSON boolean; strings such as `"false"` are rejected. Invalid required item data rejects the
|
|
573
|
+
whole feed; items are never assigned invented IDs or returned partially parsed.
|
|
574
|
+
Invalid optional dates and attachment numbers are preserved in raw data and
|
|
575
|
+
reported in `issues`, with `nil` normalized values. Dates are never substituted
|
|
576
|
+
with the current time. `effective_at`, `latest`, and `items_since` safely use a
|
|
577
|
+
valid modification date when publication is invalid or absent.
|
|
578
|
+
|
|
579
|
+
UTF-8 strings and readable IO accept ordinary leading JSON whitespace and one
|
|
580
|
+
UTF-8 BOM at the very start, before whitespace. Embedded or repeated BOMs are
|
|
581
|
+
rejected, as are invalid UTF-8 bytes and numbers that overflow Ruby's floating-point
|
|
582
|
+
range. Large integer IDs retain their full precision. `source` preserves the
|
|
583
|
+
original input. This is a parser, not a complete
|
|
584
|
+
standards validator: it does not validate URL reachability, language tags, ID
|
|
585
|
+
uniqueness across updates, or publisher extension schemas.
|
|
586
|
+
`SimpleRSS.valid?(source)` reports parseability. A parsed JSON feed's instance
|
|
587
|
+
`valid?` is true, including an empty feed. XML retains its historical distinction:
|
|
588
|
+
the class method accepts parseable empty feeds, but instance `valid?` requires
|
|
589
|
+
items and a title or link.
|
|
590
|
+
|
|
591
|
+
For JSON feeds, `raw_json` is an immutable snapshot of the entire decoded
|
|
592
|
+
document with string keys. Each normalized entry's `raw` is its original JSON
|
|
593
|
+
item, also with string keys; `raw_xml` is `nil`. Original numeric IDs, date
|
|
594
|
+
strings, nested data, and unknown fields remain intact. Normalized entries are
|
|
595
|
+
immutable snapshots based on the original JSON, so edits to `items` do not
|
|
596
|
+
rewrite their normalized fields. Reordering or deduplication retains the source
|
|
597
|
+
association; inserting an unrelated item raises an error during normalization.
|
|
598
|
+
|
|
599
|
+
`items` remains an array of hashes with symbol keys and dot access. It retains
|
|
600
|
+
JSON fields and adds compatibility aliases: string `id`/`guid`, `link`,
|
|
601
|
+
`description`, `content`, category arrays, publication/update dates, and the first
|
|
602
|
+
attachment's enclosure metadata. Nested original JSON values are frozen.
|
|
603
|
+
XML-only tag configuration and `array_tags` do not affect JSON; passing XML
|
|
604
|
+
`mappings` to JSON normalization raises `ArgumentError`.
|
|
605
|
+
|
|
606
|
+
`as_json`, `to_hash`, and `to_json` export that Ruby object view: original feed
|
|
607
|
+
metadata plus compatibility fields and the current items, with times converted
|
|
608
|
+
to ISO 8601. These operations preserve extensions but are **not JSON Feed
|
|
609
|
+
exporters**. Use `raw_json` to inspect the original document. XML serialization
|
|
610
|
+
behavior is unchanged; calling `to_xml` on a JSON feed raises `SimpleRSSError`
|
|
611
|
+
until a separate conversion contract exists.
|
|
612
|
+
|
|
613
|
+
These mappings follow the [JSON Feed 1.0 specification](https://www.jsonfeed.org/version/1/)
|
|
614
|
+
and [JSON Feed 1.1 specification](https://www.jsonfeed.org/version/1.1/).
|
|
615
|
+
|
|
165
616
|
### JSON Serialization
|
|
166
617
|
|
|
167
618
|
```ruby
|
|
@@ -181,7 +632,7 @@ feed.as_json
|
|
|
181
632
|
|
|
182
633
|
### XML Serialization
|
|
183
634
|
|
|
184
|
-
Convert parsed feeds to standard RSS 2.0 or Atom format:
|
|
635
|
+
Convert parsed XML feeds to standard RSS 2.0 or Atom format:
|
|
185
636
|
|
|
186
637
|
```ruby
|
|
187
638
|
feed = SimpleRSS.parse(xml)
|
|
@@ -225,6 +676,19 @@ SimpleRSS.item_tags << :"entry#xml:lang"
|
|
|
225
676
|
| `tag#attr` | `:"media:content#url"` | `.media_content_url` | Attribute value |
|
|
226
677
|
| `tag+rel` | `:"link+alternate"` | `.link_alternate` | Element with specific `rel` attribute |
|
|
227
678
|
|
|
679
|
+
Relation tags provide both underscore and legacy `+` hash keys. For example,
|
|
680
|
+
`item.link_alternate`, `item[:link_alternate]`, and `item[:"link+alternate"]`
|
|
681
|
+
return the same parsed value. Both keys appear in `to_hash`, `as_json`, and
|
|
682
|
+
`to_json`; they are ordinary hash entries, so reassigning one does not update the
|
|
683
|
+
other. The built-in link relations are `alternate`, `self`, `edit`, and `replies`.
|
|
684
|
+
|
|
685
|
+
Link relation accessors read `href` from the first direct child link with the
|
|
686
|
+
requested explicit `rel` attribute. Attribute order and child markup do not
|
|
687
|
+
affect the URL. Links inside source metadata, content, or other nested elements
|
|
688
|
+
are excluded. A missing relation or `href` returns `nil`. Relative href values
|
|
689
|
+
remain relative. `item.link` retains its existing extraction behavior; it does
|
|
690
|
+
not select a canonical article URL across relations or feed formats.
|
|
691
|
+
|
|
228
692
|
### Collecting Multiple Values
|
|
229
693
|
|
|
230
694
|
By default, SimpleRSS returns only the first occurrence of each tag. To collect all values:
|
|
@@ -236,6 +700,26 @@ feed = SimpleRSS.parse(xml, array_tags: [:category])
|
|
|
236
700
|
item.category # => ["tech", "programming", "ruby"]
|
|
237
701
|
```
|
|
238
702
|
|
|
703
|
+
RSS categories use element text, including CDATA: `<category>ruby</category>`.
|
|
704
|
+
Atom 1.0 categories use the `term` attribute:
|
|
705
|
+
|
|
706
|
+
```xml
|
|
707
|
+
<category term="ruby" label="Ruby Language" scheme="https://example.com/topics"/>
|
|
708
|
+
<category term="rails"/>
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
With `array_tags: [:category]`, those Atom categories become `["ruby", "rails"]`
|
|
712
|
+
in source order, preserving duplicates. Scalar mode returns the first usable
|
|
713
|
+
term. Both modes work with `feed.items_by_category("ruby")`. Missing or blank
|
|
714
|
+
Atom terms are skipped; labels and element content do not supply a fallback.
|
|
715
|
+
The original `label` and `scheme` attributes remain in `feed.source` for callers
|
|
716
|
+
that need the source metadata.
|
|
717
|
+
|
|
718
|
+
Category extraction uses direct children of each entry or item and resolves
|
|
719
|
+
default and prefixed namespace declarations on the feed, entry, and category.
|
|
720
|
+
Categories inside content or source metadata are excluded. RSS categories keep
|
|
721
|
+
their existing scalar/array and CDATA behavior.
|
|
722
|
+
|
|
239
723
|
## API Reference
|
|
240
724
|
|
|
241
725
|
### `SimpleRSS.parse(source, options = {})`
|
data/Rakefile
CHANGED
|
@@ -14,7 +14,7 @@ RUBY_FORGE_USER = ENV["RUBY_FORGE_USER"] || "cardmagic"
|
|
|
14
14
|
RELEASE_NAME = "#{PKG_NAME}-#{PKG_VERSION}".freeze
|
|
15
15
|
|
|
16
16
|
PKG_FILES = FileList[
|
|
17
|
-
"lib
|
|
17
|
+
"lib/**/*", "bin/*", "examples/**/*", "test/**/*", "[A-Z]*", "Rakefile", "html/**/*"
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
desc "Default Task"
|
data/examples/digest.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "simple-rss"
|
|
2
|
+
require "optparse"
|
|
3
|
+
|
|
4
|
+
options = {}
|
|
5
|
+
arguments = OptionParser.new do |parser|
|
|
6
|
+
parser.banner = "Usage: ruby -Ilib examples/digest.rb [--source-url URL] FEED_FILE..."
|
|
7
|
+
parser.on("--source-url URL", "Base URL for relative feed references") { |url| options[:source_url] = url }
|
|
8
|
+
end
|
|
9
|
+
arguments.parse!
|
|
10
|
+
abort arguments.to_s if ARGV.empty?
|
|
11
|
+
|
|
12
|
+
entries = ARGV.flat_map do |path|
|
|
13
|
+
File.open(path) { |source| SimpleRSS.parse(source, options).normalized_entries }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
entries.sort_by { |entry| entry.effective_at ? -entry.effective_at.to_r : Float::INFINITY }.each do |entry|
|
|
17
|
+
puts [entry.title || entry.identifier || "Untitled", entry.effective_at&.getutc&.iso8601, entry.url, entry.content_text].compact.join("\n")
|
|
18
|
+
puts
|
|
19
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "simple-rss"
|
|
2
|
+
|
|
3
|
+
abort "Usage: ruby -Ilib examples/discover.rb WEBSITE_URL" unless ARGV.size == 1
|
|
4
|
+
|
|
5
|
+
candidates = SimpleRSS.discover(ARGV.first)
|
|
6
|
+
if candidates.empty?
|
|
7
|
+
puts "No advertised feeds found."
|
|
8
|
+
exit
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
candidates.each do |candidate|
|
|
12
|
+
verification = candidate[:verified] ? "parsed feed" : "advertised link"
|
|
13
|
+
puts [candidate[:title], candidate[:format], candidate[:url], verification].compact.join(" | ")
|
|
14
|
+
end
|
data/examples/feedbag.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require "simple-rss"
|
|
2
|
+
require "feedbag"
|
|
3
|
+
|
|
4
|
+
abort "Usage: ruby -Ilib examples/feedbag.rb WEBSITE_URL" unless ARGV.size == 1
|
|
5
|
+
|
|
6
|
+
Feedbag.find(ARGV.first, open_timeout: 10, read_timeout: 10).each do |url|
|
|
7
|
+
puts url
|
|
8
|
+
feed = SimpleRSS.fetch(url, network_policy: :public, timeout: 10)
|
|
9
|
+
feed&.normalized_entries&.each { |entry| puts entry.title || entry.identifier }
|
|
10
|
+
end
|