1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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
}
|