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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +63 -0
- data/docs/adr/001-normalized-model.md +21 -0
- data/docs/adr/002-tolerant-safe-parsing.md +23 -0
- data/docs/normalization.md +20 -0
- data/lib/iklil/models.rb +37 -0
- data/lib/iklil/opml.rb +79 -0
- data/lib/iklil/parser.rb +279 -0
- data/lib/iklil/preprocess.rb +101 -0
- data/lib/iklil/version.rb +5 -0
- data/lib/iklil/xml.rb +108 -0
- data/lib/iklil.rb +38 -0
- data/sig/iklil.rbs +69 -0
- metadata +60 -0
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
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.
|
data/lib/iklil/models.rb
ADDED
|
@@ -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
|
data/lib/iklil/parser.rb
ADDED
|
@@ -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
|
+
"&#{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
|
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: []
|