Skip to content

Commit a34719f

Browse files
NagyViktclaude
andcommitted
PR 18: Sales toolkit — 7 new commands focused on revenue lift
Negative keywords (block waste): gads list-negative-keywords [--campaign X] gads add-negative-keyword <campaign> 'used' --match-type PHRASE --apply gads remove-negative-keyword <campaign> <criterion_id> --apply Search-term harvesting (find what users really type): gads list-search-terms --days 30 --limit 100 → ranked by spend; flags zero-conv terms as negative candidates Recommendations (apply Google's free advice): gads apply-recommendation <resource_name> --apply → companions existing `gads list-recommendations` Asset performance (drop the dead weight): gads asset-performance [--asset-group X] → groups by Google's performance label (BEST/GOOD/LOW/PENDING) → flags LOW candidates for removal Conversion action creation (fix tracking from CLI): gads create-conversion-action --name Purchase --type WEBPAGE \\ --category PURCHASE --currency HUF --default-value 50000 --apply → closes the only major API gap from the audit; tracking setup no longer UI-only. All mutations get auto-snapshot + audit-log via the existing hooks (added to _MUTATING_COMMANDS). Manifest updated so `gads commands` surfaces the new ops to LLM agents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c2aa96 commit a34719f

1 file changed

Lines changed: 333 additions & 0 deletions

File tree

gads

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"\nSynced. 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

27633038
def _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

Comments
 (0)