simple-rss 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +492 -8
  4. data/Rakefile +1 -1
  5. data/examples/digest.rb +19 -0
  6. data/examples/discover.rb +14 -0
  7. data/examples/feedbag.rb +10 -0
  8. data/lib/simple-rss/discovery.rb +107 -0
  9. data/lib/simple-rss/entry_normalizer.rb +356 -0
  10. data/lib/simple-rss/http_client.rb +216 -0
  11. data/lib/simple-rss/json_entry_normalizer.rb +147 -0
  12. data/lib/simple-rss/json_feed.rb +176 -0
  13. data/lib/simple-rss/normalized_entry.rb +79 -0
  14. data/lib/simple-rss/request_errors.rb +21 -0
  15. data/lib/simple-rss/request_policy.rb +72 -0
  16. data/lib/simple-rss/xml_element.rb +136 -0
  17. data/lib/simple-rss.rb +225 -77
  18. data/simple-rss.gemspec +5 -5
  19. data/test/base/category_parsing_test.rb +217 -0
  20. data/test/base/date_ordering_test.rb +95 -0
  21. data/test/base/discovery_dependency_test.rb +29 -0
  22. data/test/base/discovery_test.rb +151 -0
  23. data/test/base/discovery_transport_test.rb +413 -0
  24. data/test/base/enumerable_test.rb +16 -0
  25. data/test/base/feedbag_integration_test.rb +21 -0
  26. data/test/base/json_feed_test.rb +372 -0
  27. data/test/base/normalized_entries_test.rb +410 -0
  28. data/test/base/normalized_fetch_test.rb +132 -0
  29. data/test/base/relation_links_test.rb +162 -0
  30. data/test/data/atom_categories.xml +23 -0
  31. data/test/data/atom_nested_link.xml +13 -0
  32. data/test/data/discovery.html +28 -0
  33. data/test/data/json_feed_1.json +75 -0
  34. data/test/data/json_feed_1_1.json +78 -0
  35. data/test/data/mixed_dates.xml +55 -0
  36. data/test/data/normalized_atom.xml +22 -0
  37. data/test/data/normalized_rss.xml +21 -0
  38. data/test/data/rss_categories.xml +15 -0
  39. data/test/support/http_server.rb +44 -0
  40. data/test/support/replace_method.rb +14 -0
  41. metadata +42 -10
data/README.md CHANGED
@@ -4,20 +4,38 @@
4
4
  [![CI](https://github.com/cardmagic/simple-rss/actions/workflows/ruby.yml/badge.svg)](https://github.com/cardmagic/simple-rss/actions/workflows/ruby.yml)
5
5
  [![License: LGPL](https://img.shields.io/badge/License-LGPL-blue.svg)](https://opensource.org/licenses/LGPL-3.0)
6
6
 
7
- A simple, flexible, extensible, and liberal RSS and Atom reader for Ruby. Designed to be backwards compatible with Ruby's standard RSS parser while handling malformed feeds gracefully.
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 both RSS and Atom feeds
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
- - Zero runtime dependencies
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
- Version 2.0 is a major update with powerful new capabilities:
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 any parsed feed to clean RSS 2.0 or Atom XML with `to_xml(format: :rss2)` or `to_xml(format: :atom)`.
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 (sorted by pubDate or updated)
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/*", "bin/*", "test/**/*", "[A-Z]*", "Rakefile", "html/**/*"
17
+ "lib/**/*", "bin/*", "examples/**/*", "test/**/*", "[A-Z]*", "Rakefile", "html/**/*"
18
18
  ]
19
19
 
20
20
  desc "Default Task"
@@ -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
@@ -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