@@ -326,6 +326,13 @@ _COMMANDS_MANIFEST = [
326326 ("snapshot prune" , "mutate" , True , "Delete old local snapshots (no API)" , "gads snapshot prune --older-than 7d --apply" ),
327327 ("health" , "read" , False , "Single-command account dashboard" , "gads health" ),
328328 ("audit-log" , "read" , False , "Recent --apply mutations from ~/.gads/audit.log" , "gads audit-log --tail 20" ),
329+ ("list-negative-keywords" , "read" , False , "Campaign-level negative keywords" , "gads list-negative-keywords" ),
330+ ("add-negative-keyword" , "mutate" , True , "Add a campaign-level negative keyword" , "gads add-negative-keyword <campaign_id> 'used' --match-type PHRASE --apply" ),
331+ ("remove-negative-keyword" , "mutate" , True , "Remove a campaign-level negative keyword" ,"gads remove-negative-keyword <campaign_id> <criterion_id> --apply" ),
332+ ("list-search-terms" , "read" , False , "What users actually searched (last N days)" , "gads list-search-terms --days 30" ),
333+ ("apply-recommendation" , "mutate" , True , "Apply a Google-generated recommendation" , "gads apply-recommendation <resource_name> --apply" ),
334+ ("asset-performance" , "read" , False , "Top vs bottom assets by performance label" , "gads asset-performance --asset-group <id>" ),
335+ ("create-conversion-action" , "mutate" , True , "Create a new conversion action" , "gads create-conversion-action --name Purchase --type WEBPAGE --category PURCHASE --apply" ),
329336]
330337
331338
@@ -2758,6 +2765,274 @@ def cmd_sync(args):
27582765 print (f"\n Synced. Run `gads plan { args .state_file } ` to verify drift is gone." )
27592766
27602767
2768+ # ─── Sales toolkit: negatives, search terms, recommendations, asset perf ─
2769+ #
2770+ # Commands that directly drive revenue. Negatives block waste; search
2771+ # terms reveal real intent; recommendations auto-applies Google's free
2772+ # advice; asset perf surfaces what to drop.
2773+
2774+ def cmd_list_negative_keywords (args ):
2775+ """List negative keywords at campaign or ad-group scope."""
2776+ where = ["campaign.status != 'REMOVED'" ]
2777+ if args .campaign :
2778+ where .append (f"campaign.id = { args .campaign } " )
2779+ q = f"""
2780+ SELECT campaign.id, campaign.name,
2781+ campaign_criterion.criterion_id,
2782+ campaign_criterion.keyword.text,
2783+ campaign_criterion.keyword.match_type
2784+ FROM campaign_criterion
2785+ WHERE campaign_criterion.type = 'KEYWORD'
2786+ AND campaign_criterion.negative = TRUE
2787+ AND { ' AND ' .join (where )}
2788+ ORDER BY campaign.id
2789+ """
2790+ res = api_call ("POST" , "googleAds:search" , {"query" : q }, args .customer )
2791+ rows = res .get ("results" , [])
2792+
2793+ out = []
2794+ for row in rows :
2795+ cc = row ["campaignCriterion" ]
2796+ out .append ({
2797+ "campaign_id" : row ["campaign" ]["id" ],
2798+ "campaign_name" : row ["campaign" ]["name" ],
2799+ "criterion_id" : cc .get ("criterionId" ),
2800+ "text" : cc .get ("keyword" , {}).get ("text" , "" ),
2801+ "match_type" : cc .get ("keyword" , {}).get ("matchType" , "" ),
2802+ })
2803+ if args .format == "json" :
2804+ _emit_json (out )
2805+ return
2806+ if not out :
2807+ print ("No campaign-level negative keywords." )
2808+ return
2809+ print (f"{ 'CRIT ID' :>14} { 'MATCH' :<8} { 'CAMPAIGN' :<28} KEYWORD" )
2810+ print ("-" * 120 )
2811+ for n in out :
2812+ print (f"{ n ['criterion_id' ]:>14} { n ['match_type' ]:<8} { n ['campaign_name' ][:28 ]:<28} { n ['text' ]} " )
2813+
2814+
2815+ def cmd_add_negative_keyword (args ):
2816+ """Add a negative keyword at campaign scope."""
2817+ if args .match_type not in ("EXACT" , "PHRASE" , "BROAD" ):
2818+ sys .exit ("ERROR: --match-type must be EXACT / PHRASE / BROAD" )
2819+ body = {"operations" : [{"create" : {
2820+ "campaign" : f"customers/{ args .customer } /campaigns/{ args .campaign } " ,
2821+ "negative" : True ,
2822+ "status" : "ENABLED" ,
2823+ "keyword" : {"text" : args .text , "matchType" : args .match_type },
2824+ }}]}
2825+ if not args .apply :
2826+ dry_run_banner (f"ADD NEGATIVE KEYWORD [{ args .match_type } ] '{ args .text } '" ,
2827+ f"campaign { args .campaign } " )
2828+ return
2829+ res = api_call ("POST" , "campaignCriteria:mutate" , body , args .customer )
2830+ rn = res ["results" ][0 ]["resourceName" ]
2831+ print (f"OK — negative keyword [{ args .match_type } ] '{ args .text } ' added: { rn } " )
2832+
2833+
2834+ def cmd_remove_negative_keyword (args ):
2835+ """Remove a campaign-level negative keyword by criterion_id."""
2836+ resource = f"customers/{ args .customer } /campaignCriteria/{ args .campaign } ~{ args .criterion_id } "
2837+ body = {"operations" : [{"remove" : resource }]}
2838+ if not args .apply :
2839+ dry_run_banner (f"REMOVE NEGATIVE KEYWORD criterion { args .criterion_id } " , args .campaign )
2840+ return
2841+ api_call ("POST" , "campaignCriteria:mutate" , body , args .customer )
2842+ print (f"OK — removed: { resource } " )
2843+
2844+
2845+ def cmd_list_search_terms (args ):
2846+ """What real users actually typed before clicking your ads.
2847+ Sources negative-keyword candidates + new keyword opportunities."""
2848+ end_d = datetime .date .today () - datetime .timedelta (days = 1 )
2849+ start_d = end_d - datetime .timedelta (days = args .days - 1 )
2850+ where = [f"segments.date BETWEEN '{ start_d } ' AND '{ end_d } '" ]
2851+ if args .campaign :
2852+ where .append (f"campaign.id = { args .campaign } " )
2853+ q = f"""
2854+ SELECT search_term_view.search_term,
2855+ segments.search_term_match_type,
2856+ campaign.name,
2857+ metrics.impressions, metrics.clicks, metrics.cost_micros,
2858+ metrics.conversions, metrics.conversions_value
2859+ FROM search_term_view
2860+ WHERE { ' AND ' .join (where )}
2861+ ORDER BY metrics.cost_micros DESC
2862+ LIMIT { args .limit }
2863+ """
2864+ res = api_call ("POST" , "googleAds:search" , {"query" : q }, args .customer )
2865+ rows = res .get ("results" , [])
2866+
2867+ parsed = []
2868+ for row in rows :
2869+ stv = row .get ("searchTermView" , {})
2870+ m = row .get ("metrics" , {})
2871+ cost = int (m .get ("costMicros" , 0 ))
2872+ conv = float (m .get ("conversions" , 0 ))
2873+ parsed .append ({
2874+ "search_term" : stv .get ("searchTerm" , "" ),
2875+ "campaign" : row .get ("campaign" , {}).get ("name" , "" ),
2876+ "match_type" : row .get ("segments" , {}).get ("searchTermMatchType" , "" ),
2877+ "impressions" : int (m .get ("impressions" , 0 )),
2878+ "clicks" : int (m .get ("clicks" , 0 )),
2879+ "cost_units" : cost / 1_000_000 ,
2880+ "conversions" : conv ,
2881+ "conv_value_units" : float (m .get ("conversionsValue" , 0 )),
2882+ "cost_per_conv" : (cost / 1_000_000 / conv ) if conv else None ,
2883+ })
2884+
2885+ if args .format == "json" :
2886+ _emit_json (parsed )
2887+ return
2888+ if not parsed :
2889+ print (f"No search terms for the period { start_d } → { end_d } ." )
2890+ return
2891+ print (f"Search terms — { start_d } → { end_d } (top { len (parsed )} by spend)\n " )
2892+ print (f"{ 'COST' :>8} { 'CLK' :>5} { 'CONV' :>5} { 'MATCH' :<10} SEARCH TERM" )
2893+ print ("-" * 110 )
2894+ for s in parsed :
2895+ print (f"{ s ['cost_units' ]:>8,.0f} { s ['clicks' ]:>5} { s ['conversions' ]:>5.1f} "
2896+ f"{ s ['match_type' ]:<10} { s ['search_term' ]} " )
2897+ # Quick analysis hint
2898+ zero_conv = [s for s in parsed if s ["clicks" ] >= 2 and s ["conversions" ] == 0 ]
2899+ if zero_conv :
2900+ print (f"\n 💡 { len (zero_conv )} search term(s) with ≥2 clicks and 0 conversions —" )
2901+ print (f" candidates for `gads add-negative-keyword`." )
2902+
2903+
2904+ def cmd_apply_recommendation (args ):
2905+ """Apply one of Google's auto-generated recommendations."""
2906+ # Validate the recommendation exists
2907+ q = f"""
2908+ SELECT recommendation.type, recommendation.dismissed
2909+ FROM recommendation
2910+ WHERE recommendation.resource_name = '{ args .resource_name } '
2911+ """
2912+ res = api_call ("POST" , "googleAds:search" , {"query" : q }, args .customer )
2913+ rows = res .get ("results" , [])
2914+ if not rows :
2915+ sys .exit (f"ERROR: recommendation not found: { args .resource_name } " )
2916+ rec_type = rows [0 ]["recommendation" ].get ("type" , "?" )
2917+ print (f"Recommendation type: { rec_type } " )
2918+
2919+ if not args .apply :
2920+ dry_run_banner (f"APPLY recommendation { rec_type } " , args .resource_name )
2921+ return
2922+ body = {"operations" : [{"resourceName" : args .resource_name }]}
2923+ api_call ("POST" , "recommendations:apply" , body , args .customer )
2924+ print (f"OK — applied recommendation: { args .resource_name } " )
2925+
2926+
2927+ def cmd_asset_performance (args ):
2928+ """Top + bottom performing assets by impressions/clicks/conv per asset group.
2929+ Surfaces which headlines/descriptions to keep vs drop."""
2930+ where = ["asset_group_asset.status != 'REMOVED'" ]
2931+ if args .asset_group :
2932+ where .append (f"asset_group.id = { args .asset_group } " )
2933+ q = f"""
2934+ SELECT asset.id, asset.text_asset.text,
2935+ asset_group.id, asset_group.name,
2936+ asset_group_asset.field_type,
2937+ asset_group_asset.performance_label
2938+ FROM asset_group_asset
2939+ WHERE { ' AND ' .join (where )}
2940+ AND asset.type = 'TEXT'
2941+ AND asset_group_asset.field_type IN ('HEADLINE', 'DESCRIPTION', 'LONG_HEADLINE')
2942+ """
2943+ try :
2944+ res = api_call ("POST" , "googleAds:search" , {"query" : q }, args .customer )
2945+ except SystemExit :
2946+ # performance_label sometimes not yet populated for young campaigns.
2947+ # Retry without it.
2948+ q = q .replace ("asset_group_asset.performance_label," , "" )
2949+ res = api_call ("POST" , "googleAds:search" , {"query" : q }, args .customer )
2950+
2951+ rows = res .get ("results" , [])
2952+ out = []
2953+ for row in rows :
2954+ out .append ({
2955+ "asset_id" : row ["asset" ]["id" ],
2956+ "text" : row ["asset" ].get ("textAsset" , {}).get ("text" , "" ),
2957+ "asset_group_id" : row ["assetGroup" ]["id" ],
2958+ "asset_group_name" : row ["assetGroup" ]["name" ],
2959+ "field_type" : row ["assetGroupAsset" ]["fieldType" ],
2960+ "performance_label" : row ["assetGroupAsset" ].get ("performanceLabel" , "PENDING" ),
2961+ })
2962+
2963+ if args .format == "json" :
2964+ _emit_json (out )
2965+ return
2966+ if not out :
2967+ print ("No text assets found." )
2968+ return
2969+
2970+ # Group by performance bucket
2971+ buckets = {"BEST" : [], "GOOD" : [], "LOW" : [], "PENDING" : [], "LEARNING" : []}
2972+ for a in out :
2973+ buckets .setdefault (a ["performance_label" ], []).append (a )
2974+
2975+ for label , color_glyph in (("BEST" , "🟢" ), ("GOOD" , "🟦" ),
2976+ ("LEARNING" , "⏳" ), ("PENDING" , "⏳" ),
2977+ ("LOW" , "🔴" )):
2978+ items = buckets .get (label , [])
2979+ if not items :
2980+ continue
2981+ print (f"\n { color_glyph } { label } ({ len (items )} )" )
2982+ for a in items [:15 ]:
2983+ print (f" [{ a ['asset_id' ]} ] { a ['field_type' ]:<13} { a ['text' ][:60 ]} " )
2984+ if len (items ) > 15 :
2985+ print (f" ... and { len (items ) - 15 } more" )
2986+
2987+ low = buckets .get ("LOW" , [])
2988+ if low :
2989+ print (f"\n 💡 { len (low )} asset(s) labelled LOW —" )
2990+ print (f" consider removing them to lift Ad Strength." )
2991+
2992+
2993+ def cmd_create_conversion_action (args ):
2994+ """Create a new conversion action. Defaults sensible for e-commerce."""
2995+ if args .type not in ("WEBPAGE" , "WEBSITE_CALL" , "UPLOAD_CLICKS" , "PHONE_CALL_FROM_ADS" ):
2996+ sys .exit ("ERROR: --type must be WEBPAGE, WEBSITE_CALL, UPLOAD_CLICKS, or PHONE_CALL_FROM_ADS" )
2997+ if args .category not in ("PURCHASE" , "SIGNUP" , "LEAD" , "PAGE_VIEW" ,
2998+ "ADD_TO_CART" , "BEGIN_CHECKOUT" , "SUBSCRIBE_PAID" ,
2999+ "BOOK_APPOINTMENT" , "DOWNLOAD" ):
3000+ sys .exit ("ERROR: --category must be one of the supported enums" )
3001+
3002+ body = {"operations" : [{"create" : {
3003+ "name" : args .name ,
3004+ "type" : args .type ,
3005+ "category" : args .category ,
3006+ "status" : "ENABLED" ,
3007+ "primaryForGoal" : args .primary ,
3008+ "valueSettings" : {
3009+ "defaultValue" : args .default_value ,
3010+ "defaultCurrencyCode" : args .currency ,
3011+ "alwaysUseDefaultValue" : False ,
3012+ },
3013+ "countingType" : "MANY_PER_CLICK" if args .category in ("ADD_TO_CART" , "PAGE_VIEW" ) else "ONE_PER_CLICK" ,
3014+ "clickThroughLookbackWindowDays" : 30 ,
3015+ "viewThroughLookbackWindowDays" : 1 ,
3016+ }}]}
3017+
3018+ print (f"Will create conversion action:" )
3019+ print (f" Name: { args .name } " )
3020+ print (f" Type: { args .type } " )
3021+ print (f" Category: { args .category } " )
3022+ print (f" Primary: { args .primary } " )
3023+ print (f" Default value: { args .default_value } { args .currency } " )
3024+
3025+ if not args .apply :
3026+ dry_run_banner ("CREATE conversion_action" , args .name )
3027+ return
3028+ res = api_call ("POST" , "conversionActions:mutate" , body , args .customer )
3029+ rn = res ["results" ][0 ]["resourceName" ]
3030+ cid = rn .rsplit ("/" , 1 )[- 1 ]
3031+ print (f"\n ✓ Conversion action created: { rn } " )
3032+ print (f" Conversion action ID: { cid } " )
3033+ print (f" Next: install the tag — gads UI → Tools → Conversions → click '{ args .name } '" )
3034+
3035+
27613036# ─── Audit log: one JSON line per --apply mutation ────────────────────────
27623037
27633038def _audit_log_path ():
@@ -2941,6 +3216,8 @@ _MUTATING_COMMANDS = {
29413216 "add-text-assets" , "add-business-name" , "upload-image" , "add-youtube-video" ,
29423217 "replace-headlines" , "create-brand-campaign" , "split-asset-group" ,
29433218 "cleanup-orphans" , "sync" ,
3219+ "add-negative-keyword" , "remove-negative-keyword" ,
3220+ "apply-recommendation" , "create-conversion-action" ,
29443221}
29453222
29463223
@@ -3337,6 +3614,62 @@ def main():
33373614 help = "Single-command account dashboard"
33383615 ).set_defaults (func = cmd_health )
33393616
3617+ # ───── Sales toolkit ─────
3618+ p = sub .add_parser ("list-negative-keywords" ,
3619+ help = "List campaign-level negative keywords" )
3620+ p .add_argument ("--campaign" , help = "Filter by campaign ID" )
3621+ p .set_defaults (func = cmd_list_negative_keywords )
3622+
3623+ p = sub .add_parser ("add-negative-keyword" ,
3624+ help = "Add a campaign-level negative keyword" )
3625+ p .add_argument ("campaign" , help = "Campaign ID" )
3626+ p .add_argument ("text" , help = "Keyword text (the word/phrase to block)" )
3627+ p .add_argument ("--match-type" , required = True , dest = "match_type" ,
3628+ choices = ["EXACT" , "PHRASE" , "BROAD" ])
3629+ p .add_argument ("--apply" , action = "store_true" )
3630+ p .set_defaults (func = cmd_add_negative_keyword )
3631+
3632+ p = sub .add_parser ("remove-negative-keyword" , help = "Remove a negative keyword by criterion ID" )
3633+ p .add_argument ("campaign" , help = "Campaign ID" )
3634+ p .add_argument ("criterion_id" , help = "Criterion ID (from list-negative-keywords)" )
3635+ p .add_argument ("--apply" , action = "store_true" )
3636+ p .set_defaults (func = cmd_remove_negative_keyword )
3637+
3638+ p = sub .add_parser ("list-search-terms" ,
3639+ help = "What users actually searched before clicking — for harvesting keywords + negatives" )
3640+ p .add_argument ("--campaign" , help = "Filter by campaign ID" )
3641+ p .add_argument ("--days" , type = int , default = 30 , help = "Look back N days (default 30)" )
3642+ p .add_argument ("--limit" , type = int , default = 100 , help = "Top N by spend (default 100)" )
3643+ p .set_defaults (func = cmd_list_search_terms )
3644+
3645+ p = sub .add_parser ("apply-recommendation" ,
3646+ help = "Apply one of Google's auto-generated recommendations" )
3647+ p .add_argument ("resource_name" ,
3648+ help = "Full resource name from `gads list-recommendations`" )
3649+ p .add_argument ("--apply" , action = "store_true" )
3650+ p .set_defaults (func = cmd_apply_recommendation )
3651+
3652+ p = sub .add_parser ("asset-performance" ,
3653+ help = "Top vs bottom assets by Google's performance label" )
3654+ p .add_argument ("--asset-group" , dest = "asset_group" , help = "Filter by asset group ID" )
3655+ p .set_defaults (func = cmd_asset_performance )
3656+
3657+ p = sub .add_parser ("create-conversion-action" ,
3658+ help = "Create a new conversion action" )
3659+ p .add_argument ("--name" , required = True , help = "Display name, e.g. 'Purchase'" )
3660+ p .add_argument ("--type" , default = "WEBPAGE" ,
3661+ help = "WEBPAGE (default), WEBSITE_CALL, UPLOAD_CLICKS, PHONE_CALL_FROM_ADS" )
3662+ p .add_argument ("--category" , default = "PURCHASE" ,
3663+ help = "PURCHASE (default), SIGNUP, LEAD, PAGE_VIEW, ADD_TO_CART, BEGIN_CHECKOUT, ..." )
3664+ p .add_argument ("--primary" , action = "store_true" , default = True ,
3665+ help = "Mark as primary-for-goal (default true)" )
3666+ p .add_argument ("--default-value" , dest = "default_value" , type = float , default = 0.0 ,
3667+ help = "Fallback conversion value if event doesn't send one" )
3668+ p .add_argument ("--currency" , default = "USD" ,
3669+ help = "3-letter ISO code, e.g. USD, HUF, EUR" )
3670+ p .add_argument ("--apply" , action = "store_true" )
3671+ p .set_defaults (func = cmd_create_conversion_action )
3672+
33403673 p = sub .add_parser ("audit-log" , help = "Show recent --apply mutations" )
33413674 p .add_argument ("--tail" , type = int , default = 20 , help = "Last N entries (default: 20, 0 = all)" )
33423675 p .set_defaults (func = cmd_audit_log )
0 commit comments