From 44614d7ccc0c6c7fd5d57659e5bf09ee61bbd235 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 4 Oct 2026 00:28:19 +0200 Subject: [PATCH] feat(ads): read, apply and dismiss Google Ads recommendations Two commands under ads google: recommendations, which lists what Google suggests and takes --apply or --dismiss, and optimization-score. Applying changes a live account, so it confirms unless -y is passed. Re-pins fopost-go to its current main, which carries both these calls and the ad-network listing LinkedIn added. Still a pseudo-version: re-point it at the tag whenever that SDK is next released. --- go.mod | 2 +- go.sum | 6 +- internal/cmd/ads_google.go | 148 +++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cd17466..3566979 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.20261003222702-faf657bdaf90 + github.com/fopost/fopost-go v0.2.1-0.20261003224903-429911602cac github.com/spf13/cobra v1.10.2 golang.org/x/term v0.27.0 ) diff --git a/go.sum b/go.sum index 716488b..f84401a 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,10 @@ github.com/fopost/fopost-go v0.2.1-0.20260920195809-0f2a3f293638 h1:HyHOHnuVqcv8 github.com/fopost/fopost-go v0.2.1-0.20260920195809-0f2a3f293638/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= github.com/fopost/fopost-go v0.2.1-0.20261003215548-65ec0bbbe207 h1:mHYksMJZWjyhMiJPmJOxa4fxvjUyaBkYjog9GWM+MnU= github.com/fopost/fopost-go v0.2.1-0.20261003215548-65ec0bbbe207/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= -github.com/fopost/fopost-go v0.2.1-0.20261003222702-faf657bdaf90 h1:vzeOG95v+uy3sTuLYiv0PdDAIGMBGNofSD/NTB0hQ1o= -github.com/fopost/fopost-go v0.2.1-0.20261003222702-faf657bdaf90/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= +github.com/fopost/fopost-go v0.2.1-0.20261003221824-a57f7c3c6032 h1:83hQv33R0XK8D2Zgn2B/EdcBJRJQ0at+3T0Tp+EJS7c= +github.com/fopost/fopost-go v0.2.1-0.20261003221824-a57f7c3c6032/go.mod h1:Qz+7UCYTBnDEeZqB3VMrePxmYLVR9rFvx3+QWcKND8Q= +github.com/fopost/fopost-go v0.2.1-0.20261003224903-429911602cac h1:icF8vsLmR1YuWbs+e+NYFkApcxq3x1IN3565jdCBEVo= +github.com/fopost/fopost-go v0.2.1-0.20261003224903-429911602cac/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_google.go b/internal/cmd/ads_google.go index f5b4b48..a12ce30 100644 --- a/internal/cmd/ads_google.go +++ b/internal/cmd/ads_google.go @@ -18,6 +18,8 @@ func newAdsGoogleCmd(state *State) *cobra.Command { "insights are on the other ads commands and work across networks.", } cmd.AddCommand( + newGoogleRecommendationsCmd(state), + newGoogleOptimizationScoreCmd(state), newGoogleKeywordsCmd(state), newGoogleKeywordIdeasCmd(state), newGoogleSearchTermsCmd(state), @@ -353,3 +355,149 @@ func newGoogleQueryCmd(state *State) *cobra.Command { flags.bind(cmd) return cmd } + +func newGoogleRecommendationsCmd(state *State) *cobra.Command { + flags := &googleFlags{} + var types []string + var apply, dismiss []string + var yes bool + cmd := &cobra.Command{ + Use: "recommendations", + Short: "Read, apply or dismiss what Google suggests for the account", + Long: "Without --apply or --dismiss this lists what Google suggests, with the\n" + + "impact it projects. Applying one changes what the live account serves or bids.", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := state.Client() + if err != nil { + return err + } + scope, err := flags.scope(state) + if err != nil { + return err + } + printer := state.Printer() + + if len(apply) > 0 && len(dismiss) > 0 { + return usageErrorf("give --apply or --dismiss, not both") + } + + if len(apply) > 0 || len(dismiss) > 0 { + ids := apply + verb := "Applied" + run := client.GoogleAds.ApplyRecommendations + if len(dismiss) > 0 { + ids, verb, run = dismiss, "Dismissed", client.GoogleAds.DismissRecommendations + } + if !yes { + if err := confirm(state, fmt.Sprintf( + "%s %d recommendation(s) on %s?", verb, len(ids), flags.customer)); err != nil { + return err + } + } + count, err := run(cmd.Context(), &fopost.GoogleRecommendationsRequest{ + GoogleScope: scope, + IDs: ids, + }) + if err != nil { + return err + } + return printer.Value(map[string]int{strings.ToLower(verb): count}, func() { + printer.Success("%s %d recommendation(s)", verb, count) + }) + } + + rows, err := client.GoogleAds.Recommendations(cmd.Context(), scope, types) + if err != nil { + return err + } + return printer.Value(rows, func() { + table := [][]string{} + for _, row := range rows { + table = append(table, []string{ + row.ID, + row.Type, + deltaOf(impactClicks(row)), + deltaOf(impactConversions(row)), + }) + } + printer.Table([]string{"id", "type", "clicks", "conversions"}, table) + }) + }, + } + flags.bind(cmd) + cmd.Flags().StringSliceVar(&types, "type", nil, "Limit to a recommendation type; repeatable") + cmd.Flags().StringSliceVar(&apply, "apply", nil, "Apply this recommendation id; repeatable") + cmd.Flags().StringSliceVar(&dismiss, "dismiss", nil, "Dismiss this recommendation id; repeatable") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation prompt") + return cmd +} + +func impactClicks(row fopost.GoogleRecommendation) (*float64, *float64) { + if row.Impact == nil { + return nil, nil + } + return row.Impact.BaseClicks, row.Impact.PotentialClicks +} + +func impactConversions(row fopost.GoogleRecommendation) (*float64, *float64) { + if row.Impact == nil { + return nil, nil + } + return row.Impact.BaseConversions, row.Impact.PotentialConversions +} + +// deltaOf renders what applying a recommendation would move the metric by. +func deltaOf(base, potential *float64) string { + if base == nil || potential == nil { + return "-" + } + change := *potential - *base + if change >= 0 { + return fmt.Sprintf("+%.1f", change) + } + return fmt.Sprintf("%.1f", change) +} + +func newGoogleOptimizationScoreCmd(state *State) *cobra.Command { + flags := &googleFlags{} + cmd := &cobra.Command{ + Use: "optimization-score", + Short: "Read the account's optimization score and each campaign's", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := state.Client() + if err != nil { + return err + } + scope, err := flags.scope(state) + if err != nil { + return err + } + score, err := client.GoogleAds.OptimizationScore(cmd.Context(), scope) + if err != nil { + return err + } + printer := state.Printer() + return printer.Value(score, func() { + printer.Line("Account: %s", percentOf(score.Score)) + rows := [][]string{} + for _, campaign := range score.Campaigns { + rows = append(rows, []string{ + campaign.ID, + output.Truncate(campaign.Name, 40), + percentOf(campaign.Score), + }) + } + printer.Table([]string{"id", "campaign", "score"}, rows) + }) + }, + } + flags.bind(cmd) + return cmd +} + +func percentOf(score *float64) string { + if score == nil { + return "-" + } + return fmt.Sprintf("%.0f%%", *score*100) +}