aboutsummaryrefslogtreecommitdiff
path: root/cmds/feeds.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmds/feeds.go')
-rw-r--r--cmds/feeds.go185
1 files changed, 185 insertions, 0 deletions
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
+}