From cc66a7e171d12db9df8e4409de15361408e729db Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 02:23:38 +0200 Subject: [PATCH] feat: read content decay, posting cadence, post timelines and native posts --- README.md | 29 ++- go.mod | 2 +- go.sum | 2 + internal/cmd/analytics.go | 6 + internal/cmd/analytics_deeper.go | 314 ++++++++++++++++++++++++++ internal/cmd/analytics_deeper_test.go | 190 ++++++++++++++++ 6 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/analytics_deeper.go create mode 100644 internal/cmd/analytics_deeper_test.go diff --git a/README.md b/README.md index a8792cc..6986403 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,8 @@ fopost posts list · get · create · publish · cancel · delete duplicate · preflight · deliveries fopost media list · upload · delete fopost labels list · create · delete -fopost analytics overview · top-posts · time-series +fopost analytics overview · top-posts · time-series · decay · frequency · timeline · + changes · collect-post · native-posts fopost automations list · get · toggle · trigger · runs fopost webhooks list · create · test · delete fopost ads tree · pause · resume · insights · leads @@ -226,6 +227,32 @@ Run `fopost --help` for the flags on any of them. Global flags, accepted everywhere: `--api-key`, `--base-url`, `--workspace`, `--timeout`, `--json`, `--quiet`, `--no-color`. +## Analytics + +```bash +# How long a post keeps earning, from the repeated readings of each post +fopost analytics decay --days 30 + +# Whether posting more earned more +fopost analytics frequency --days 90 + +# Every reading held for one post, by id or by permalink +fopost analytics timeline post_1 +fopost analytics timeline 'https://x.com/acme/status/1' + +# Mirror the metrics into your own store; feed the cursor back as --since +fopost analytics changes --since 2026-03-02T00:00:00Z --json + +# Refresh one post now instead of waiting for the next collection run +fopost analytics collect-post post_1 + +# Posts on an account that never went out through FoPost +fopost analytics native-posts --account acc_1 +``` + +`collect-post` spends the same per-user budget as a full collection run, so a +loop over it will start answering `429`. + ## Examples [`examples/daily-digest.sh`](examples/daily-digest.sh) is a runnable script that diff --git a/go.mod b/go.mod index efaecb8..c7d23be 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/fopost/fopost-cli go 1.22 require ( - github.com/fopost/fopost-go v0.2.1-0.20260919224127-0a4908e7b1fc + github.com/fopost/fopost-go v0.2.1-0.20260919234549-9df6af60f5b9 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.27.0 ) diff --git a/go.sum b/go.sum index 66c7c85..683da3d 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/fopost/fopost-go v0.2.1-0.20260919224127-0a4908e7b1fc h1:XzQsDXpqdmHo0fbAs07vVyESqW2fFC1WdNAPjHTssG8= github.com/fopost/fopost-go v0.2.1-0.20260919224127-0a4908e7b1fc/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= +github.com/fopost/fopost-go v0.2.1-0.20260919234549-9df6af60f5b9 h1:ZerLTdoDXhfDP54uMEQT65WyUtBz3AwVHKsU4OmA98M= +github.com/fopost/fopost-go v0.2.1-0.20260919234549-9df6af60f5b9/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cmd/analytics.go b/internal/cmd/analytics.go index ca9f924..65972d9 100644 --- a/internal/cmd/analytics.go +++ b/internal/cmd/analytics.go @@ -20,6 +20,12 @@ func newAnalyticsCmd(state *State) *cobra.Command { newAnalyticsOverviewCmd(state), newAnalyticsTopPostsCmd(state), newAnalyticsTimeSeriesCmd(state), + newAnalyticsDecayCmd(state), + newAnalyticsFrequencyCmd(state), + newAnalyticsTimelineCmd(state), + newAnalyticsChangesCmd(state), + newAnalyticsCollectPostCmd(state), + newAnalyticsNativePostsCmd(state), ) return cmd } diff --git a/internal/cmd/analytics_deeper.go b/internal/cmd/analytics_deeper.go new file mode 100644 index 0000000..55023fe --- /dev/null +++ b/internal/cmd/analytics_deeper.go @@ -0,0 +1,314 @@ +package cmd + +import ( + "fmt" + "strconv" + + fopost "github.com/fopost/fopost-go" + "github.com/spf13/cobra" + + "github.com/fopost/fopost-cli/internal/output" +) + +func newAnalyticsDecayCmd(state *State) *cobra.Command { + params := &fopost.AnalyticsParams{} + cmd := &cobra.Command{ + Use: "decay", + Short: "How long a post keeps earning after it goes out", + Long: "Engagement grouped by the post's age at each reading, so every band says " + + "where the average post had got to by then. --days selects posts by publish " + + "time, not reading time.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := state.Client() + if err != nil { + return err + } + resolved, err := state.Resolved() + if err != nil { + return err + } + params.WorkspaceID = resolved.Workspace + decay, err := client.Analytics.Decay(cmd.Context(), params) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(decay, func() { + printer.Fields([][2]string{ + {"Posts measured", itoa(decay.PostsMeasured)}, + {"Half of final engagement by", output.Dash(decay.HalfLifeBucket)}, + }) + printer.Line("") + rows := make([][]string, 0, len(decay.Bands)) + for _, band := range decay.Bands { + if band.Posts == 0 { + continue + } + rows = append(rows, []string{ + band.Label, + itoa(band.Posts), + fmt.Sprintf("%.0f", band.AvgEngagements), + fmt.Sprintf("%.0f", band.AvgImpressions), + percent(band.ShareOfFinal), + }) + } + printer.Table( + []string{"age", "posts", "engagements", "impressions", "share of final"}, + rows, + ) + }) + }, + } + analyticsFlags(cmd, params) + return cmd +} + +func newAnalyticsFrequencyCmd(state *State) *cobra.Command { + params := &fopost.AnalyticsParams{} + cmd := &cobra.Command{ + Use: "frequency", + Short: "What each posting cadence earned per post", + Long: "Weeks run Monday to Sunday in UTC and are grouped by their own post count, " + + "so a four-post week is compared against other four-post weeks rather than " + + "the average. A week with no posts belongs to no band.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := state.Client() + if err != nil { + return err + } + resolved, err := state.Resolved() + if err != nil { + return err + } + params.WorkspaceID = resolved.Workspace + cadence, err := client.Analytics.Frequency(cmd.Context(), params) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(cadence, func() { + best := "-" + if cadence.Best != nil { + best = cadence.Best.Label + } + printer.Fields([][2]string{ + {"Weeks posted", itoa(len(cadence.Weeks))}, + {"Best cadence", best}, + }) + printer.Line("") + rows := make([][]string, 0, len(cadence.Bands)) + for _, band := range cadence.Bands { + if band.Posts == 0 { + continue + } + rows = append(rows, []string{ + band.Label, + itoa(band.Weeks), + itoa(band.Posts), + fmt.Sprintf("%.0f", band.AvgEngagementsPerPost), + percent(band.EngagementRate), + }) + } + printer.Table( + []string{"cadence", "weeks", "posts", "per post", "engagement rate"}, + rows, + ) + }) + }, + } + analyticsFlags(cmd, params) + return cmd +} + +func newAnalyticsTimelineCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "timeline ", + Short: "Every reading held for one post, oldest first", + Long: "One timeline per delivery, because the same post on two networks decays " + + "differently. The argument is a FoPost post id, or the permalink of a post " + + "made natively on the network.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + timeline, err := client.Analytics.Timeline(cmd.Context(), args[0]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(timeline, func() { + for i, delivery := range timeline.Deliveries { + if i > 0 { + printer.Line("") + } + printer.Line(fmt.Sprintf("%s @%s", delivery.Platform, delivery.Username)) + rows := make([][]string, 0, len(delivery.Points)) + for _, point := range delivery.Points { + rows = append(rows, []string{ + age(point.AgeMinutes), + optional(point.Engagements), + optional(point.Impressions), + itoa(point.Delta.Engagements), + }) + } + printer.Table([]string{"age", "engagements", "impressions", "moved"}, rows) + } + }) + }, + } +} + +func newAnalyticsChangesCmd(state *State) *cobra.Command { + params := &fopost.MetricChangesParams{} + cmd := &cobra.Command{ + Use: "changes", + Short: "Metric readings recorded since a timestamp", + Long: "Oldest first, with a cursor to pass as the next --since. This is how an " + + "external store mirrors the metrics without refetching the whole history. " + + "Without --since it answers with the last seven days.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := state.Client() + if err != nil { + return err + } + resolved, err := state.Resolved() + if err != nil { + return err + } + params.WorkspaceID = resolved.Workspace + page, err := client.Analytics.Changes(cmd.Context(), params) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(page, func() { + rows := make([][]string, 0, len(page.Changes)) + for _, change := range page.Changes { + rows = append(rows, []string{ + change.FetchedAt.String(), + change.Platform, + output.Dash(change.PostID), + change.ExternalPostID, + optional(change.Engagements), + }) + } + printer.Table( + []string{"read at", "platform", "post id", "external id", "engagements"}, + rows, + ) + printer.Line("") + printer.Fields([][2]string{ + {"Cursor", output.Dash(page.Cursor.String())}, + {"More", strconv.FormatBool(page.HasMore)}, + }) + }) + }, + } + flags := cmd.Flags() + flags.StringVar(¶ms.Since, "since", "", "return readings recorded after this RFC 3339 instant") + flags.IntVar(¶ms.Limit, "limit", 0, "how many readings to return") + flags.StringVar(¶ms.AccountID, "account", "", "narrow to one account id") + return cmd +} + +func newAnalyticsCollectPostCmd(state *State) *cobra.Command { + return &cobra.Command{ + Use: "collect-post ", + Short: "Refresh one post's metrics now", + Long: "Re-reads one post from the network instead of waiting for the next " + + "scheduled collection. One post is still a platform call, so this spends the " + + "same per-user budget as a full collection run.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := state.Client() + if err != nil { + return err + } + result, err := client.Analytics.CollectPost(cmd.Context(), args[0]) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(result, func() { + rows := make([][]string, 0, len(result.Deliveries)) + for _, delivery := range result.Deliveries { + rows = append(rows, []string{ + delivery.Platform, + delivery.ExternalPostID, + strconv.FormatBool(delivery.Collected), + output.Dash(delivery.Message), + }) + } + printer.Table([]string{"platform", "external id", "refreshed", "note"}, rows) + }) + }, + } +} + +func newAnalyticsNativePostsCmd(state *State) *cobra.Command { + params := &fopost.NativePostsParams{} + var accountID string + cmd := &cobra.Command{ + Use: "native-posts", + Short: "Posts on an account that never went out through FoPost", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := state.Client() + if err != nil { + return err + } + list, err := client.Analytics.NativePosts(cmd.Context(), accountID, params) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(list, func() { + rows := make([][]string, 0, len(list.Data)) + for _, post := range list.Data { + rows = append(rows, []string{ + post.PostedAt.String(), + output.Truncate(post.Text, 40), + optional(post.Metrics.Engagements), + output.Dash(post.Permalink), + }) + } + printer.Table([]string{"posted", "text", "engagements", "permalink"}, rows) + }) + }, + } + flags := cmd.Flags() + flags.StringVar(&accountID, "account", "", "the account id to list (required)") + flags.IntVar(¶ms.Page, "page", 0, "page number") + flags.IntVar(¶ms.PerPage, "per-page", 0, "page size") + flags.IntVar(¶ms.Days, "days", 0, "only posts published in the last this many days") + _ = cmd.MarkFlagRequired("account") + return cmd +} + +// age renders a reading's age in the largest unit that stays readable. +func age(minutes *int) string { + if minutes == nil { + return "-" + } + switch m := *minutes; { + case m < 60: + return fmt.Sprintf("%dm", m) + case m < 48*60: + return fmt.Sprintf("%dh", m/60) + default: + return fmt.Sprintf("%dd", m/(24*60)) + } +} + +// optional renders a metric the network did not report as a dash, not zero. +func optional(value *int) string { + if value == nil { + return "-" + } + return strconv.Itoa(*value) +} diff --git a/internal/cmd/analytics_deeper_test.go b/internal/cmd/analytics_deeper_test.go new file mode 100644 index 0000000..959b4b0 --- /dev/null +++ b/internal/cmd/analytics_deeper_test.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/fopost/fopost-cli/internal/config" +) + +// configured points the CLI at the fake API with a workspace already chosen, +// so the analytics commands need no flags beyond their own. +func configured(t *testing.T, baseURL string) { + t.Helper() + if err := config.Save(&config.File{APIKey: "fp_k", BaseURL: baseURL, Workspace: "ws_1"}); err != nil { + t.Fatal(err) + } +} + +func TestAnalyticsDecayPrintsTheBandsAndHalfLife(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "days": 30, + "postsMeasured": 2, + "halfLifeBucket": "1h_3h", + "bands": []map[string]any{ + {"bucket": "under_1h", "label": "First hour", "posts": 2, + "avgEngagements": 25, "avgImpressions": 300, "shareOfFinal": 0.3}, + {"bucket": "6h_12h", "label": "6-12 hours", "posts": 0, + "avgEngagements": 0, "avgImpressions": 0, "shareOfFinal": nil}, + }, + }}) + }) + configured(t, api.URL) + + stdout, stderr, code := run(t, "", "analytics", "decay", "--days", "30") + if code != ExitOK { + t.Fatalf("exit = %d\n%s%s", code, stdout, stderr) + } + api.find(t, "GET", "/analytics/decay") + if !strings.Contains(stdout, "1h_3h") { + t.Fatalf("decay did not print the half life:\n%s", stdout) + } + if !strings.Contains(stdout, "First hour") { + t.Fatalf("decay did not print the measured band:\n%s", stdout) + } + // A band nothing was measured in is left out rather than printed as zeroes + if strings.Contains(stdout, "6-12 hours") { + t.Fatalf("decay printed an empty band:\n%s", stdout) + } +} + +func TestAnalyticsFrequencyPrintsTheBestCadence(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "days": 90, + "weeks": []map[string]any{ + {"weekStart": "2026-03-02", "posts": 2, "engagements": 240, "avgEngagementsPerPost": 120}, + }, + "bands": []map[string]any{ + {"band": "under_3", "label": "1-2 a week", "weeks": 1, "posts": 2, + "avgPostsPerWeek": 2, "avgEngagementsPerPost": 120, "engagementRate": 0.12}, + }, + "best": map[string]any{"band": "under_3", "label": "1-2 a week", "avgEngagementsPerPost": 120}, + }}) + }) + configured(t, api.URL) + + stdout, stderr, code := run(t, "", "analytics", "frequency", "--days", "90") + if code != ExitOK { + t.Fatalf("exit = %d\n%s%s", code, stdout, stderr) + } + api.find(t, "GET", "/analytics/frequency") + if !strings.Contains(stdout, "1-2 a week") { + t.Fatalf("frequency did not print the best cadence:\n%s", stdout) + } +} + +func TestAnalyticsTimelineAcceptsAPermalink(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "postId": nil, + "deliveries": []map[string]any{{ + "accountId": "acc_1", "platform": "twitter", "username": "acme", + "externalPostId": "1", "postedAt": "2026-03-02T00:00:00.000Z", + "points": []map[string]any{{ + "at": "2026-03-02T00:30:00.000Z", "ageMinutes": 30, "engagements": 40, + "impressions": 400, "reach": nil, "likes": 30, "comments": nil, + "shares": nil, "videoViews": nil, + "delta": map[string]any{"impressions": 400, "reach": 0, "engagements": 40, + "likes": 30, "comments": 0, "shares": 0}, + }}, + }}, + }}) + }) + configured(t, api.URL) + + stdout, stderr, code := run(t, "", "analytics", "timeline", "https://x.com/acme/status/1") + if code != ExitOK { + t.Fatalf("exit = %d\n%s%s", code, stdout, stderr) + } + // The permalink travels as one escaped path segment + api.find(t, "GET", "/analytics/posts/https://x.com/acme/status/1/timeline") + if !strings.Contains(stdout, "30m") { + t.Fatalf("timeline did not print the reading age:\n%s", stdout) + } +} + +func TestAnalyticsChangesPrintsTheCursor(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "since": "2026-03-02T00:00:00.000Z", "cursor": "2026-03-02T06:00:00.000Z", + "hasMore": true, + "changes": []map[string]any{{ + "accountId": "acc_1", "platform": "twitter", "externalPostId": "1", + "postId": "post_1", "postedAt": "2026-03-02T00:00:00.000Z", + "fetchedAt": "2026-03-02T06:00:00.000Z", "impressions": 900, + "reach": nil, "engagements": 90, "likes": 70, "comments": 10, "shares": 10, + }}, + }}) + }) + configured(t, api.URL) + + stdout, stderr, code := run(t, "", "analytics", "changes", "--since", "2026-03-02T00:00:00Z") + if code != ExitOK { + t.Fatalf("exit = %d\n%s%s", code, stdout, stderr) + } + api.find(t, "GET", "/analytics/changes") + if !strings.Contains(stdout, "post_1") { + t.Fatalf("changes did not print the reading:\n%s", stdout) + } + if !strings.Contains(stdout, "2026-03-02T06:00:00Z") { + t.Fatalf("changes did not print the cursor:\n%s", stdout) + } +} + +func TestAnalyticsCollectPostReportsEachDelivery(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "collected": 1, + "deliveries": []map[string]any{{ + "accountId": "acc_1", "platform": "twitter", "externalPostId": "1", + "collected": true, "fetchedAt": "2026-03-02T00:30:00.000Z", "message": nil, + }}, + }}) + }) + configured(t, api.URL) + + stdout, stderr, code := run(t, "", "analytics", "collect-post", "post_1") + if code != ExitOK { + t.Fatalf("exit = %d\n%s%s", code, stdout, stderr) + } + api.find(t, "POST", "/posts/post_1/analytics/collect") + if !strings.Contains(stdout, "true") { + t.Fatalf("collect-post did not report the refresh:\n%s", stdout) + } +} + +func TestAnalyticsNativePostsListsPostsMadeOutsideFoPost(t *testing.T) { + isolate(t) + api := newFakeAPI(t, func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{{ + "externalPostId": "1", "text": "Posted by hand", + "permalink": "https://x.com/acme/status/1", "thumbnailUrl": nil, + "mediaType": nil, "postedAt": "2026-03-02T00:00:00.000Z", + "fetchedAt": "2026-03-02T06:00:00.000Z", + "metrics": map[string]any{"impressions": 900, "reach": nil, "engagements": 90, + "likes": 70, "comments": 10, "shares": 10, "videoViews": nil}, + }}, + "meta": map[string]any{"page": 1, "perPage": 20, "total": 1}, + }) + }) + configured(t, api.URL) + + stdout, stderr, code := run(t, "", "analytics", "native-posts", "--account", "acc_1") + if code != ExitOK { + t.Fatalf("exit = %d\n%s%s", code, stdout, stderr) + } + api.find(t, "GET", "/accounts/acc_1/native-posts") + if !strings.Contains(stdout, "Posted by hand") { + t.Fatalf("native-posts did not print the post:\n%s", stdout) + } +}