diff --git a/README.md b/README.md index 0d7dbdd..b80180b 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ fopost knowledge list · add · sync · delete · search 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 6526ecd..14cbffb 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.20260920141700-633d72b705ca + github.com/fopost/fopost-go v0.2.1-0.20260920143205-156d1c2d360c github.com/spf13/cobra v1.10.2 golang.org/x/term v0.27.0 ) diff --git a/go.sum b/go.sum index 7d15718..cf33d95 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/fopost/fopost-go v0.2.1-0.20260920130053-a8ac95df638d h1:XSxGXq2PUdF+ github.com/fopost/fopost-go v0.2.1-0.20260920130053-a8ac95df638d/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/fopost/fopost-go v0.2.1-0.20260920141700-633d72b705ca h1:1a7XwNMmGCUh22hW6FOs8R5F5xJVTsbgCoJK+VdgEMc= github.com/fopost/fopost-go v0.2.1-0.20260920141700-633d72b705ca/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= +github.com/fopost/fopost-go v0.2.1-0.20260920143205-156d1c2d360c h1:Md2W/Uu8pXDMqJkZ0gRuSb8iNz3/H4zn5FS++Yt+Lf8= +github.com/fopost/fopost-go v0.2.1-0.20260920143205-156d1c2d360c/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 +}