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,324 @@
|
||||
package rss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FeedConfig — one feed to read. A feed with no Name or no URL is ignored.
|
||||
type FeedConfig struct {
|
||||
Name string // short id; the note source is "rss:<Name>"
|
||||
URL string // http(s) only, enforced by the fetcher
|
||||
Category string // free text ("технологии"), used to answer "что по X?"
|
||||
Interval time.Duration // 0 ⇒ the poller's default
|
||||
Include []string // when non-empty, keep only items matching one of these
|
||||
Exclude []string // drop items matching any of these, even if included
|
||||
}
|
||||
|
||||
// Fetcher is the guarded HTTP door (internal/webfetch). An interface so the
|
||||
// poller is testable without a network and so it CANNOT fetch by any other
|
||||
// means: no http.Client is constructed in this package.
|
||||
type Fetcher interface {
|
||||
Get(ctx context.Context, url string) (*Body, error)
|
||||
}
|
||||
|
||||
// Body is the minimum the poller needs from a response.
|
||||
type Body struct{ Bytes []byte }
|
||||
|
||||
// Notes is core's note-writing half. Same shape as ipc.CoreAPI's method, so the
|
||||
// daemon passes its API straight in.
|
||||
type Notes interface {
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
}
|
||||
|
||||
// Marks remembers how far a feed was read. Durable, because the alternative is
|
||||
// re-writing yesterday's headlines as fresh notes after every restart. The
|
||||
// daemon backs this with config facts (key "rss:latest:<feed>").
|
||||
type Marks interface {
|
||||
LastMark(ctx context.Context, feed string) (time.Time, error)
|
||||
SetMark(ctx context.Context, feed string, at time.Time) error
|
||||
}
|
||||
|
||||
// Embedder embeds a note on its way into the store so recall can find it. nil ⇒
|
||||
// notes are written without a vector (still readable by the recent-notes path).
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, text string) ([]float32, error)
|
||||
}
|
||||
|
||||
// Ranker is the relevance seam. The plan called for scoring each item against
|
||||
// an interest profile built from his notes; that profile does not exist yet, and
|
||||
// a threshold over an embedder with no profile to compare to is a random filter
|
||||
// with a confident name. So the seam is here, nil in the daemon, and the filter
|
||||
// that actually runs is the per-feed keyword one — a rule he can read and
|
||||
// predict. When there IS a profile, implement this and pass it.
|
||||
//
|
||||
// Note what a Ranker must NOT be: anything that sends his notes outward. The
|
||||
// scoring happens locally against a local embedder; the feed item is the input,
|
||||
// his memory is never the payload.
|
||||
type Ranker interface {
|
||||
Relevant(ctx context.Context, text string) (bool, error)
|
||||
}
|
||||
|
||||
// Config — poller-wide settings.
|
||||
type Config struct {
|
||||
DefaultInterval time.Duration // 0 ⇒ DefaultPollInterval
|
||||
MaxItems int // most notes written per feed per poll; 0 ⇒ DefaultMaxItems
|
||||
MaxAge time.Duration // ignore items older than this on a cold start; 0 ⇒ DefaultMaxAge
|
||||
}
|
||||
|
||||
// Defaults chosen to be quiet: a feed read every half hour, at most a handful of
|
||||
// items kept, and a cold start that does not import a month of history.
|
||||
const (
|
||||
DefaultPollInterval = 30 * time.Minute
|
||||
DefaultMaxItems = 5
|
||||
DefaultMaxAge = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Poller reads feeds on a schedule and writes what survives filtering as notes.
|
||||
type Poller struct {
|
||||
feeds []FeedConfig
|
||||
fetch Fetcher
|
||||
notes Notes
|
||||
marks Marks
|
||||
embed Embedder
|
||||
ranker Ranker
|
||||
cfg Config
|
||||
nextDue map[string]time.Time
|
||||
seen map[string]map[string]bool // feed → item ID, for items with no date
|
||||
}
|
||||
|
||||
// NewPoller wires a poller. Returns nil when there is nothing to poll — a
|
||||
// capability is off unless configured, and callers check for nil.
|
||||
func NewPoller(feeds []FeedConfig, fetch Fetcher, notes Notes, marks Marks, embed Embedder, ranker Ranker, cfg Config) *Poller {
|
||||
var valid []FeedConfig
|
||||
for _, f := range feeds {
|
||||
if strings.TrimSpace(f.Name) == "" || strings.TrimSpace(f.URL) == "" {
|
||||
log.Printf("rss: skipping a feed with no name or no url")
|
||||
continue
|
||||
}
|
||||
valid = append(valid, f)
|
||||
}
|
||||
if len(valid) == 0 || fetch == nil || notes == nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.DefaultInterval <= 0 {
|
||||
cfg.DefaultInterval = DefaultPollInterval
|
||||
}
|
||||
if cfg.MaxItems <= 0 {
|
||||
cfg.MaxItems = DefaultMaxItems
|
||||
}
|
||||
if cfg.MaxAge <= 0 {
|
||||
cfg.MaxAge = DefaultMaxAge
|
||||
}
|
||||
return &Poller{
|
||||
feeds: valid, fetch: fetch, notes: notes, marks: marks,
|
||||
embed: embed, ranker: ranker, cfg: cfg,
|
||||
nextDue: map[string]time.Time{},
|
||||
seen: map[string]map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// Feeds returns the configured feeds (the answer path lists categories).
|
||||
func (p *Poller) Feeds() []FeedConfig { return p.feeds }
|
||||
|
||||
// PollDue reads every feed whose interval has elapsed and returns how many
|
||||
// notes were written. Errors are logged per feed, never returned: one dead feed
|
||||
// must not stop the others, and there is nobody waiting on this.
|
||||
func (p *Poller) PollDue(ctx context.Context, now time.Time) int {
|
||||
written := 0
|
||||
for _, f := range p.feeds {
|
||||
if due, ok := p.nextDue[f.Name]; ok && now.Before(due) {
|
||||
continue
|
||||
}
|
||||
interval := f.Interval
|
||||
if interval <= 0 {
|
||||
interval = p.cfg.DefaultInterval
|
||||
}
|
||||
p.nextDue[f.Name] = now.Add(interval)
|
||||
n, err := p.PollFeed(ctx, f, now)
|
||||
if err != nil {
|
||||
// The URL is configured by him and not a secret, so it is loggable;
|
||||
// item titles are not logged, only counts.
|
||||
log.Printf("rss: feed %s: %v", f.Name, err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("rss: feed %s: %d new item(s) noted", f.Name, n)
|
||||
}
|
||||
written += n
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// PollFeed reads one feed now, regardless of its schedule.
|
||||
func (p *Poller) PollFeed(ctx context.Context, f FeedConfig, now time.Time) (int, error) {
|
||||
body, err := p.fetch.Get(ctx, f.URL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
feed, err := Parse(strings.NewReader(string(body.Bytes)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
mark := p.mark(ctx, f.Name, now)
|
||||
newest := mark
|
||||
written := 0
|
||||
for _, it := range feed.Items {
|
||||
if written >= p.cfg.MaxItems {
|
||||
break
|
||||
}
|
||||
if !p.fresh(f, it, mark, now) {
|
||||
continue
|
||||
}
|
||||
if !Matches(f, it) {
|
||||
continue
|
||||
}
|
||||
if p.ranker != nil {
|
||||
ok, err := p.ranker.Relevant(ctx, it.Title+" "+it.Summary)
|
||||
if err != nil {
|
||||
log.Printf("rss: feed %s: relevance: %v", f.Name, err)
|
||||
} else if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := p.write(ctx, f, it, now); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written++
|
||||
if it.Published.After(newest) {
|
||||
newest = it.Published
|
||||
}
|
||||
}
|
||||
if p.marks != nil && newest.After(mark) {
|
||||
if err := p.marks.SetMark(ctx, f.Name, newest); err != nil {
|
||||
log.Printf("rss: feed %s: save mark: %v", f.Name, err)
|
||||
}
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// mark — how far this feed was read. A feed with no mark starts MaxAge ago, so
|
||||
// a first poll takes today's headlines instead of the whole archive.
|
||||
func (p *Poller) mark(ctx context.Context, feed string, now time.Time) time.Time {
|
||||
cold := now.Add(-p.cfg.MaxAge)
|
||||
if p.marks == nil {
|
||||
return cold
|
||||
}
|
||||
at, err := p.marks.LastMark(ctx, feed)
|
||||
if err != nil || at.IsZero() {
|
||||
return cold
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// fresh — two dedup rules, because feeds are inconsistent about dates. A dated
|
||||
// item must be newer than the mark; an undated one is kept once per process by
|
||||
// ID. Both are needed: dates alone re-import undated feeds forever, IDs alone
|
||||
// lose their memory on restart.
|
||||
func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time) bool {
|
||||
if !it.Published.IsZero() {
|
||||
if !it.Published.After(mark) {
|
||||
return false
|
||||
}
|
||||
// A feed that dates its items in the future (or a clock skew) must not
|
||||
// win the mark and mute everything after it.
|
||||
return !it.Published.After(now.Add(time.Hour))
|
||||
}
|
||||
id := it.ID
|
||||
if id == "" {
|
||||
id = it.Title
|
||||
}
|
||||
if p.seen[f.Name] == nil {
|
||||
p.seen[f.Name] = map[string]bool{}
|
||||
}
|
||||
if p.seen[f.Name][id] {
|
||||
return false
|
||||
}
|
||||
p.seen[f.Name][id] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// write stores one item as a note. Source "rss:<feed>" is what the answer path
|
||||
// filters on, and what makes a feed note distinguishable from something he said.
|
||||
func (p *Poller) write(ctx context.Context, f FeedConfig, it Item, now time.Time) error {
|
||||
text := NoteText(f, it)
|
||||
var vec []float32
|
||||
if p.embed != nil {
|
||||
v, err := p.embed.Embed(ctx, text)
|
||||
if err != nil {
|
||||
log.Printf("rss: feed %s: embed: %v", f.Name, err)
|
||||
} else {
|
||||
vec = v
|
||||
}
|
||||
}
|
||||
ts := it.Published
|
||||
if ts.IsZero() {
|
||||
ts = now
|
||||
}
|
||||
if _, err := p.notes.WriteNote(ctx, ts, text, vec, SourceFor(f.Name)); err != nil {
|
||||
return fmt.Errorf("write note: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceFor is the note source for a feed.
|
||||
func SourceFor(feed string) string { return "rss:" + feed }
|
||||
|
||||
// SourcePrefix — what the answer path matches to find feed notes.
|
||||
const SourcePrefix = "rss:"
|
||||
|
||||
// NoteText renders an item as the note body. The category is included because
|
||||
// "что нового по технологиям?" is answered by reading notes, and a note has to
|
||||
// carry enough to be recognised as belonging to that category.
|
||||
func NoteText(f FeedConfig, it Item) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(it.Title)
|
||||
if f.Category != "" {
|
||||
fmt.Fprintf(&b, " [%s]", f.Category)
|
||||
}
|
||||
if it.Summary != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(trimRunes(it.Summary, 500))
|
||||
}
|
||||
if it.Link != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(it.Link)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// trimRunes cuts on a rune boundary — a note is Russian as often as English and
|
||||
// half a cyrillic letter is a broken note.
|
||||
func trimRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(string(r[:max])) + "…"
|
||||
}
|
||||
|
||||
// Matches applies the per-feed keyword filter: keep when Include is empty or one
|
||||
// include matches, drop when any exclude matches. Case-insensitive substring,
|
||||
// which for Russian is the honest choice — no stemmer here, so "выборы" does not
|
||||
// match "выборах", and a filter he writes is a filter he can predict.
|
||||
func Matches(f FeedConfig, it Item) bool {
|
||||
hay := strings.ToLower(it.Title + " " + it.Summary)
|
||||
for _, x := range f.Exclude {
|
||||
if x = strings.ToLower(strings.TrimSpace(x)); x != "" && strings.Contains(hay, x) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(f.Include) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, in := range f.Include {
|
||||
if in = strings.ToLower(strings.TrimSpace(in)); in != "" && strings.Contains(hay, in) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user