iklil 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9cac2ea2480eeeaa590210ce6c7e7af40e20e0b727959a8bda34f89e358ed057
4
+ data.tar.gz: 804925a2664425344bcbf54ec0fbd79ef0a5cc1a0947ae263735b0a7a7b53a56
5
+ SHA512:
6
+ metadata.gz: 2c703082516cc60d10de1fe797b33688ade12535195e227266f20dda0a8cbe96137f8640b2a8a6d3b6fb7d152b13341833320434305c1c52e055b9f23346d7da
7
+ data.tar.gz: 1c94b8ba11b241d925f61456427a1a76a8c1189a7e38ef15809577302ea91edb2ea0dad87f8871b359d25c04b895fde8eb6f0bd432dec84a91068f823137342e
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-21
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Iklil
2
+
3
+ Iklil (ρ Scorpii, from Arabic *iklīl*, “crown”) is a dependency-free Ruby
4
+ parser for RSS, Atom, JSON Feed, and OPML. It converts the formats into one
5
+ small immutable model and keeps diagnostics instead of dropping an entire feed
6
+ when one item is malformed.
7
+
8
+ ## Features
9
+
10
+ - RSS 0.90/0.91/0.92/2.0 and RSS 1.0 (RDF)
11
+ - Atom 1.0
12
+ - JSON Feed 1.0 and 1.1
13
+ - OPML 1.0/2.0 import and deterministic export
14
+ - Relative URL resolution, date normalization, and common namespace extensions
15
+ - BOM/encoding handling, named-entity repair, control-character removal, and
16
+ truncated XML recovery
17
+ - DTD and external entity removal before XML parsing
18
+ - Ruby standard library only; Ruby 3.2+
19
+
20
+ ## Installation
21
+
22
+ ```ruby
23
+ gem "iklil"
24
+ ```
25
+
26
+ ## Quick start
27
+
28
+ ```ruby
29
+ require "iklil"
30
+
31
+ feed = Iklil.parse(File.binread("feed.xml"), base_url: "https://example.test/")
32
+ feed.format
33
+ feed.entries.first.title
34
+ feed.entries.first.published_at # a Time, or nil when absent/invalid
35
+ feed.diagnostics
36
+ ```
37
+
38
+ All feed formats expose `Iklil::Feed`, `Iklil::Entry`, and
39
+ `Iklil::Enclosure`. `Entry#content_type` is `:html` or `:text`; Iklil does not
40
+ execute or sanitize HTML.
41
+
42
+ ```ruby
43
+ subscriptions = Iklil.parse_opml(File.binread("subscriptions.opml"))
44
+ File.write("copy.opml", Iklil.render_opml(subscriptions, title: "Feeds"))
45
+ ```
46
+
47
+ ## Security
48
+
49
+ Iklil never fetches URLs. XML DTDs and entity declarations are removed before
50
+ REXML receives untrusted input, so feeds cannot make the parser read local or
51
+ remote files. Use a separate sanitizer before displaying HTML content.
52
+
53
+ ## Development
54
+
55
+ ```sh
56
+ bundle install
57
+ bundle exec rake
58
+ gem build --strict iklil.gemspec
59
+ ```
60
+
61
+ ## License
62
+
63
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,21 @@
1
+ # ADR 001: Normalize feed formats into one model
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-21
5
+ - Author: Yudai Takada
6
+
7
+ ## Decision
8
+
9
+ RSS, Atom, and JSON Feed are parsed into immutable `Feed`, `Entry`, and
10
+ `Enclosure` values. Format-specific fields remain in `Entry#extensions`.
11
+
12
+ ## Context
13
+
14
+ The reader needs to display and sort entries without knowing which wire format
15
+ produced them. Keeping format branches in the application would duplicate URL,
16
+ date, and content handling.
17
+
18
+ ## Consequences
19
+
20
+ Consumers get one stable API. New namespace fields are not lost, but consumers
21
+ must explicitly opt into them. The parser does not perform HTML sanitization.
@@ -0,0 +1,23 @@
1
+ # ADR 002: Tolerant parsing with diagnostics and fail-closed XML
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-21
5
+ - Author: Yudai Takada
6
+
7
+ ## Decision
8
+
9
+ Iklil repairs common feed damage, returns readable fields when possible, and
10
+ records warnings in `Feed#diagnostics`. DTDs and entity declarations are
11
+ removed before REXML parses untrusted input.
12
+
13
+ ## Context
14
+
15
+ Public feeds frequently contain undeclared entities, invalid dates, truncated
16
+ downloads, or inconsistent encoding. A single bad item should not hide every
17
+ other item. XML external entities are not needed for feeds and create an
18
+ unacceptable local-file and network access risk.
19
+
20
+ ## Consequences
21
+
22
+ Applications must inspect diagnostics when they need strict ingestion. Iklil
23
+ never resolves external entities and never performs network requests.
@@ -0,0 +1,20 @@
1
+ # Feed normalization
2
+
3
+ Iklil maps each supported input to the same `Feed`, `Entry`, and `Enclosure`
4
+ models. Missing values remain `nil`; malformed values add a warning to
5
+ `Feed#diagnostics` rather than aborting the complete feed.
6
+
7
+ | Common field | RSS 2.0 / RSS 0.9x | RSS 1.0 | Atom 1.0 | JSON Feed |
8
+ | --- | --- | --- | --- | --- |
9
+ | `id` | `guid`, then `link`, then SHA-256 | `rdf:about`, then `link`, then SHA-256 | `id`, then link, then SHA-256 | `id`, then `url`, then SHA-256 |
10
+ | `title` | `title` | `title` | `title` | `title` |
11
+ | `published_at` | `pubDate`, `date` | `dc:date`, `date` | `published` | `date_published` |
12
+ | `updated_at` | `lastBuildDate` | `dc:date` | `updated` | `date_modified` |
13
+ | `summary` | `description` | `description` | `summary` | `summary` |
14
+ | `content` | `content:encoded`, then `description` | `content:encoded`, then `description` | `content`, then `summary` | `content_html`, then `content_text`, then `summary` |
15
+ | `content_type` | `:html` for `content:encoded`, otherwise `:text` | same | `type="html"`/`xhtml` => `:html` | `content_html` => `:html`, otherwise `:text` |
16
+ | `url` | `link` | `link` | alternate `link` | `url` |
17
+
18
+ Relative URLs are resolved against the caller's `base_url` and inherited
19
+ `xml:base` values. Namespace fields not needed for the common model are kept
20
+ in `Entry#extensions` under their original qualified name.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iklil
4
+ Diagnostic = Data.define(:severity, :message, :path) do
5
+ def warning?
6
+ severity == :warning
7
+ end
8
+ end
9
+
10
+ Enclosure = Data.define(:url, :type, :length, :duration, :title)
11
+
12
+ Entry = Data.define(
13
+ :id, :title, :url, :summary, :content, :content_type,
14
+ :published_at, :updated_at, :authors, :categories, :enclosures, :extensions
15
+ ) do
16
+ def html?
17
+ content_type == :html
18
+ end
19
+
20
+ def text?
21
+ content_type == :text
22
+ end
23
+ end
24
+
25
+ Feed = Data.define(
26
+ :title, :subtitle, :url, :site_url, :updated_at, :language, :icon,
27
+ :authors, :entries, :format, :diagnostics
28
+ ) do
29
+ def valid?
30
+ !diagnostics.any? { |diagnostic| diagnostic.severity == :error }
31
+ end
32
+ end
33
+
34
+ Subscription = Data.define(
35
+ :title, :xml_url, :html_url, :type, :text, :description, :language
36
+ )
37
+ end
data/lib/iklil/opml.rb ADDED
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iklil
4
+ module OPML
5
+ module_function
6
+
7
+ def parse(bytes)
8
+ text = Preprocess.call(bytes)
9
+ document = REXML::Document.new(text)
10
+ outlines = document.root && document.root.elements.to_a("body//outline")
11
+ outlines ||= []
12
+ outlines.filter_map do |outline|
13
+ xml_url = XML.attribute(outline, "xmlUrl") || XML.attribute(outline, "xmlurl")
14
+ next unless xml_url && !xml_url.empty?
15
+
16
+ Subscription.new(
17
+ title: XML.attribute(outline, "title") || XML.attribute(outline, "text"),
18
+ xml_url: xml_url,
19
+ html_url: XML.attribute(outline, "htmlUrl") || XML.attribute(outline, "htmlurl"),
20
+ type: XML.attribute(outline, "type"), text: XML.attribute(outline, "text"),
21
+ description: XML.attribute(outline, "description"), language: XML.attribute(outline, "language")
22
+ )
23
+ end
24
+ rescue REXML::ParseException => error
25
+ raise Error, "invalid OPML: #{error.message}"
26
+ end
27
+
28
+ def render(subscriptions, title:)
29
+ groups = Hash.new { |hash, key| hash[key] = [] }
30
+ Array(subscriptions).each do |subscription|
31
+ groups[group_value(subscription)] << subscription
32
+ end
33
+ body = groups.map do |group, items|
34
+ children = items.map { |item| render_outline(item) }.join
35
+ if group.nil? || group.empty?
36
+ children
37
+ else
38
+ "<outline text=\"#{escape(group)}\" title=\"#{escape(group)}\">#{children}</outline>"
39
+ end
40
+ end.join
41
+ <<~XML
42
+ <?xml version="1.0" encoding="UTF-8"?>
43
+ <opml version="2.0">
44
+ <head><title>#{escape(title)}</title></head>
45
+ <body>#{body}</body>
46
+ </opml>
47
+ XML
48
+ end
49
+
50
+ def render_outline(subscription)
51
+ values = {
52
+ text: value(subscription, :text) || value(subscription, :title),
53
+ title: value(subscription, :title) || value(subscription, :text),
54
+ type: value(subscription, :type) || "rss",
55
+ xmlUrl: value(subscription, :xml_url),
56
+ htmlUrl: value(subscription, :html_url),
57
+ description: value(subscription, :description),
58
+ language: value(subscription, :language)
59
+ }
60
+ attributes = values.filter_map { |key, value| "#{key}=\"#{escape(value)}\"" if value && !value.to_s.empty? }.join(" ")
61
+ "<outline #{attributes}/>"
62
+ end
63
+
64
+ def value(subscription, key)
65
+ return subscription.public_send(key) if subscription.respond_to?(key)
66
+ return subscription[key.to_s] || subscription[key] if subscription.is_a?(Hash)
67
+
68
+ nil
69
+ end
70
+
71
+ def group_value(subscription)
72
+ value(subscription, :group) || value(subscription, :category)
73
+ end
74
+
75
+ def escape(value)
76
+ CGI.escapeHTML(value.to_s)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iklil
4
+ class Parser
5
+ class << self
6
+ def parse(bytes, base_url:, encoding:)
7
+ data = bytes.respond_to?(:read) ? bytes.read : bytes
8
+ format = detect(data)
9
+ return parse_json(data, base_url: base_url) if format == :json_feed
10
+
11
+ text = Preprocess.call(data, encoding: encoding)
12
+ document = xml_document(text)
13
+ root = document.root
14
+ return empty_feed(format, "feed has no root element") unless root
15
+
16
+ case XML.local_name(root)
17
+ when "rss"
18
+ parse_rss(root, base_url: base_url)
19
+ when "RDF", "rdf"
20
+ parse_rss1(root, base_url: base_url)
21
+ when "feed"
22
+ parse_atom(root, base_url: base_url)
23
+ else
24
+ empty_feed(nil, "unsupported feed root #{root.name.inspect}")
25
+ end
26
+ rescue JSON::ParserError => error
27
+ empty_feed(:json_feed, "invalid JSON Feed: #{error.message}")
28
+ rescue REXML::ParseException => error
29
+ empty_feed(format, "invalid XML: #{error.message}")
30
+ rescue StandardError => error
31
+ empty_feed(format, "could not parse feed: #{error.message}")
32
+ end
33
+
34
+ def detect(bytes)
35
+ raw = (bytes.respond_to?(:read) ? bytes.read : bytes).to_s.b.sub(/\A(?:\xEF\xBB\xBF|\xFF\xFE|\xFE\xFF)/n, "")
36
+ stripped = raw.lstrip
37
+ if stripped.start_with?("{") || stripped.start_with?("[")
38
+ begin
39
+ value = JSON.parse(raw.force_encoding(Encoding::UTF_8))
40
+ return :json_feed if value.is_a?(Hash) && value["version"].to_s.match?(%r{jsonfeed\.org/version/1})
41
+ rescue JSON::ParserError
42
+ return :json_feed if raw.match?(/"version"\s*:\s*"https?:\/\/jsonfeed\.org\/version\/1/i)
43
+ end
44
+ end
45
+
46
+ root = stripped[/<\s*([A-Za-z_][\w:.-]*)/, 1].to_s.split(":").last
47
+ case root
48
+ when "rss"
49
+ version = stripped[/<rss\b[^>]*\bversion\s*=\s*["']([^"']+)/i, 1].to_s
50
+ version.start_with?("0.9") ? :rss09 : :rss2
51
+ when "RDF", "rdf"
52
+ :rss1
53
+ when "feed"
54
+ :atom
55
+ else
56
+ nil
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def xml_document(text)
63
+ REXML::Document.new(text)
64
+ rescue REXML::ParseException
65
+ recovered = Preprocess.close_open_elements(text)
66
+ raise if recovered == text
67
+
68
+ REXML::Document.new(recovered)
69
+ end
70
+
71
+ def parse_rss(root, base_url:)
72
+ diagnostics = []
73
+ channel = XML.child(root, "channel") || root
74
+ feed_base = XML.base_url(base_url, channel)
75
+ entries = XML.children(channel, "item").map.with_index do |item, index|
76
+ rss_entry(item, base_url: XML.base_url(feed_base, item), diagnostics: diagnostics, path: "channel/item[#{index}]")
77
+ end
78
+ self_url = XML.children(channel).filter_map do |node|
79
+ next unless XML.local_name(node) == "link" && XML.attribute(node, "rel") == "self"
80
+
81
+ XML.resolve_url(XML.attribute(node, "href"), feed_base)
82
+ end.first
83
+ Feed.new(
84
+ title: XML.path_text(channel, "title"),
85
+ subtitle: XML.path_text(channel, "description"),
86
+ url: self_url || feed_base,
87
+ site_url: XML.resolve_url(XML.path_text(channel, "link"), feed_base),
88
+ updated_at: date_value(XML.path_text(channel, "lastBuildDate"), diagnostics, "channel/lastBuildDate"),
89
+ language: XML.path_text(channel, "language"),
90
+ icon: XML.resolve_url(XML.path_text(channel, "image", "url"), feed_base),
91
+ authors: rss_authors(channel),
92
+ entries: entries,
93
+ format: root.attributes["version"].to_s.start_with?("0.9") ? :rss09 : :rss2,
94
+ diagnostics: diagnostics
95
+ )
96
+ end
97
+
98
+ def parse_rss1(root, base_url:)
99
+ diagnostics = []
100
+ channel = XML.child(root, "channel") || root
101
+ feed_base = XML.base_url(base_url, channel)
102
+ entries = XML.children(root, "item").map.with_index do |item, index|
103
+ rss_entry(item, base_url: XML.base_url(feed_base, item), diagnostics: diagnostics, path: "item[#{index}]")
104
+ end
105
+ Feed.new(
106
+ title: XML.path_text(channel, "title"),
107
+ subtitle: XML.path_text(channel, "description"),
108
+ url: feed_base,
109
+ site_url: XML.resolve_url(XML.path_text(channel, "link"), feed_base),
110
+ updated_at: date_value(XML.path_text(channel, "date"), diagnostics, "channel/date"),
111
+ language: XML.path_text(channel, "language"),
112
+ icon: nil,
113
+ authors: rss_authors(channel),
114
+ entries: entries,
115
+ format: :rss1,
116
+ diagnostics: diagnostics
117
+ )
118
+ end
119
+
120
+ def parse_atom(root, base_url:)
121
+ diagnostics = []
122
+ feed_base = XML.base_url(base_url, root)
123
+ links = XML.children(root, "link")
124
+ self_url = links.find { |link| XML.attribute(link, "rel").to_s == "self" }
125
+ site_url = links.find { |link| [nil, "alternate"].include?(XML.attribute(link, "rel")) }
126
+ entries = XML.children(root, "entry").map.with_index do |entry, index|
127
+ atom_entry(entry, base_url: XML.base_url(feed_base, entry), diagnostics: diagnostics, path: "entry[#{index}]")
128
+ end
129
+ Feed.new(
130
+ title: XML.deep_text(XML.child(root, "title")),
131
+ subtitle: XML.deep_text(XML.child(root, "subtitle")),
132
+ url: XML.resolve_url(XML.attribute(self_url, "href"), feed_base) || feed_base,
133
+ site_url: XML.resolve_url(XML.attribute(site_url, "href"), feed_base),
134
+ updated_at: date_value(XML.path_text(root, "updated"), diagnostics, "updated"),
135
+ language: XML.attribute(root, "lang"),
136
+ icon: XML.resolve_url(XML.path_text(root, "icon"), feed_base),
137
+ authors: atom_authors(root),
138
+ entries: entries,
139
+ format: :atom,
140
+ diagnostics: diagnostics
141
+ )
142
+ end
143
+
144
+ def parse_json(bytes, base_url:)
145
+ diagnostics = []
146
+ raw = bytes.respond_to?(:read) ? bytes.read : bytes
147
+ value = JSON.parse(raw.to_s.sub(/\A\xEF\xBB\xBF/, ""))
148
+ unless value.is_a?(Hash) && value["version"].to_s.match?(%r{jsonfeed\.org/version/1})
149
+ return empty_feed(:json_feed, "JSON document is not JSON Feed 1.x")
150
+ end
151
+ feed_base = base_url
152
+ entries = Array(value["items"]).each_with_index.each_with_object([]) do |(item, index), result|
153
+ if item.is_a?(Hash)
154
+ result << json_entry(item, base_url: feed_base, diagnostics: diagnostics, path: "items[#{index}]")
155
+ else
156
+ diagnostics << Diagnostic.new(severity: :warning, message: "item is not an object", path: "items[#{index}]")
157
+ end
158
+ end
159
+ Feed.new(
160
+ title: value["title"], subtitle: value["description"], url: XML.resolve_url(value["feed_url"], feed_base) || feed_base,
161
+ site_url: XML.resolve_url(value["home_page_url"], feed_base), updated_at: date_value(value["date_modified"], diagnostics, "date_modified"),
162
+ language: value["language"], icon: value["icon"], authors: json_authors(value["author"] || value["authors"]),
163
+ entries: entries, format: :json_feed, diagnostics: diagnostics
164
+ )
165
+ end
166
+
167
+ def rss_entry(item, base_url:, diagnostics:, path:)
168
+ link = XML.resolve_url(XML.path_text(item, "link"), base_url)
169
+ guid = XML.path_text(item, "guid") || XML.attribute(item, "about")
170
+ encoded = XML.child(item, "encoded") || XML.children(item).find { |node| node.name.to_s == "content:encoded" }
171
+ summary = XML.path_text(item, "description")
172
+ content = encoded ? XML.deep_text(encoded) : summary
173
+ content_type = encoded ? :html : :text
174
+ published = XML.path_text(item, "pubDate") || XML.path_text(item, "date")
175
+ updated = XML.path_text(item, "lastBuildDate") || XML.path_text(item, "date")
176
+ Entry.new(
177
+ id: guid || link || stable_id(item, content), title: XML.path_text(item, "title"), url: link,
178
+ summary: summary, content: content, content_type: content_type,
179
+ published_at: date_value(published, diagnostics, "#{path}/pubDate"),
180
+ updated_at: date_value(updated, diagnostics, "#{path}/lastBuildDate"), authors: rss_authors(item),
181
+ categories: XML.children(item, "category").filter_map { |node| XML.text(node) },
182
+ enclosures: rss_enclosures(item, base_url),
183
+ extensions: XML.extension_values(item, known: %w[title link guid description pubDate date lastBuildDate author category enclosure])
184
+ )
185
+ end
186
+
187
+ def atom_entry(entry, base_url:, diagnostics:, path:)
188
+ links = XML.children(entry, "link")
189
+ link = links.find { |node| [nil, "alternate"].include?(XML.attribute(node, "rel")) }
190
+ content_node = XML.child(entry, "content")
191
+ raw_content_type = XML.attribute(content_node, "type").to_s.downcase
192
+ content = content_node && (content_node.has_elements? ? XML.inner_xml(content_node) : XML.deep_text(content_node))
193
+ content_type = if %w[html xhtml text/html application/xhtml+xml].include?(raw_content_type) || (raw_content_type.empty? && content_node&.has_elements?)
194
+ :html
195
+ else
196
+ :text
197
+ end
198
+ summary = XML.deep_text(XML.child(entry, "summary"))
199
+ Entry.new(
200
+ id: XML.path_text(entry, "id") || XML.resolve_url(XML.attribute(link, "href"), base_url) || stable_id(entry, content),
201
+ title: XML.deep_text(XML.child(entry, "title")), url: XML.resolve_url(XML.attribute(link, "href"), base_url),
202
+ summary: summary, content: content || summary, content_type: content_type,
203
+ published_at: date_value(XML.path_text(entry, "published"), diagnostics, "#{path}/published"),
204
+ updated_at: date_value(XML.path_text(entry, "updated"), diagnostics, "#{path}/updated"),
205
+ authors: atom_authors(entry), categories: XML.children(entry, "category").filter_map { |node| XML.attribute(node, "term") || XML.text(node) },
206
+ enclosures: links.filter_map do |node|
207
+ next unless XML.attribute(node, "rel") == "enclosure"
208
+ Enclosure.new(url: XML.resolve_url(XML.attribute(node, "href"), base_url), type: XML.attribute(node, "type"), length: integer(XML.attribute(node, "length")), duration: nil, title: XML.attribute(node, "title"))
209
+ end,
210
+ extensions: XML.extension_values(entry, known: %w[id title link summary content published updated author category])
211
+ )
212
+ end
213
+
214
+ def json_entry(item, base_url:, diagnostics:, path:)
215
+ html = item["content_html"]
216
+ text = item["content_text"]
217
+ content = html || text || item["summary"]
218
+ content_type = html ? :html : :text
219
+ attachments = Array(item["attachments"]).filter_map do |attachment|
220
+ next unless attachment.is_a?(Hash) && attachment["url"]
221
+ Enclosure.new(url: XML.resolve_url(attachment["url"].to_s, base_url), type: attachment["mime_type"], length: integer(attachment["size_in_bytes"]), duration: attachment["duration_in_seconds"], title: attachment["title"])
222
+ end
223
+ Entry.new(
224
+ id: item["id"] || XML.resolve_url(item["url"].to_s, base_url) || stable_id(item, content),
225
+ title: item["title"], url: XML.resolve_url(item["url"].to_s, base_url), summary: item["summary"], content: content,
226
+ content_type: content_type, published_at: date_value(item["date_published"], diagnostics, "#{path}/date_published"),
227
+ updated_at: date_value(item["date_modified"], diagnostics, "#{path}/date_modified"), authors: json_authors(item["author"] || item["authors"]),
228
+ categories: Array(item["tags"]), enclosures: attachments,
229
+ extensions: item.reject { |key, _| %w[id title url external_url content_html content_text summary date_published date_modified author authors tags attachments].include?(key) }
230
+ )
231
+ end
232
+
233
+ def rss_authors(element)
234
+ %w[author creator].filter_map { |name| XML.children(element, name).filter_map { |node| XML.text(node) } }.flatten.uniq
235
+ end
236
+
237
+ def atom_authors(element)
238
+ XML.children(element, "author").filter_map do |author|
239
+ XML.deep_text(XML.child(author, "name")) || XML.text(author)
240
+ end.uniq
241
+ end
242
+
243
+ def json_authors(author)
244
+ Array(author).filter_map { |value| value.is_a?(Hash) ? (value["name"] || value["url"]) : value }.map(&:to_s).reject(&:empty?).uniq
245
+ end
246
+
247
+ def rss_enclosures(item, base_url)
248
+ XML.children(item).filter_map do |node|
249
+ next unless %w[enclosure content].include?(XML.local_name(node)) && (node.name == "enclosure" || XML.attribute(node, "url"))
250
+ Enclosure.new(url: XML.resolve_url(XML.attribute(node, "url"), base_url), type: XML.attribute(node, "type"), length: integer(XML.attribute(node, "length") || XML.attribute(node, "fileSize")), duration: XML.attribute(node, "duration"), title: XML.attribute(node, "title"))
251
+ end
252
+ end
253
+
254
+ def date_value(value, diagnostics, path)
255
+ return nil if value.nil? || value.to_s.strip.empty?
256
+
257
+ DateTime.parse(value.to_s).to_time
258
+ rescue ArgumentError, TypeError
259
+ diagnostics << Diagnostic.new(severity: :warning, message: "invalid date #{value.inspect}", path: path)
260
+ nil
261
+ end
262
+
263
+ def integer(value)
264
+ Integer(value, 10) if value && value.to_s.match?(/\A\d+\z/)
265
+ rescue ArgumentError
266
+ nil
267
+ end
268
+
269
+ def stable_id(value, content)
270
+ source = value.respond_to?(:expanded_name) ? XML.inner_xml(value) : value.to_s
271
+ Digest::SHA256.hexdigest([source, content].join("\u0000"))
272
+ end
273
+
274
+ def empty_feed(format, message)
275
+ Feed.new(title: nil, subtitle: nil, url: nil, site_url: nil, updated_at: nil, language: nil, icon: nil, authors: [], entries: [], format: format, diagnostics: [Diagnostic.new(severity: :error, message: message, path: nil)])
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iklil
4
+ # Converts untrusted XML bytes into a UTF-8, non-executable XML document.
5
+ # DTDs are removed before REXML sees the input; REXML must never resolve
6
+ # external entities from a feed.
7
+ module Preprocess
8
+ module_function
9
+
10
+ XML_DECLARATION = /\A\s*<\?xml\s+[^>]*encoding\s*=\s*["']([^"']+)["']/im
11
+ VALID_ENTITIES = %w[amp apos gt lt quot].freeze
12
+ NAMED_ENTITIES = {
13
+ "nbsp" => 160, "copy" => 169, "reg" => 174, "trade" => 8482,
14
+ "hellip" => 8230, "mdash" => 8212, "ndash" => 8211, "laquo" => 171,
15
+ "raquo" => 187, "ldquo" => 8220, "rdquo" => 8221, "lsquo" => 8216,
16
+ "rsquo" => 8217, "bull" => 8226, "rarr" => 8594, "times" => 215,
17
+ "euro" => 8364, "yen" => 165, "pound" => 163
18
+ }.freeze
19
+
20
+ def call(bytes, encoding: :auto)
21
+ raw = (bytes.respond_to?(:read) ? bytes.read : bytes).to_s.b.dup
22
+ source_encoding = encoding == :auto ? sniff_encoding(raw) : encoding
23
+ raw = strip_bom(raw)
24
+ text = decode(raw, source_encoding)
25
+ text = strip_dtd(text)
26
+ text = repair_entities(text)
27
+ text = strip_control_chars(text)
28
+ text.force_encoding(Encoding::UTF_8)
29
+ text
30
+ end
31
+
32
+ def strip_bom(bytes)
33
+ bytes.sub(/\A(?:\xEF\xBB\xBF|\xFF\xFE|\xFE\xFF)/n, "")
34
+ end
35
+
36
+ def sniff_encoding(bytes)
37
+ return Encoding::UTF_16LE if bytes.start_with?("\xFF\xFE".b)
38
+ return Encoding::UTF_16BE if bytes.start_with?("\xFE\xFF".b)
39
+
40
+ match = bytes.byteslice(0, 512).to_s.force_encoding(Encoding::ASCII_8BIT).match(XML_DECLARATION)
41
+ return Encoding::UTF_8 unless match
42
+
43
+ Encoding.find(match[1].strip.tr("_", "-"))
44
+ rescue ArgumentError
45
+ Encoding::UTF_8
46
+ end
47
+
48
+ def decode(bytes, encoding)
49
+ source = encoding.is_a?(Encoding) ? encoding : Encoding.find(encoding.to_s)
50
+ bytes.dup.force_encoding(source).encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�")
51
+ rescue Encoding::ConverterNotFoundError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
52
+ bytes.dup.force_encoding(Encoding::UTF_8).scrub("�")
53
+ end
54
+
55
+ def strip_dtd(text)
56
+ # Handles both a simple declaration and an internal subset containing >.
57
+ without_doctype = text.gsub(/<!DOCTYPE\b[^\[]*(?:\[[\s\S]*?\]\s*)?>/i, "")
58
+ without_doctype.gsub(/<!ENTITY\b[^>]*>/i, "")
59
+ end
60
+
61
+ def repair_entities(text)
62
+ text.gsub(/&(#(?:x[0-9a-f]+|[0-9]+)|[A-Za-z][A-Za-z0-9]+);/i) do |entity|
63
+ value = Regexp.last_match(1)
64
+ if valid_numeric_entity?(value) || VALID_ENTITIES.include?(value)
65
+ entity
66
+ elsif NAMED_ENTITIES.key?(value.downcase)
67
+ "&#" + NAMED_ENTITIES.fetch(value.downcase).to_s + ";"
68
+ else
69
+ "&amp;#{value};"
70
+ end
71
+ end
72
+ end
73
+
74
+ def strip_control_chars(text)
75
+ text.delete("\x00-\x08\x0B\x0C\x0E-\x1F\x7F")
76
+ end
77
+
78
+ def valid_numeric_entity?(value)
79
+ return false unless value.start_with?("#")
80
+
81
+ number = value.start_with?("#x", "#X") ? value[2..].to_i(16) : value[1..].to_i(10)
82
+ number == 9 || number == 10 || number == 13 || (number >= 0x20 && number <= 0xD7FF) ||
83
+ (number >= 0xE000 && number <= 0xFFFD) || (number >= 0x10000 && number <= 0x10FFFF)
84
+ end
85
+
86
+ # A small recovery pass for the common case of a feed truncated while it
87
+ # was being downloaded. It is only used after REXML rejects the document.
88
+ def close_open_elements(text)
89
+ stack = []
90
+ text.scan(/<\s*(\/?)\s*([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?(\/?)\s*>/) do |closing, name, self_closing|
91
+ if closing == "/"
92
+ index = stack.rindex(name)
93
+ stack.slice!(index..-1) if index
94
+ elsif self_closing != "/" && !%w[br hr img link meta].include?(name.downcase)
95
+ stack << name
96
+ end
97
+ end
98
+ stack.reverse.reduce(text) { |value, name| "#{value}</#{name}>" }
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iklil
4
+ VERSION = "0.1.0"
5
+ end
data/lib/iklil/xml.rb ADDED
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iklil
4
+ module XML
5
+ module_function
6
+
7
+ def local_name(element)
8
+ element.name.to_s.split(":").last
9
+ end
10
+
11
+ def child(element, name)
12
+ return nil unless element
13
+
14
+ element.elements.find { |item| local_name(item) == name }
15
+ end
16
+
17
+ def children(element, name = nil)
18
+ return [] unless element
19
+
20
+ elements = element.elements.to_a
21
+ name ? elements.select { |item| local_name(item) == name } : elements
22
+ end
23
+
24
+ def text(element)
25
+ return nil unless element
26
+
27
+ value = element.texts.map(&:value).join
28
+ value = value.strip
29
+ value.empty? ? nil : value
30
+ end
31
+
32
+ def attribute(element, name)
33
+ return nil unless element
34
+
35
+ element.attributes.each_attribute do |attribute|
36
+ return attribute.value if attribute.expanded_name == name || attribute.name == name || attribute.name.split(":").last == name
37
+ end
38
+ nil
39
+ end
40
+
41
+ def path_text(element, *names)
42
+ names.reduce(element) { |current, name| child(current, name) }.then { |node| text(node) }
43
+ end
44
+
45
+ def base_url(base, element)
46
+ relative = attribute(element, "base")
47
+ return base unless relative && !relative.empty?
48
+ return relative unless base && !base.empty?
49
+
50
+ URI.join(base, relative).to_s
51
+ rescue URI::InvalidURIError
52
+ relative
53
+ end
54
+
55
+ def resolve_url(value, base)
56
+ return nil if value.nil? || value.empty?
57
+ return value unless base && !base.empty?
58
+
59
+ URI.join(base, value).to_s
60
+ rescue URI::InvalidURIError
61
+ value
62
+ end
63
+
64
+ def tree_text(element)
65
+ return "" unless element
66
+
67
+ element.children.map { |node| node.respond_to?(:value) ? node.value.to_s : tree_text(node) }.join
68
+ end
69
+
70
+ def deep_text(element)
71
+ return nil unless element
72
+
73
+ value = tree_text(element).strip
74
+ value.empty? ? nil : value
75
+ end
76
+
77
+ def inner_xml(element)
78
+ return "" unless element
79
+
80
+ element.children.map do |node|
81
+ if node.respond_to?(:value)
82
+ CGI.escapeHTML(node.value.to_s)
83
+ else
84
+ attributes = node.attributes.each_attribute.map { |attribute| "#{attribute.expanded_name}=\"#{CGI.escapeHTML(attribute.value.to_s)}\"" }
85
+ opening = attributes.empty? ? "<#{node.name}>" : "<#{node.name} #{attributes.join(" ")}>"
86
+ "#{opening}#{inner_xml(node)}</#{node.name}>"
87
+ end
88
+ end.join
89
+ end
90
+
91
+ def extension_values(element, known: [])
92
+ return {} unless element
93
+
94
+ element.elements.each_with_object({}) do |node, result|
95
+ local = local_name(node)
96
+ next if known.include?(local)
97
+
98
+ key = node.expanded_name.to_s
99
+ value = if node.has_elements?
100
+ extension_values(node)
101
+ else
102
+ text(node) || ""
103
+ end
104
+ result[key] = result.key?(key) ? Array(result[key]) + [value] : value
105
+ end
106
+ end
107
+ end
108
+ end
data/lib/iklil.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "date"
5
+ require "digest"
6
+ require "json"
7
+ require "rexml/document"
8
+ require "time"
9
+ require "uri"
10
+
11
+ require_relative "iklil/version"
12
+ require_relative "iklil/models"
13
+ require_relative "iklil/preprocess"
14
+ require_relative "iklil/xml"
15
+ require_relative "iklil/parser"
16
+ require_relative "iklil/opml"
17
+
18
+ module Iklil
19
+ class Error < StandardError; end
20
+
21
+ module_function
22
+
23
+ def parse(bytes, base_url: nil, encoding: :auto)
24
+ Parser.parse(bytes, base_url: base_url, encoding: encoding)
25
+ end
26
+
27
+ def detect(bytes)
28
+ Parser.detect(bytes)
29
+ end
30
+
31
+ def parse_opml(bytes)
32
+ OPML.parse(bytes)
33
+ end
34
+
35
+ def render_opml(subscriptions, title: "Iklil subscriptions")
36
+ OPML.render(subscriptions, title: title)
37
+ end
38
+ end
data/sig/iklil.rbs ADDED
@@ -0,0 +1,69 @@
1
+ module Iklil
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class Diagnostic
8
+ attr_reader severity: Symbol
9
+ attr_reader message: String
10
+ attr_reader path: String?
11
+ def initialize: (severity: Symbol, message: String, path: String?) -> void
12
+ def warning?: () -> bool
13
+ end
14
+
15
+ class Enclosure
16
+ attr_reader url: String?
17
+ attr_reader type: String?
18
+ attr_reader length: Integer?
19
+ attr_reader duration: Integer?
20
+ attr_reader title: String?
21
+ end
22
+
23
+ class Entry
24
+ attr_reader id: String
25
+ attr_reader title: String?
26
+ attr_reader url: String?
27
+ attr_reader summary: String?
28
+ attr_reader content: String?
29
+ attr_reader content_type: Symbol
30
+ attr_reader published_at: Time?
31
+ attr_reader updated_at: Time?
32
+ attr_reader authors: Array[String]
33
+ attr_reader categories: Array[String]
34
+ attr_reader enclosures: Array[Enclosure]
35
+ attr_reader extensions: Hash[String, untyped]
36
+ def html?: () -> bool
37
+ def text?: () -> bool
38
+ end
39
+
40
+ class Feed
41
+ attr_reader title: String?
42
+ attr_reader subtitle: String?
43
+ attr_reader url: String?
44
+ attr_reader site_url: String?
45
+ attr_reader updated_at: Time?
46
+ attr_reader language: String?
47
+ attr_reader icon: String?
48
+ attr_reader authors: Array[String]
49
+ attr_reader entries: Array[Entry]
50
+ attr_reader format: Symbol?
51
+ attr_reader diagnostics: Array[Diagnostic]
52
+ def valid?: () -> bool
53
+ end
54
+
55
+ class Subscription
56
+ attr_reader title: String?
57
+ attr_reader xml_url: String
58
+ attr_reader html_url: String?
59
+ attr_reader type: String?
60
+ attr_reader text: String?
61
+ attr_reader description: String?
62
+ attr_reader language: String?
63
+ end
64
+
65
+ def self.parse: (String | IO, base_url: String?, encoding: Symbol | String | Encoding) -> Feed
66
+ def self.detect: (String | IO) -> Symbol?
67
+ def self.parse_opml: (String | IO) -> Array[Subscription]
68
+ def self.render_opml: (Array[Subscription], title: String) -> String
69
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: iklil
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A dependency-free Ruby parser that normalizes RSS, Atom, JSON Feed, and
13
+ OPML data while safely handling malformed input.
14
+ email:
15
+ - t.yudai92@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - docs/adr/001-normalized-model.md
24
+ - docs/adr/002-tolerant-safe-parsing.md
25
+ - docs/normalization.md
26
+ - lib/iklil.rb
27
+ - lib/iklil/models.rb
28
+ - lib/iklil/opml.rb
29
+ - lib/iklil/parser.rb
30
+ - lib/iklil/preprocess.rb
31
+ - lib/iklil/version.rb
32
+ - lib/iklil/xml.rb
33
+ - sig/iklil.rbs
34
+ homepage: https://github.com/noxdea/iklil
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ allowed_push_host: https://rubygems.org
39
+ homepage_uri: https://github.com/noxdea/iklil
40
+ source_code_uri: https://github.com/noxdea/iklil/tree/main
41
+ changelog_uri: https://github.com/noxdea/iklil/blob/main/CHANGELOG.md
42
+ rubygems_mfa_required: 'true'
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '3.2'
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 4.0.16
58
+ specification_version: 4
59
+ summary: Tolerant RSS, Atom, JSON Feed, and OPML parser
60
+ test_files: []