From 20ca0124837214e00ab03c591215406af2becc1a Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 04:15:31 +0200 Subject: [PATCH] feat(ads): add catalogs and the public ad archive Pins the Go SDK at the branch commit that carries them; re-pin to the merged commit once fopost-go lands. --- README.md | 2 +- go.mod | 2 +- go.sum | 2 + internal/cmd/ads.go | 4 +- internal/cmd/ads_extras.go | 157 +++++++++++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/ads_extras.go diff --git a/README.md b/README.md index a8792cc..a3b8587 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ fopost labels list · create · delete fopost analytics overview · top-posts · time-series fopost automations list · get · toggle · trigger · runs fopost webhooks list · create · test · delete -fopost ads tree · pause · resume · insights · leads +fopost ads tree · pause · resume · insights · leads · catalogs · library fopost completion bash · zsh · fish · powershell fopost version ``` diff --git a/go.mod b/go.mod index efaecb8..c27e8ff 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.20260920014644-6b4d1f1f376b github.com/spf13/cobra v1.10.2 golang.org/x/term v0.27.0 ) diff --git a/go.sum b/go.sum index 66c7c85..915a215 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.20260920014644-6b4d1f1f376b h1:RdpmgNAvuT2OSBSx4ZsCcfdn+c8WcY8QHccJubaz+ww= +github.com/fopost/fopost-go v0.2.1-0.20260920014644-6b4d1f1f376b/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/ads.go b/internal/cmd/ads.go index 9bb8426..5323857 100644 --- a/internal/cmd/ads.go +++ b/internal/cmd/ads.go @@ -15,7 +15,7 @@ func init() { register(newAdsCmd) } func newAdsCmd(state *State) *cobra.Command { cmd := &cobra.Command{ Use: "ads", - Short: "Inspect Meta campaigns, change their status, and read insights and leads", + Short: "Inspect Meta campaigns and catalogs, change status, and read insights, leads and the ad archive", } cmd.AddCommand( newAdsTreeCmd(state), @@ -23,6 +23,8 @@ func newAdsCmd(state *State) *cobra.Command { newAdsStatusCmd(state, "resume", fopost.AdStatusActive), newAdsInsightsCmd(state), newAdsLeadsCmd(state), + newAdsCatalogsCmd(state), + newAdsLibraryCmd(state), ) return cmd } diff --git a/internal/cmd/ads_extras.go b/internal/cmd/ads_extras.go new file mode 100644 index 0000000..42849fc --- /dev/null +++ b/internal/cmd/ads_extras.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "strconv" + "strings" + + fopost "github.com/fopost/fopost-go" + "github.com/spf13/cobra" + + "github.com/fopost/fopost-cli/internal/output" +) + +func count(n *int) string { + if n == nil { + return "-" + } + return strconv.Itoa(*n) +} + +// newAdsCatalogsCmd lists the product catalogs a connection reaches, and the +// product sets inside one. A catalog ad runs from a set, not the whole catalog. +func newAdsCatalogsCmd(state *State) *cobra.Command { + var connection, catalog string + cmd := &cobra.Command{ + Use: "catalogs", + Short: "List product catalogs, or the product sets in one", + Example: strings.Join([]string{ + " fopost ads catalogs --connection conn_1", + " fopost ads catalogs --connection conn_1 --catalog 500123456789", + }, "\n"), + RunE: func(cmd *cobra.Command, args []string) error { + if connection == "" { + return usageErrorf("--connection is required") + } + client, err := state.Client() + if err != nil { + return err + } + resolved, err := state.Resolved() + if err != nil { + return err + } + params := &fopost.AdObjectParams{ + WorkspaceID: resolved.Workspace, + ConnectionID: connection, + } + printer := state.Printer() + + if catalog != "" { + sets, err := client.Ads.ProductSets(cmd.Context(), catalog, params) + if err != nil { + return err + } + return printer.Value(sets, func() { + rows := [][]string{} + for _, set := range sets { + rows = append(rows, []string{ + set.ID, + output.Truncate(set.Name, 40), + count(set.ProductCount), + }) + } + printer.Table([]string{"id", "name", "products"}, rows) + }) + } + + result, err := client.Ads.Catalogs(cmd.Context(), params) + if err != nil { + return err + } + return printer.Value(result.Catalogs, func() { + rows := [][]string{} + for _, c := range result.Catalogs { + rows = append(rows, []string{ + c.ID, + output.Truncate(c.Name, 40), + c.Vertical, + count(c.ProductCount), + }) + } + printer.Table([]string{"id", "name", "vertical", "products"}, rows) + }) + }, + } + cmd.Flags().StringVar(&connection, "connection", "", "Meta Ads connection id (required)") + cmd.Flags().StringVar(&catalog, "catalog", "", "Show the product sets in this catalog instead") + return cmd +} + +// newAdsLibraryCmd searches the public ad archive. Results are read live on +// every call and stored nowhere, so an ad that stops running is simply absent +// from the next search. +func newAdsLibraryCmd(state *State) *cobra.Command { + var connection, country, page string + var limit int + cmd := &cobra.Command{ + Use: "library [keyword]", + Short: "Search the public ad archive for ads anyone is running", + Args: cobra.MaximumNArgs(1), + Example: strings.Join([]string{ + " fopost ads library --connection conn_1 --country US \"running shoes\"", + " fopost ads library --connection conn_1 --country GB --page 1234567890", + }, "\n"), + RunE: func(cmd *cobra.Command, args []string) error { + if connection == "" { + return usageErrorf("--connection is required") + } + keyword := "" + if len(args) == 1 { + keyword = args[0] + } + if keyword == "" && page == "" { + return usageErrorf("search by keyword or by --page") + } + client, err := state.Client() + if err != nil { + return err + } + resolved, err := state.Resolved() + if err != nil { + return err + } + params := &fopost.LibraryParams{ + WorkspaceID: resolved.Workspace, + ConnectionID: connection, + Countries: []string{country}, + Q: keyword, + Limit: limit, + } + if page != "" { + params.PageIDs = []string{page} + } + result, err := client.Ads.Library(cmd.Context(), params) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(result.Entries, func() { + rows := [][]string{} + for _, entry := range result.Entries { + rows = append(rows, []string{ + entry.ID, + output.Truncate(entry.PageName, 28), + output.Truncate(strings.Join(entry.Bodies, " · "), 48), + strings.Join(entry.PublisherPlatforms, ","), + }) + } + printer.Table([]string{"id", "page", "copy", "platforms"}, rows) + }) + }, + } + cmd.Flags().StringVar(&connection, "connection", "", "Meta Ads connection id (required)") + cmd.Flags().StringVar(&country, "country", "US", "Two-letter code the ad reached") + cmd.Flags().StringVar(&page, "page", "", "Search one Page instead of a keyword") + cmd.Flags().IntVar(&limit, "limit", 25, "How many entries to return, 1-100") + return cmd +}