Files
Maven/internal/rss/poller.go
T
claude f7b76c572f Bound the undated-item set per feed (V-641)
rss.Poller.seen held every undated item ever seen, one entry per id, for as
long as mavend ran. fresh() added and nothing removed. A feed that ships items
with no <pubDate> grew it forever.

seenIDs is the same set with a bound: the map answers the lookup, a slice
remembers insertion order, and the oldest id falls out past 512. The cap has
to stay above any one feed's front page or an item still listed there would be
written a second time, and a few hundred covers the largest page anyone
publishes. The set only ever had to span one poll window plus the resync
guard, not all of history.

Dedupe behaviour is unchanged. The comment at fresh() explains why the set
does not survive a restart; it never bounded it within one run.
2026-08-07 01:32:15 +04:00

472 lines
16 KiB
Go

package rss
import (
"bytes"
"context"
"fmt"
"log"
"sort"
"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]*seenIDs // feed → item IDs, for items with no date
polled map[string]bool // feed → polled at least once in THIS process
}
// 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) == "" {
// Name the offender. A silent skip in a list of six feeds is a
// config typo nobody finds.
log.Printf("rss: skipping feed %q (%q): a feed needs both a name and a url", f.Name, f.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]*seenIDs{},
polled: 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
}
// bytes.NewReader and not strings.NewReader(string(…)): the latter copied a
// feed document that can run to a megabyte, for nothing.
feed, err := Parse(bytes.NewReader(body.Bytes))
if err != nil {
return 0, err
}
mark, durable := p.mark(ctx, f.Name, now)
// resync — the first poll of this feed since the process started, on a feed
// we have read before. Undated items are deduped by an in-memory ID set that
// dies with the process, so on this poll they are all "unseen" again and
// would all be re-noted. See fresh.
resync := durable && !p.polled[f.Name]
p.polled[f.Name] = true
// Gather first, cap second, and write OLDEST first.
//
// The old loop walked the feed newest-first and stopped at MaxItems, then
// marked the newest item it had written. Feeds are newest-first, so with
// twenty new items and a cap of five it wrote the five newest and moved the
// mark past all twenty: items six through twenty were older than the mark on
// the next poll and were dropped for good. max_items reads as a pacing knob
// in the config doc, and that made it a silent loss. Writing the oldest five
// and marking the newest of THOSE is pacing: the rest arrive over the polls
// that follow, in order, each one exactly once.
var cands []Item
sawUndated := false
for _, it := range feed.Items {
if it.Published.IsZero() {
sawUndated = true
}
if !p.fresh(f, it, mark, now, resync) {
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
}
}
cands = append(cands, it)
}
sort.SliceStable(cands, func(i, j int) bool {
return itemTime(cands[i], now).Before(itemTime(cands[j], now))
})
if len(cands) > p.cfg.MaxItems {
cands = cands[:p.cfg.MaxItems]
}
newest := time.Time{}
written := 0
for _, it := range cands {
if err := p.write(ctx, f, it, now); err != nil {
return written, err
}
written++
if it.Published.After(newest) {
newest = it.Published
}
}
p.advance(ctx, f.Name, mark, newest, sawUndated, now)
return written, nil
}
// itemTime — an item's own date, or now when the feed did not give one. Undated
// items sort last, which is the only defensible guess: they were seen now.
func itemTime(it Item, now time.Time) time.Time {
if it.Published.IsZero() {
return now
}
return it.Published
}
// advance moves the durable mark to the newest item actually WRITTEN. Because
// the cap is applied to the oldest candidates (see PollFeed), that is never
// ahead of an item still waiting to be read.
//
// A feed whose items carry no dates gets the mark set to now instead. Nothing
// else would ever set it, and the mark's existence is what tells the next
// process that this feed has been read before.
func (p *Poller) advance(ctx context.Context, feed string, mark, newest time.Time, sawUndated bool, now time.Time) {
if p.marks == nil {
return
}
at := newest
if at.IsZero() && sawUndated {
at = now
}
if at.IsZero() || !at.After(mark) {
return
}
if err := p.marks.SetMark(ctx, feed, at); err != nil {
log.Printf("rss: feed %s: save mark: %v", feed, err)
}
}
// mark — how far this feed was read, and whether that came from the durable
// store. A feed with no mark starts MaxAge ago, so a first poll takes today's
// headlines instead of the whole archive; durable is false in that case, and it
// is what tells PollFeed the difference between "never read" and "read by an
// earlier process".
func (p *Poller) mark(ctx context.Context, feed string, now time.Time) (time.Time, bool) {
cold := now.Add(-p.cfg.MaxAge)
if p.marks == nil {
return cold, false
}
at, err := p.marks.LastMark(ctx, feed)
if err != nil || at.IsZero() {
return cold, false
}
return at, true
}
// maxSeenPerFeed bounds the undated-item set. It has to stay comfortably above
// any one feed's front page, or an item still listed there would fall out of the
// set and be written a second time. A few hundred entries covers the largest
// page anyone publishes, and the set only has to span one poll window plus the
// resync guard, not all of history.
const maxSeenPerFeed = 512
// seenIDs is a bounded insertion-ordered set. The map answers the lookup, the
// slice remembers what to drop first, so an undated feed cannot grow the poller
// for as long as mavend runs.
type seenIDs struct {
ids map[string]bool
order []string
}
// add records id and reports whether it was new.
func (s *seenIDs) add(id string) bool {
if s.ids == nil {
s.ids = make(map[string]bool, maxSeenPerFeed)
}
if s.ids[id] {
return false
}
s.ids[id] = true
s.order = append(s.order, id)
if len(s.order) > maxSeenPerFeed {
delete(s.ids, s.order[0])
s.order = s.order[1:]
}
return true
}
// 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.
//
// The ID set does not survive a restart, and on its own that re-notes an undated
// feed's whole front page on every boot — five notes, then five more, all stamped
// `now`, sitting at the top of the recent-notes window and crowding out the notes
// he actually made. A crash loop turns it into a flood. So on the first poll after
// a restart of a feed we have read before (resync), undated items are recorded as
// seen and NOT written. The cost is the undated items that appeared while the
// daemon was down. That is a bounded loss, and the alternative is an unbounded one.
func (p *Poller) fresh(f FeedConfig, it Item, mark, now time.Time, resync bool) 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] = &seenIDs{}
}
if !p.seen[f.Name].add(id) {
return false
}
return !resync
}
// 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()
}
// NoteHeadline is the part of a feed note she reads out: the first line with the
// category tag taken off. The tag is bookkeeping for the answer path, and piper
// says brackets out loud — "Заголовок [технологии]" is what he heard before.
func NoteHeadline(text string) string {
line := text
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if head, _, ok := splitTag(line); ok {
return head
}
return line
}
// NoteCategory is the category tag a feed note carries, empty when it has none.
// Matching a topic against THIS rather than against the whole note is what keeps
// "что нового про погоду" from matching a tech headline whose link happens to
// contain "pogod".
func NoteCategory(text string) string {
line := text
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
_, tag, _ := splitTag(strings.TrimSpace(line))
return tag
}
// splitTag pulls a trailing "[...]" off a headline. Only a trailing one: a title
// that opens with "[перевод]" is the feed's own word, not ours.
func splitTag(line string) (head, tag string, ok bool) {
if !strings.HasSuffix(line, "]") {
return line, "", false
}
i := strings.LastIndexByte(line, '[')
if i < 0 {
return line, "", false
}
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1 : len(line)-1]), true
}
// 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
}