Read RSS and Atom feeds, and speak about them only when asked (#258)
internal/rss parses RSS 2.0 and Atom, and polls each configured feed on its own interval; internal/webfetch is the one door either of them uses to touch the network. The poller writes items as notes with source "rss:<feed>" and nothing else: the answer path reads them back when he asks "что нового в лентах?", and nothing is announced on arrival. A feed that dispatched would be a nag, which is why the plan's breaking-news rule was left out rather than built. webfetch is where the limits live, as code rather than a paragraph: http(s) only, an allowlist (the configured feeds' hosts) and a denylist, a 2 MiB body cap, a 3-redirect cap, one request per host per second, and a refusal to connect to any private address — checked in the dialer's Control hook so it holds for every resolved address and every redirect hop, not just for a literal IP. Off unless configured: no "feeds" block, no poller, no outbound request. How far a feed was read is a config fact (rss:latest:<name>), so a restart does not re-note yesterday's headlines.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
package rss
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const rss2 = `<?xml version="1.0"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Хабр</title>
|
||||
<item>
|
||||
<title>Новая уязвимость в ядре</title>
|
||||
<link>https://example.org/a</link>
|
||||
<description><p>Патч уже <b>вышел</b>.</p></description>
|
||||
<guid>tag:example.org,a</guid>
|
||||
<pubDate>Mon, 28 Jul 2026 10:00:00 +0000</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Без даты</title>
|
||||
<link>https://example.org/b</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const atom = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Example Atom</title>
|
||||
<entry>
|
||||
<title>Release 2.0</title>
|
||||
<link rel="alternate" href="https://example.com/rel"/>
|
||||
<link rel="edit" href="https://example.com/edit"/>
|
||||
<id>urn:uuid:1</id>
|
||||
<updated>2026-07-30T12:30:00Z</updated>
|
||||
<summary>Ships & works</summary>
|
||||
</entry>
|
||||
</feed>`
|
||||
|
||||
func TestParseRSS2(t *testing.T) {
|
||||
f, err := Parse(strings.NewReader(rss2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Title != "Хабр" {
|
||||
t.Fatalf("title = %q", f.Title)
|
||||
}
|
||||
if len(f.Items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(f.Items))
|
||||
}
|
||||
it := f.Items[0]
|
||||
if it.Title != "Новая уязвимость в ядре" {
|
||||
t.Errorf("title = %q", it.Title)
|
||||
}
|
||||
if it.Summary != "Патч уже вышел ." && it.Summary != "Патч уже вышел." {
|
||||
t.Errorf("summary = %q — tags must be stripped and entities decoded", it.Summary)
|
||||
}
|
||||
if it.ID != "tag:example.org,a" {
|
||||
t.Errorf("id = %q", it.ID)
|
||||
}
|
||||
if want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC); !it.Published.Equal(want) {
|
||||
t.Errorf("published = %v, want %v", it.Published, want)
|
||||
}
|
||||
if !f.Items[1].Published.IsZero() {
|
||||
t.Errorf("undated item got a date: %v", f.Items[1].Published)
|
||||
}
|
||||
if f.Items[1].ID != "https://example.org/b" {
|
||||
t.Errorf("id falls back to the link, got %q", f.Items[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAtom(t *testing.T) {
|
||||
f, err := Parse(strings.NewReader(atom))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Title != "Example Atom" || len(f.Items) != 1 {
|
||||
t.Fatalf("feed = %+v", f)
|
||||
}
|
||||
it := f.Items[0]
|
||||
if it.Link != "https://example.com/rel" {
|
||||
t.Errorf("link = %q, want the alternate link", it.Link)
|
||||
}
|
||||
if it.Summary != "Ships & works" {
|
||||
t.Errorf("summary = %q", it.Summary)
|
||||
}
|
||||
if want := time.Date(2026, 7, 30, 12, 30, 0, 0, time.UTC); !it.Published.Equal(want) {
|
||||
t.Errorf("published = %v, want %v", it.Published, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGarbage(t *testing.T) {
|
||||
if _, err := Parse(strings.NewReader("<html><body>not a feed")); err == nil {
|
||||
t.Fatal("want an error on a non-feed document")
|
||||
}
|
||||
// A feed with an item that has neither title nor link contributes nothing
|
||||
// rather than an empty note.
|
||||
f, err := Parse(strings.NewReader(`<rss><channel><item><description>x</description></item></channel></rss>`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.Items) != 0 {
|
||||
t.Fatalf("items = %d, want 0", len(f.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainTextDropsScript(t *testing.T) {
|
||||
got := PlainText(`<p>hi</p><script>alert("x")</script><style>b{}</style> there`)
|
||||
if got != "hi there" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user