Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ repository begins with a 1.1 import, so earlier releases are not reconstructed.

## Unreleased

- Parse Atom 1.0 category terms from attributes, so scalar category access and
`array_tags: [:category]` work with `items_by_category`. Preserve source order
and duplicates, skip missing or blank terms, and resolve namespace declarations
within the current entry. Exclude categories in embedded content and source
metadata while preserving RSS category text and CDATA behavior.
([#56](https://github.com/cardmagic/simple-rss/issues/56))
- Use the first valid `pubDate`, `updated`, or `published` timestamp in
`latest`, `items_since`, and merge ordering. Malformed dates no longer make
`latest` raise or hide a valid fallback. Preserve the original field values,
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,26 @@ feed = SimpleRSS.parse(xml, array_tags: [:category])
item.category # => ["tech", "programming", "ruby"]
```

RSS categories use element text, including CDATA: `<category>ruby</category>`.
Atom 1.0 categories use the `term` attribute:

```xml
<category term="ruby" label="Ruby Language" scheme="https://example.com/topics"/>
<category term="rails"/>
```

With `array_tags: [:category]`, those Atom categories become `["ruby", "rails"]`
in source order, preserving duplicates. Scalar mode returns the first usable
term. Both modes work with `feed.items_by_category("ruby")`. Missing or blank
Atom terms are skipped; labels and element content do not supply a fallback.
The original `label` and `scheme` attributes remain in `feed.source` for callers
that need the source metadata.

Category extraction uses direct children of each entry or item and resolves
default and prefixed namespace declarations on the feed, entry, and category.
Categories inside content or source metadata are excluded. RSS categories keep
their existing scalar/array and CDATA behavior.

## API Reference

### `SimpleRSS.parse(source, options = {})`
Expand Down
124 changes: 111 additions & 13 deletions lib/simple-rss.rb
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ def handle_redirect(response, options)

DATE_TAGS = %i[pubDate lastBuildDate published updated expirationDate modified dc:date].freeze
STRIP_HTML_TAGS = %i[author contributor skipHours skipDays].freeze
ATOM_NAMESPACE = "http://www.w3.org/2005/Atom".freeze
RSS_NAMESPACES = [nil, "", "http://purl.org/rss/1.0/", "http://my.netscape.com/rdf/simple/0.9/"].freeze
XML_TAG_PATTERN = %r{<!--.*?-->|<!\[CDATA\[.*?\]\]>|<\?.*?\?>|<(/?)([\w:.-]+)((?:[^<>"']|"[^"]*"|'[^']*')*)>}m

private

Expand Down Expand Up @@ -397,12 +400,24 @@ def parse
end

# RSS items' title, link, and description
@source.scan(%r{<(rss:|atom:)?(item|entry)([\s][^>]*)?>(.*?)</(rss:|atom:)?(item|entry)>}mi) do |match|
namespace_contexts = entry_namespaces
entry_pattern = %r{<(rss:|atom:)?(item|entry)([\s][^>]*)?>(.*?)</(rss:|atom:)?(item|entry)>}mi
position = 0
while (match = entry_pattern.match(@source, position))
position = match.end(0)
item = {} #: Hash[Symbol, untyped]
namespaces = namespace_contexts[match.begin(0)]
@@item_tags.each do |tag|
next if tag.to_s.strip.empty?

parse_item_tag(item, tag, match[3], match[2])
if tag == :category
next unless namespaces

parse_category_tag(item, match[4].to_s, namespaces, element_namespace("#{match[1]}#{match[2]}", namespaces))
next
end

parse_item_tag(item, tag, match[4], match[3])
end
item.define_singleton_method(:method_missing) { |name, *_args| self[name] }
add_item_media_helpers(item)
Expand Down Expand Up @@ -474,26 +489,109 @@ def link_relation_href(content, relation)

# @rbs (String) -> Array[Hash[String, String]]
def entry_link_attributes(content)
links = [] #: Array[Hash[String, String]]
child_elements(content).filter_map do |tag, attributes, _body|
next unless %w[link atom:link rss:link].include?(tag.downcase)

xml_attributes(attributes).transform_keys(&:downcase)
end
end

# @rbs (String) -> Array[[String, String, String?]]
def child_elements(content)
elements = [] #: Array[[String, String, String?]]
current_element = nil #: [String, String, String?]?
depth = 0
tokens = %r{<!--.*?-->|<!\[CDATA\[.*?\]\]>|<\?.*?\?>|<(/?)([\w:.-]+)((?:[^<>"']|"[^"]*"|'[^']*')*)>}m
body_start = 0
position = 0

content.scan(tokens) do
closing = Regexp.last_match(1)
tag = Regexp.last_match(2)&.downcase
attributes = Regexp.last_match(3)
while (token = XML_TAG_PATTERN.match(content, position))
position = token.end(0)
attributes = token[3]
next unless attributes

if closing == "/"
if token[1] == "/"
depth = [depth - 1, 0].max
if depth.zero? && current_element
current_element[2] = content[body_start...token.begin(0)]
current_element = nil
end
next
end

links << xml_attributes(attributes) if depth.zero? && %w[link atom:link rss:link].include?(tag)
depth += 1 unless attributes.rstrip.end_with?("/")
self_closing = attributes.rstrip.end_with?("/")
if depth.zero?
element = [token[2].to_s, attributes, nil] #: [String, String, String?]
elements << element
current_element = element unless self_closing
body_start = token.end(0)
end
depth += 1 unless self_closing
end

links
elements
end

# @rbs (Hash[Symbol, untyped], String, Hash[String, String], String?) -> void
def parse_category_tag(item, content, namespaces, entry_namespace)
values = child_elements(content).filter_map do |tag, raw_attributes, body|
next unless tag.split(":").last&.casecmp?("category")

attributes = xml_attributes(raw_attributes)
namespace = element_namespace(tag, namespaces.merge(namespace_attributes(raw_attributes)))
if namespace == ATOM_NAMESPACE
term = CGI.unescapeHTML(attributes["term"].to_s).strip
next term unless term.empty?

next
end

next if entry_namespace == ATOM_NAMESPACE || !RSS_NAMESPACES.include?(namespace)
next if tag.include?(":") && namespace.nil?
next if body.nil? && (array_tag?(:category) || !raw_attributes.rstrip.end_with?("/"))

unescape(body.to_s)
end
return if values.empty?

item[:category] = array_tag?(:category) ? values : values.first
end

# @rbs () -> Hash[Integer, Hash[String, String]]
def entry_namespaces
contexts = {} #: Hash[Integer, Hash[String, String]]
scopes = [{}] #: Array[Hash[String, String]]
position = 0

while (token = XML_TAG_PATTERN.match(@source, position))
position = token.end(0)
attributes = token[3]
next unless attributes

if token[1] == "/"
scopes.pop if scopes.size > 1
next
end

namespaces = scopes.fetch(-1).merge(namespace_attributes(attributes))
tag = token[2].to_s.split(":").last
contexts[token.begin(0).to_i] = namespaces if %w[item entry].include?(tag&.downcase)
scopes << namespaces unless attributes.rstrip.end_with?("/")
end

contexts
end

# @rbs (String) -> Hash[String, String]
def namespace_attributes(attributes)
xml_attributes(attributes)
.select { |name, _value| name == "xmlns" || name.start_with?("xmlns:") }
.transform_values { |value| CGI.unescapeHTML(value) }
end

# @rbs (String, Hash[String, String]) -> String?
def element_namespace(tag, namespaces)
key = tag.include?(":") ? "xmlns:#{tag.split(":").first}" : "xmlns"
namespaces[key]
end

# @rbs (String) -> Hash[String, String]
Expand All @@ -502,7 +600,7 @@ def xml_attributes(attributes)
attributes.scan(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/m) do
name = Regexp.last_match(1)
value = Regexp.last_match(2) || Regexp.last_match(3)
values[name.downcase] = value if name && value
values[name] = value if name && value
end
values
end
Expand Down
Loading
Loading