aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSam Scholten2026-10-07 15:31:52 +1000
committerSam Scholten2026-10-07 15:31:52 +1000
commit2f75794194209586d0b17d26e4e87d94d3e6aead (patch)
tree069798e987a7bc2a395baa38fbe4d27c3cba1dce
parentf84f1c0e26908e2a70777441e1174a26dca10b91 (diff)
downloadfluxrec-2f75794194209586d0b17d26e4e87d94d3e6aead.tar.gz
fluxrec-2f75794194209586d0b17d26e4e87d94d3e6aead.zip
feeds: subscription audit command (per-feed volume + star stats)
GET /v1/feeds + feed_id entries filter in the client, one cheap volume probe per feed over a --days window, star stats joined from labels.jsonl by feed id, category column, --min-per-day/--min-seen filters. Read-only; justfile recipe with one-line doc comments throughout.
-rw-r--r--README.md11
-rw-r--r--cmds/feeds.go185
-rw-r--r--cmds/feeds_test.go278
-rw-r--r--justfile20
-rw-r--r--main.go3
-rw-r--r--miniflux/client.go36
-rw-r--r--miniflux/client_test.go59
7 files changed, 577 insertions, 15 deletions
diff --git a/README.md b/README.md
index eb84765..1288601 100644
--- a/README.md
+++ b/README.md
@@ -15,11 +15,12 @@ No deps beyond stdlib + vendored ML core.
## Commands
```
-fluxrec export stars + sampled negatives → labels.jsonl (local)
-fluxrec train labels.jsonl → model.json + report.json (local)
-fluxrec serve poll, score, RSS at /recommendations.xml (server)
-fluxrec retrostar model-ranked candidates to star by hand (cold start)
-fluxrec score debug: stdin titles → stdout scores
+fluxrec export stars + sampled negatives → labels.jsonl (local)
+fluxrec train labels.jsonl → model.json + report.json (local)
+fluxrec serve poll, score, RSS at /recommendations.xml (server)
+fluxrec retrostar model-ranked candidates to star by hand (cold start)
+fluxrec feeds per-feed volume + star stats (pruning aid)
+fluxrec score debug: stdin titles → stdout scores
```
## Setup
diff --git a/cmds/feeds.go b/cmds/feeds.go
new file mode 100644
index 0000000..48d07de
--- /dev/null
+++ b/cmds/feeds.go
@@ -0,0 +1,185 @@
+// Feeds command: subscription pruning audit. Lists every subscribed feed
+// with its entry volume over a recent window (one cheap /v1/entries probe
+// per feed: limit=1, read only the total) joined with star stats from
+// labels.jsonl, so "loud" and "valuable to me" sit in the same table.
+// Read-only — it never writes to Miniflux; unsubscribing stays a human
+// action in the UI.
+//
+// The two columns have different denominators on purpose: volume comes
+// from the API (everything published in the window), stars come from
+// labels.jsonl (the sample export happened to capture, refreshed only when
+// export runs). Star rate is therefore a heuristic, and feeds with no
+// label rows render "-" — never confuse "no data" with a 0% rate.
+package cmds
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "sort"
+ "time"
+
+ "fluxrec/core"
+ "fluxrec/miniflux"
+)
+
+type FeedsCommand struct {
+ Days int
+ Labels string
+ MinPerDay float64
+ MinSeen int
+}
+
+func (c *FeedsCommand) Name() string { return "feeds" }
+
+func (c *FeedsCommand) Init(args []string) error {
+ fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
+ fs.Usage = func() {
+ fmt.Fprint(fs.Output(), `Usage: fluxrec feeds [flags]
+
+Print one row per subscribed feed: entry volume over the window (from the
+API) plus stars seen in labels.jsonl, sorted loudest first. For spotting
+dailies and high-volume feeds worth pruning. Read-only: nothing is
+written to Miniflux.
+
+Filter units differ on purpose: volume scales with the window, so its
+threshold is per-day (window-invariant); "seen" counts label rows, which
+accumulate over export history rather than the window, so its threshold
+is an absolute sample size.
+`)
+ fs.PrintDefaults()
+ }
+ fs.IntVar(&c.Days, "days", 180, "Count entries published within this many days")
+ fs.StringVar(&c.Labels, "labels", "labels.jsonl", "Path to labels.jsonl for star stats (empty = volume columns only)")
+ fs.Float64Var(&c.MinPerDay, "min-per-day", 0.05, "Only show feeds averaging at least this many entries/day (0 = all; default hides dead/dormant feeds)")
+ fs.IntVar(&c.MinSeen, "min-seen", 0, "Only show feeds with at least this many labeled rows (0 = all; hides '-' rows)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if c.Days < 1 {
+ return fmt.Errorf("--days must be >= 1, got %d", c.Days)
+ }
+ if c.MinPerDay < 0 {
+ return fmt.Errorf("--min-per-day must be >= 0, got %g", c.MinPerDay)
+ }
+ if c.MinSeen < 0 {
+ return fmt.Errorf("--min-seen must be >= 0, got %d", c.MinSeen)
+ }
+ return nil
+}
+
+// feedRow is one line of the audit table.
+type feedRow struct {
+ feed miniflux.FeedInfo
+ volume int // entries in the window (API total)
+ seen int // labeled rows for this feed (sample, not all entries)
+ stars int // of those, the positives
+ hasLbl bool // any rows at all — distinguishes "no data" from "0%"
+}
+
+func (c *FeedsCommand) Run(stdin io.Reader, stdout io.Writer) error {
+ cfg, err := miniflux.ConfigFromEnv()
+ if err != nil {
+ return err
+ }
+ client := miniflux.NewClient(cfg)
+
+ feeds, err := client.Feeds()
+ if err != nil {
+ return err
+ }
+
+ // Star stats per feed, joined by feed ID so renames are harmless.
+ // A missing labels file is not an error: a fresh checkout runs the
+ // audit volume-only.
+ stats := map[int64]*feedRow{}
+ if c.Labels != "" {
+ rows, err := core.ReadLabels(c.Labels)
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("reading labels: %w", err)
+ }
+ for _, r := range rows {
+ s := stats[r.FeedID]
+ if s == nil {
+ s = &feedRow{}
+ stats[r.FeedID] = s
+ }
+ s.seen++
+ if r.Label == core.LabelPos {
+ s.stars++
+ }
+ s.hasLbl = true
+ }
+ }
+
+ cutoff := time.Now().Add(-time.Duration(c.Days) * 24 * time.Hour).Unix()
+ table := make([]feedRow, 0, len(feeds))
+ for _, f := range feeds {
+ vol, err := c.feedVolume(client, f.ID, cutoff)
+ if err != nil {
+ return err
+ }
+ row := feedRow{feed: f, volume: vol}
+ if s := stats[f.ID]; s != nil {
+ row.seen, row.stars, row.hasLbl = s.seen, s.stars, s.hasLbl
+ }
+ table = append(table, row)
+ }
+
+ sort.SliceStable(table, func(i, j int) bool {
+ if table[i].volume == table[j].volume {
+ return table[i].feed.ID < table[j].feed.ID
+ }
+ return table[i].volume > table[j].volume
+ })
+
+ fmt.Fprintf(stdout, "%-6s %-36s %-16s %6s %6s %6s %6s %6s\n", "id", "feed", "category", "days", "/day", "stars", "seen", "rate")
+ shown := 0
+ for _, r := range table {
+ perDay := float64(r.volume) / float64(c.Days)
+ if perDay < c.MinPerDay || r.seen < c.MinSeen {
+ continue
+ }
+ shown++
+ stars, seen, rate := "-", "-", "-"
+ if r.hasLbl {
+ stars = fmt.Sprint(r.stars)
+ seen = fmt.Sprint(r.seen)
+ rate = fmt.Sprintf("%.0f%%", 100*float64(r.stars)/float64(r.seen))
+ }
+ disabled := ""
+ if r.feed.Disabled {
+ disabled = " (disabled)"
+ }
+ fmt.Fprintf(stdout, "%-6d %-36s %-16s %6d %6.1f %6s %6s %6s%s\n",
+ r.feed.ID, clip(r.feed.Title, 36), clip(r.feed.Category.Title, 16),
+ r.volume, perDay, stars, seen, rate, disabled)
+ }
+ fmt.Fprintf(stdout, "feeds: showing %d of %d subscriptions, window %d days; star stats from %s (sample — export refreshes it)\n",
+ shown, len(table), c.Days, c.Labels)
+ return nil
+}
+
+// clip shortens s to at most n runes, ending a truncated string with an
+// ellipsis, so one over-long title can't break the table's alignment.
+func clip(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ if n <= 1 {
+ return string(r[:n])
+ }
+ return string(r[:n-1]) + "…"
+}
+
+// feedVolume counts entries published at or after cutoff for one feed via
+// a limit=1 probe — the cheap way to read EntrySet.Total.
+func (c *FeedsCommand) feedVolume(client *miniflux.Client, feedID int64, cutoff int64) (int, error) {
+ set, err := client.Entries(miniflux.Filters{FeedID: feedID, PublishedAfter: cutoff, Limit: 1})
+ if err != nil {
+ return 0, fmt.Errorf("counting feed %d: %w", feedID, err)
+ }
+ return set.Total, nil
+}
diff --git a/cmds/feeds_test.go b/cmds/feeds_test.go
new file mode 100644
index 0000000..60151cc
--- /dev/null
+++ b/cmds/feeds_test.go
@@ -0,0 +1,278 @@
+// Test for `fluxrec feeds` against an httptest fake Miniflux: rows are
+// sorted loudest-first, label stats join by feed ID, and a feed with no
+// label rows renders "-" rather than a fake 0% rate.
+package cmds
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "fluxrec/core"
+)
+
+// feedsFake serves three feeds and per-feed entry counts keyed by the
+// feed_id query param, honoring published_after like pagedServer does.
+type feedsFake struct {
+ feedsJSON string
+ pool map[int64][]testEntry // feed ID -> entries
+ queries []string
+}
+
+func (f *feedsFake) handler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ f.queries = append(f.queries, r.URL.Path+"?"+r.URL.RawQuery)
+ w.Header().Set("Content-Type", "application/json")
+ if r.URL.Path == "/v1/feeds" {
+ fmt.Fprint(w, f.feedsJSON)
+ return
+ }
+ q := r.URL.Query()
+ fid, _ := strconv.ParseInt(q.Get("feed_id"), 10, 64)
+ pool := f.pool[fid]
+ if pa, _ := strconv.ParseInt(q.Get("published_after"), 10, 64); pa > 0 {
+ cutoff := time.Unix(pa, 0)
+ filtered := pool[:0:0]
+ for _, e := range pool {
+ if !e.PublishedAt.Before(cutoff) {
+ filtered = append(filtered, e)
+ }
+ }
+ pool = filtered
+ }
+ limit, _ := strconv.Atoi(q.Get("limit"))
+ if limit <= 0 {
+ limit = len(pool)
+ }
+ if limit > len(pool) {
+ limit = len(pool)
+ }
+ fmt.Fprint(w, entriesJSON(len(pool), pool[:limit]))
+ }
+}
+
+func TestFeedsAudit(t *testing.T) {
+ now := time.Now()
+ fresh := func(entries []testEntry) []testEntry {
+ for i := range entries {
+ entries[i].PublishedAt = now.Add(-24 * time.Hour)
+ }
+ return entries
+ }
+ fake := &feedsFake{
+ feedsJSON: `[
+ {"id": 1, "title": "Quiet Blog", "site_url": "https://quiet.example", "disabled": false,
+ "category": {"id": 10, "title": "Blogs"}},
+ {"id": 2, "title": "Firehose Daily", "site_url": "https://fire.example", "disabled": false,
+ "category": {"id": 11, "title": "Flood"}},
+ {"id": 3, "title": "New Sub", "site_url": "https://new.example", "disabled": false,
+ "category": {"id": 10, "title": "Blogs"}}
+ ]`,
+ pool: map[int64][]testEntry{
+ 1: fresh(makeEntries(2, 100, false, 1)),
+ 2: fresh(makeEntries(90, 200, false, 2)),
+ 3: fresh(makeEntries(4, 300, false, 3)),
+ },
+ }
+ srv := httptest.NewServer(fake.handler())
+ defer srv.Close()
+ t.Setenv("MINIFLUX_URL", srv.URL)
+ t.Setenv("MINIFLUX_TOKEN", "test-token")
+
+ dir := t.TempDir()
+ labelsPath := filepath.Join(dir, "labels.jsonl")
+ if err := core.AppendLabels(labelsPath, []core.LabelRow{
+ {EntryID: 1, URL: "https://quiet.example/a", Title: "A", FeedID: 1, FeedTitle: "Quiet Blog", Label: core.LabelPos, CapturedAt: now},
+ {EntryID: 2, URL: "https://quiet.example/b", Title: "B", FeedID: 1, FeedTitle: "Quiet Blog", Label: core.LabelNeg, CapturedAt: now},
+ {EntryID: 3, URL: "https://fire.example/c", Title: "C", FeedID: 2, FeedTitle: "Firehose Daily", Label: core.LabelNeg, CapturedAt: now},
+ }); err != nil {
+ t.Fatalf("AppendLabels: %v", err)
+ }
+
+ cmd := &FeedsCommand{Days: 30, Labels: labelsPath}
+ var out bytes.Buffer
+ if err := cmd.Run(nil, &out); err != nil {
+ t.Fatalf("feeds: %v", err)
+ }
+ got := out.String()
+
+ // Sorted by volume: Firehose (90) before New Sub (4) before Quiet (2).
+ fi, ni, qi := strings.Index(got, "Firehose Daily"), strings.Index(got, "New Sub"), strings.Index(got, "Quiet Blog")
+ if fi < 0 || ni < 0 || qi < 0 {
+ t.Fatalf("output missing a feed row:\n%s", got)
+ }
+ if !(fi < ni && ni < qi) {
+ t.Errorf("rows not sorted by volume desc:\n%s", got)
+ }
+
+ // Label stats joined by feed ID: Quiet = 1/2 (50%), Firehose = 0/1 (0%).
+ if !strings.Contains(got, "50%") {
+ t.Errorf("Quiet Blog rate should be 50%%:\n%s", got)
+ }
+
+ // New Sub has no label rows: "-" placeholders, not a bogus 0%.
+ for _, line := range strings.Split(got, "\n") {
+ if strings.Contains(line, "New Sub") && !strings.Contains(line, "-") {
+ t.Errorf("New Sub should render '-' for missing label stats: %q", line)
+ }
+ }
+
+ // Per-feed day's rate: Firehose = 90/30 = 3.0/day.
+ if !strings.Contains(got, "3.0") {
+ t.Errorf("Firehose per-day rate missing:\n%s", got)
+ }
+
+ // Categories render next to their feeds.
+ for _, line := range strings.Split(got, "\n") {
+ if strings.Contains(line, "Firehose Daily") && !strings.Contains(line, "Flood") {
+ t.Errorf("Firehose row missing its category: %q", line)
+ }
+ if strings.Contains(line, "Quiet Blog") && !strings.Contains(line, "Blogs") {
+ t.Errorf("Quiet Blog row missing its category: %q", line)
+ }
+ }
+
+ // Unfiltered run shows all three feeds.
+ if !strings.Contains(got, "showing 3 of 3 subscriptions") {
+ t.Errorf("footer should count all feeds when unfiltered:\n%s", got)
+ }
+
+ // One /v1/feeds call plus one volume probe per feed, each carrying
+ // feed_id and published_after.
+ if len(fake.queries) != 4 {
+ t.Fatalf("expected 4 API calls, got %d: %v", len(fake.queries), fake.queries)
+ }
+ for _, q := range fake.queries[1:] {
+ if !strings.Contains(q, "feed_id=") || !strings.Contains(q, "published_after=") {
+ t.Errorf("volume probe missing filters: %q", q)
+ }
+ }
+}
+
+// Filters: --min-per-day keeps only the firehose (volume scales with the
+// window, so its threshold is per-day); --min-seen keeps only the
+// well-sampled feed (label rows accumulate over export history, so its
+// threshold is absolute).
+func TestFeedsFilters(t *testing.T) {
+ now := time.Now()
+ fresh := func(entries []testEntry) []testEntry {
+ for i := range entries {
+ entries[i].PublishedAt = now.Add(-24 * time.Hour)
+ }
+ return entries
+ }
+ newServer := func(t *testing.T) *httptest.Server {
+ t.Helper()
+ fake := &feedsFake{
+ feedsJSON: `[
+ {"id": 1, "title": "Loud", "site_url": "https://loud.example", "category": {"id": 1, "title": "Flood"}},
+ {"id": 2, "title": "Quiet", "site_url": "https://quiet.example", "category": {"id": 2, "title": "Blogs"}}
+ ]`,
+ pool: map[int64][]testEntry{
+ 1: fresh(makeEntries(90, 100, false, 1)),
+ 2: fresh(makeEntries(3, 500, false, 2)),
+ },
+ }
+ srv := httptest.NewServer(fake.handler())
+ t.Cleanup(srv.Close)
+ t.Setenv("MINIFLUX_URL", srv.URL)
+ t.Setenv("MINIFLUX_TOKEN", "test-token")
+ return srv
+ }
+
+ labelsPath := filepath.Join(t.TempDir(), "labels.jsonl")
+ rows := []core.LabelRow{}
+ for i := 0; i < 2; i++ {
+ rows = append(rows, core.LabelRow{EntryID: int64(10 + i), URL: "u", Title: "t",
+ FeedID: 1, FeedTitle: "Loud", Label: core.LabelNeg, CapturedAt: now})
+ }
+ for i := 0; i < 10; i++ {
+ rows = append(rows, core.LabelRow{EntryID: int64(100 + i), URL: "u", Title: "t",
+ FeedID: 2, FeedTitle: "Quiet", Label: core.LabelNeg, CapturedAt: now})
+ }
+ if err := core.AppendLabels(labelsPath, rows); err != nil {
+ t.Fatalf("AppendLabels: %v", err)
+ }
+
+ run := func(cmd *FeedsCommand) string {
+ t.Helper()
+ var out bytes.Buffer
+ if err := cmd.Run(nil, &out); err != nil {
+ t.Fatalf("feeds: %v", err)
+ }
+ return out.String()
+ }
+
+ newServer(t)
+ got := run(&FeedsCommand{Days: 30, Labels: labelsPath, MinPerDay: 1.0})
+ if !strings.Contains(got, "Loud") || strings.Contains(got, "Quiet") {
+ t.Errorf("--min-per-day 1.0 should keep only Loud (3.0/day vs 0.1/day):\n%s", got)
+ }
+ if !strings.Contains(got, "showing 1 of 2") {
+ t.Errorf("filtered footer should count the shown subset:\n%s", got)
+ }
+
+ newServer(t)
+ got = run(&FeedsCommand{Days: 30, Labels: labelsPath, MinSeen: 5})
+ if strings.Contains(got, "Loud") || !strings.Contains(got, "Quiet") {
+ t.Errorf("--min-seen 5 should keep only Quiet (10 rows vs 2):\n%s", got)
+ }
+}
+
+// Without a labels file the audit still prints the volume columns (fresh
+// checkout) rather than erroring.
+func TestFeedsAuditWithoutLabels(t *testing.T) {
+ fake := &feedsFake{
+ feedsJSON: `[{"id": 1, "title": "Quiet Blog", "site_url": "https://quiet.example", "disabled": false}]`,
+ pool: map[int64][]testEntry{1: {}},
+ }
+ srv := httptest.NewServer(fake.handler())
+ defer srv.Close()
+ t.Setenv("MINIFLUX_URL", srv.URL)
+ t.Setenv("MINIFLUX_TOKEN", "test-token")
+
+ cmd := &FeedsCommand{Days: 30, Labels: filepath.Join(t.TempDir(), "nonexistent.jsonl")}
+ var out bytes.Buffer
+ if err := cmd.Run(nil, &out); err != nil {
+ t.Fatalf("feeds without labels file: %v", err)
+ }
+ if !strings.Contains(out.String(), "Quiet Blog") {
+ t.Errorf("volume-only audit should still list the feed:\n%s", out.String())
+ }
+}
+
+// Guard: entries outside the window must not count toward volume. (Kept
+// explicit because the fake honors published_after — if the command ever
+// stops sending it, this fails loudly instead of silently inflating counts.)
+func TestFeedsAuditWindowFilter(t *testing.T) {
+ now := time.Now()
+ entries := makeEntries(3, 100, false, 1)
+ entries[0].PublishedAt = now.Add(-24 * time.Hour) // in window
+ entries[1].PublishedAt = now.Add(-10 * 24 * time.Hour) // in window
+ entries[2].PublishedAt = now.Add(-200 * 24 * time.Hour) // out: 200d > 30d window
+ fake := &feedsFake{
+ feedsJSON: `[{"id": 1, "title": "Sparse Blog", "site_url": "https://sparse.example", "disabled": false}]`,
+ pool: map[int64][]testEntry{1: entries},
+ }
+ srv := httptest.NewServer(fake.handler())
+ defer srv.Close()
+ t.Setenv("MINIFLUX_URL", srv.URL)
+ t.Setenv("MINIFLUX_TOKEN", "test-token")
+
+ cmd := &FeedsCommand{Days: 30, Labels: ""}
+ var out bytes.Buffer
+ if err := cmd.Run(nil, &out); err != nil {
+ t.Fatalf("feeds: %v", err)
+ }
+ for _, line := range strings.Split(out.String(), "\n") {
+ if strings.Contains(line, "Sparse Blog") && !strings.Contains(line, " 2 ") {
+ t.Errorf("Sparse Blog volume should be 2 (window applied): %q", line)
+ }
+ }
+}
diff --git a/justfile b/justfile
index 666ed85..f9fb1bd 100644
--- a/justfile
+++ b/justfile
@@ -39,10 +39,15 @@ dump-fixtures DIR="testdata": check-auth
retrostar LIMIT="50":
go run . retrostar --limit {{LIMIT}}
+# Pruning audit: per-feed volume + star stats
+feeds DAYS="180" MIN_PER_DAY="0.05" MIN_SEEN="0": check-auth
+ go run . feeds --days 180 --min-per-day 0.05 --min-seen 0
+
# ── retrain loop ──
-# Export new labels, train, print the report path. Eyeball report.json
-# (precision@15 >= 0.10) before `just ship SERVER` — retrain never deploys.
+# Eyeball report.json (precision@15 >= 0.10) before `just ship SERVER` —
+# retrain never deploys.
+# Export new labels, train, print the report path
retrain: check-auth
go run . export
go run . train labels.jsonl
@@ -51,14 +56,15 @@ retrain: check-auth
# ── deploy lane (local PC → server) ──
# SERVER is an ssh config alias; the agent does auth. No hostnames in this
# file. Run `ship` only after eyeballing report.json — retrain never deploys.
+# The server state dir is bind-mounted at /data (created once on the VPS;
+# see README "Deployment"); fluxrec runs under the compose stack at
+# ~/services on the server.
-# Ship a freshly trained model.json to the server state dir (bind-mounted
-# at /data; created once on the VPS: see README "Deployment")
+# Ship a freshly trained model.json to the server state dir
deploy SERVER:
scp model.json {{SERVER}}:~/fluxrec-data/
-# Bounce the serve container so it picks up the new model (fluxrec runs
-# under the compose stack at ~/services on the server)
+# Bounce the serve container so it picks up the new model
restart SERVER:
ssh {{SERVER}} 'cd ~/services && docker-compose restart fluxrec'
@@ -68,4 +74,4 @@ ship SERVER: (deploy SERVER) (restart SERVER)
# Trigger an out-of-schedule poll (serialized + cooldown-gated server-side)
poll-now URL:
- curl -sS -X POST {{URL}}/api/poll \ No newline at end of file
+ curl -sS -X POST {{URL}}/api/poll
diff --git a/main.go b/main.go
index d5adc15..efd8118 100644
--- a/main.go
+++ b/main.go
@@ -38,6 +38,8 @@ func main() {
cmd = &cmds.ServeCommand{}
case "retrostar":
cmd = &cmds.RetrostarCommand{}
+ case "feeds":
+ cmd = &cmds.FeedsCommand{}
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmdName)
printHelp()
@@ -69,6 +71,7 @@ Commands:
train Train a TF-IDF + LR model from labels.jsonl
serve Poll Miniflux, score, serve recommendations RSS (server)
retrostar Model-ranked candidates for retroactive starring (cold start)
+ feeds Subscription audit: per-feed volume + star stats (pruning aid)
Usage:
echo "some title" | fluxrec score --model model.json
diff --git a/miniflux/client.go b/miniflux/client.go
index ae23fe3..d430852 100644
--- a/miniflux/client.go
+++ b/miniflux/client.go
@@ -82,6 +82,18 @@ type Feed struct {
Title string `json:"title"`
}
+// FeedInfo is one subscription as returned by GET /v1/feeds (the pruning-
+// audit view; richer than the per-entry Feed subset above).
+type FeedInfo struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ SiteURL string `json:"site_url"`
+ Disabled bool `json:"disabled"`
+ Category struct {
+ Title string `json:"title"`
+ } `json:"category"`
+}
+
// Entry is the subset of a Miniflux entry fluxrec needs. JSON field names
// match the Miniflux API verbatim so decoding is a straight mirror.
type Entry struct {
@@ -103,6 +115,7 @@ type Entry struct {
type Filters struct {
Starred *bool // only starred entries when true
Status string // "read" | "unread"; "" = any
+ FeedID int64 // restrict to one feed (feeds-lane volume probes)
ChangedAfter int64 // unix timestamp; incremental export cursor
PublishedAfter int64 // unix timestamp; only entries published after this
AfterEntryID int64 // serve-lane poll cursor
@@ -137,10 +150,28 @@ func (c *Client) RawEntries(f Filters) ([]byte, error) {
return c.fetchEntries(f)
}
+// Feeds calls GET /v1/feeds, returning every subscription (the audit lane:
+// one call, no paging).
+func (c *Client) Feeds() ([]FeedInfo, error) {
+ body, err := c.getJSON(c.baseURL + "/v1/feeds")
+ if err != nil {
+ return nil, err
+ }
+ var feeds []FeedInfo
+ if err := json.Unmarshal(body, &feeds); err != nil {
+ return nil, fmt.Errorf("decoding /v1/feeds response: %w", err)
+ }
+ return feeds, nil
+}
+
// fetchEntries issues the GET and returns the body on HTTP 200.
func (c *Client) fetchEntries(f Filters) ([]byte, error) {
- u := entriesURL(c.baseURL, f)
+ return c.getJSON(entriesURL(c.baseURL, f))
+}
+// getJSON GETs u with the auth header and returns the body on HTTP 200;
+// any other status becomes an error naming the status and a response sniff.
+func (c *Client) getJSON(u string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
@@ -174,6 +205,9 @@ func entriesURL(base string, f Filters) string {
if f.Status != "" {
q.Set("status", f.Status)
}
+ if f.FeedID > 0 {
+ q.Set("feed_id", strconv.FormatInt(f.FeedID, 10))
+ }
if f.ChangedAfter > 0 {
q.Set("changed_after", strconv.FormatInt(f.ChangedAfter, 10))
}
diff --git a/miniflux/client_test.go b/miniflux/client_test.go
index 8f8c6ad..e3774a3 100644
--- a/miniflux/client_test.go
+++ b/miniflux/client_test.go
@@ -80,7 +80,7 @@ const entriesFixture = `{
}`
// fakeServer records the last request's auth header and query, then replies
-// with the fixture (or an error status).
+// with the fixture (or an error status). Accepts /v1/entries and /v1/feeds.
type fakeServer struct {
t *testing.T
status int
@@ -98,7 +98,7 @@ func (f *fakeServer) handler() http.HandlerFunc {
for k, v := range r.URL.Query() {
f.gotQuery[k] = v[0]
}
- if r.URL.Path != "/v1/entries" {
+ if r.URL.Path != "/v1/entries" && r.URL.Path != "/v1/feeds" {
f.t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
@@ -165,6 +165,7 @@ func TestEntriesSendsFilters(t *testing.T) {
_, err := client.Entries(Filters{
Starred: &starred,
Status: "read",
+ FeedID: 77,
ChangedAfter: 1700000000,
PublishedAfter: 1700000000,
AfterEntryID: 40999,
@@ -178,6 +179,7 @@ func TestEntriesSendsFilters(t *testing.T) {
want := map[string]string{
"starred": "true",
"status": "read",
+ "feed_id": "77",
"changed_after": "1700000000",
"published_after": "1700000000",
"after_entry_id": "40999",
@@ -225,6 +227,59 @@ func TestEntriesBadJSON(t *testing.T) {
}
}
+const feedsFixture = `[
+ {
+ "id": 77,
+ "user_id": 3,
+ "feed_url": "https://example.com/feed.xml",
+ "site_url": "https://example.com",
+ "title": "Example Blog",
+ "disabled": false,
+ "category": {"id": 1, "title": "Tech", "user_id": 3}
+ },
+ {
+ "id": 102,
+ "user_id": 3,
+ "feed_url": "https://daily.example/rss",
+ "site_url": "https://daily.example",
+ "title": "Some Daily",
+ "disabled": true,
+ "category": {"id": 2, "title": "News", "user_id": 3}
+ }
+]`
+
+func TestFeedsDecodesFixture(t *testing.T) {
+ f := &fakeServer{t: t, status: http.StatusOK, body: feedsFixture}
+ client := newFakeClient(t, f)
+
+ feeds, err := client.Feeds()
+ if err != nil {
+ t.Fatalf("Feeds: %v", err)
+ }
+ if !f.sawRequest {
+ t.Fatal("fake server saw no request")
+ }
+ if f.gotHeader != "test-token-123" {
+ t.Errorf("X-Auth-Token = %q, want test-token-123", f.gotHeader)
+ }
+ if len(feeds) != 2 {
+ t.Fatalf("len(feeds) = %d, want 2", len(feeds))
+ }
+ if feeds[0].ID != 77 || feeds[0].Title != "Example Blog" || feeds[0].SiteURL != "https://example.com" {
+ t.Errorf("feeds[0] = %+v", feeds[0])
+ }
+ if feeds[0].Disabled {
+ t.Error("feeds[0].Disabled = true, want false")
+ }
+ if !feeds[1].Disabled {
+ t.Error("feeds[1].Disabled = false, want true")
+ }
+ if feeds[0].Category.Title != "Tech" || feeds[1].Category.Title != "News" {
+ t.Errorf("categories = %q / %q, want Tech / News",
+ feeds[0].Category.Title, feeds[1].Category.Title)
+ }
+}
+
func TestConfigFromEnv(t *testing.T) {
t.Run("primary vars", func(t *testing.T) {
t.Setenv("MINIFLUX_URL", "https://flux.example")