@@ -2133,6 +2133,99 @@ def cmd_suggest(args):
21332133# copy, and account-level assets are roadmap. The export format is TOML
21342134# so it's hand-editable + diff-friendly in git.
21352135
2136+ def _live_ad_groups_state (customer_id ):
2137+ """Snapshot enabled/paused ad groups (SEARCH and SEARCH-like campaigns)."""
2138+ res = api_call ("POST" , "googleAds:search" , {"query" : """
2139+ SELECT ad_group.id, ad_group.name, ad_group.status, campaign.id
2140+ FROM ad_group
2141+ WHERE ad_group.status != 'REMOVED'
2142+ """ }, customer_id )
2143+ out = []
2144+ for row in res .get ("results" , []):
2145+ ag = row ["adGroup" ]
2146+ out .append ({
2147+ "id" : ag ["id" ],
2148+ "name" : ag ["name" ],
2149+ "status" : ag ["status" ],
2150+ "campaign_id" : row ["campaign" ]["id" ],
2151+ })
2152+ return out
2153+
2154+
2155+ def _live_keywords_state (customer_id ):
2156+ """Snapshot ad_group_criterion keywords."""
2157+ res = api_call ("POST" , "googleAds:search" , {"query" : """
2158+ SELECT ad_group_criterion.criterion_id,
2159+ ad_group_criterion.keyword.text,
2160+ ad_group_criterion.keyword.match_type,
2161+ ad_group_criterion.status,
2162+ ad_group.id
2163+ FROM ad_group_criterion
2164+ WHERE ad_group_criterion.type = 'KEYWORD'
2165+ AND ad_group_criterion.status != 'REMOVED'
2166+ """ }, customer_id )
2167+ out = []
2168+ for row in res .get ("results" , []):
2169+ agc = row ["adGroupCriterion" ]
2170+ out .append ({
2171+ "id" : agc .get ("criterionId" ),
2172+ "ad_group_id" : row ["adGroup" ]["id" ],
2173+ "text" : agc .get ("keyword" , {}).get ("text" , "" ),
2174+ "match_type" : agc .get ("keyword" , {}).get ("matchType" , "" ),
2175+ "status" : agc .get ("status" , "ENABLED" ),
2176+ })
2177+ return out
2178+
2179+
2180+ def _live_asset_group_text_assets_state (customer_id ):
2181+ """Snapshot HEADLINE/DESCRIPTION/LONG_HEADLINE assets per asset group."""
2182+ res = api_call ("POST" , "googleAds:search" , {"query" : """
2183+ SELECT asset.id, asset.text_asset.text,
2184+ asset_group.id, asset_group_asset.field_type
2185+ FROM asset_group_asset
2186+ WHERE asset_group_asset.status != 'REMOVED'
2187+ AND asset_group_asset.field_type IN ('HEADLINE', 'DESCRIPTION', 'LONG_HEADLINE')
2188+ """ }, customer_id )
2189+ out = []
2190+ for row in res .get ("results" , []):
2191+ out .append ({
2192+ "id" : row ["asset" ]["id" ],
2193+ "asset_group_id" : row ["assetGroup" ]["id" ],
2194+ "field_type" : row ["assetGroupAsset" ]["fieldType" ],
2195+ "text" : row ["asset" ].get ("textAsset" , {}).get ("text" , "" ),
2196+ })
2197+ return out
2198+
2199+
2200+ def _live_responsive_search_ads_state (customer_id ):
2201+ """Snapshot RSAs (inline headlines + descriptions)."""
2202+ res = api_call ("POST" , "googleAds:search" , {"query" : """
2203+ SELECT ad_group_ad.ad.id,
2204+ ad_group_ad.ad.responsive_search_ad.headlines,
2205+ ad_group_ad.ad.responsive_search_ad.descriptions,
2206+ ad_group_ad.ad.final_urls,
2207+ ad_group_ad.status,
2208+ ad_group.id
2209+ FROM ad_group_ad
2210+ WHERE ad_group_ad.status != 'REMOVED'
2211+ """ }, customer_id )
2212+ out = []
2213+ for row in res .get ("results" , []):
2214+ ad = row ["adGroupAd" ]["ad" ]
2215+ rsa = ad .get ("responsiveSearchAd" )
2216+ if not rsa : # only RSAs in v3
2217+ continue
2218+ out .append ({
2219+ "id" : ad ["id" ],
2220+ "ad_group_id" : row ["adGroup" ]["id" ],
2221+ "status" : row ["adGroupAd" ]["status" ],
2222+ "final_url" : (ad .get ("finalUrls" ) or ["" ])[0 ],
2223+ "headlines" : [h .get ("text" , "" ) for h in rsa .get ("headlines" , [])],
2224+ "descriptions" : [d .get ("text" , "" ) for d in rsa .get ("descriptions" , [])],
2225+ })
2226+ return out
2227+
2228+
21362229def _live_account_assets_state (customer_id ):
21372230 """Snapshot account-scope sitelinks/callouts/snippets."""
21382231 res = api_call ("POST" , "googleAds:search" , {"query" : """
@@ -2258,6 +2351,46 @@ def _serialize_state_toml(state):
22582351 lines .append (f'values = [{ values_str } ]' )
22592352 lines .append ("" )
22602353
2354+ # ---- v3: ad groups + keywords + asset-group text assets + RSAs ----
2355+ for ag in state .get ("ad_groups" , []):
2356+ lines .append ("[[ad_groups]]" )
2357+ lines .append (f'id = "{ ag ["id" ]} " # readonly' )
2358+ lines .append (f'campaign_id = "{ ag ["campaign_id" ]} "' )
2359+ lines .append (f'name = "{ _toml_escape (ag .get ("name" , "" ))} "' )
2360+ lines .append (f'status = "{ ag .get ("status" , "ENABLED" )} "' )
2361+ lines .append ("" )
2362+
2363+ for kw in state .get ("keywords" , []):
2364+ lines .append ("[[keywords]]" )
2365+ if kw .get ("id" ):
2366+ lines .append (f'id = "{ kw ["id" ]} "' )
2367+ lines .append (f'ad_group_id = "{ kw ["ad_group_id" ]} "' )
2368+ lines .append (f'text = "{ _toml_escape (kw ["text" ])} "' )
2369+ lines .append (f'match_type = "{ kw ["match_type" ]} "' )
2370+ lines .append ("" )
2371+
2372+ for ta in state .get ("asset_group_text_assets" , []):
2373+ lines .append ("[[asset_group_text_assets]]" )
2374+ if ta .get ("id" ):
2375+ lines .append (f'id = "{ ta ["id" ]} "' )
2376+ lines .append (f'asset_group_id = "{ ta ["asset_group_id" ]} "' )
2377+ lines .append (f'field_type = "{ ta ["field_type" ]} " # HEADLINE | DESCRIPTION | LONG_HEADLINE' )
2378+ lines .append (f'text = "{ _toml_escape (ta ["text" ])} "' )
2379+ lines .append ("" )
2380+
2381+ for rsa in state .get ("responsive_search_ads" , []):
2382+ lines .append ("[[responsive_search_ads]]" )
2383+ if rsa .get ("id" ):
2384+ lines .append (f'id = "{ rsa ["id" ]} " # readonly' )
2385+ lines .append (f'ad_group_id = "{ rsa ["ad_group_id" ]} "' )
2386+ lines .append (f'status = "{ rsa .get ("status" , "ENABLED" )} "' )
2387+ lines .append (f'final_url = "{ _toml_escape (rsa .get ("final_url" , "" ))} "' )
2388+ headlines_arr = ", " .join (f'"{ _toml_escape (h )} "' for h in rsa .get ("headlines" , []))
2389+ lines .append (f'headlines = [{ headlines_arr } ]' )
2390+ descs_arr = ", " .join (f'"{ _toml_escape (d )} "' for d in rsa .get ("descriptions" , []))
2391+ lines .append (f'descriptions = [{ descs_arr } ]' )
2392+ lines .append ("" )
2393+
22612394 return "\n " .join (lines )
22622395
22632396
@@ -2268,13 +2401,25 @@ def cmd_export(args):
22682401 "campaigns" : _live_campaigns_state (args .customer ),
22692402 ** assets ,
22702403 }
2404+ if args .full :
2405+ state ["ad_groups" ] = _live_ad_groups_state (args .customer )
2406+ state ["keywords" ] = _live_keywords_state (args .customer )
2407+ state ["asset_group_text_assets" ] = _live_asset_group_text_assets_state (args .customer )
2408+ state ["responsive_search_ads" ] = _live_responsive_search_ads_state (args .customer )
22712409 serialized = _serialize_state_toml (state )
22722410 counts = (
22732411 f"{ len (state ['campaigns' ])} campaign(s), "
22742412 f"{ len (state ['sitelinks' ])} sitelink(s), "
22752413 f"{ len (state ['callouts' ])} callout(s), "
22762414 f"{ len (state ['snippets' ])} snippet(s)"
22772415 )
2416+ if args .full :
2417+ counts += (
2418+ f", { len (state ['ad_groups' ])} ad group(s), "
2419+ f"{ len (state ['keywords' ])} keyword(s), "
2420+ f"{ len (state ['asset_group_text_assets' ])} ag text-asset(s), "
2421+ f"{ len (state ['responsive_search_ads' ])} RSA(s)"
2422+ )
22782423 if args .output and args .output != "-" :
22792424 Path (args .output ).write_text (serialized )
22802425 print (f"Wrote { args .output } ({ counts } )" )
@@ -2355,15 +2500,41 @@ def _compute_diff_states(desired_state, current_state):
23552500 "operation" : "prune" ,
23562501 "data" : live_entry ,
23572502 })
2503+
2504+ # ---- v3: ad_group_text_assets, keywords, RSAs (id-keyed, create/prune) ----
2505+ for plural , singular in (
2506+ ("keywords" , "keyword" ),
2507+ ("asset_group_text_assets" , "asset_group_text_asset" ),
2508+ ("responsive_search_ads" , "responsive_search_ad" ),
2509+ ):
2510+ if plural not in desired_state :
2511+ continue
2512+ desired_list = desired_state [plural ]
2513+ live_list = current_state .get (plural , [])
2514+ state_ids = {e ["id" ] for e in desired_list if e .get ("id" )}
2515+ for entry in desired_list :
2516+ if not entry .get ("id" ):
2517+ changes .append ({"kind" : singular , "operation" : "create" , "data" : entry })
2518+ for live_entry in live_list :
2519+ if live_entry ["id" ] not in state_ids :
2520+ changes .append ({"kind" : singular , "operation" : "prune" , "data" : live_entry })
23582521 return changes
23592522
23602523
23612524def _compute_diff (state_file , customer_id ):
2362- """Diff a state file against the live Google Ads account."""
2525+ """Diff a state file against the live Google Ads account. Only fetches
2526+ the v3 entity types if the state file mentions them — keeps the simple
2527+ case (campaigns + account assets) fast."""
23632528 live_state = {
23642529 "campaigns" : _live_campaigns_state (customer_id ),
23652530 ** _live_account_assets_state (customer_id ),
23662531 }
2532+ v3_keys = ("ad_groups" , "keywords" , "asset_group_text_assets" , "responsive_search_ads" )
2533+ if any (k in state_file for k in v3_keys ):
2534+ live_state ["ad_groups" ] = _live_ad_groups_state (customer_id )
2535+ live_state ["keywords" ] = _live_keywords_state (customer_id )
2536+ live_state ["asset_group_text_assets" ] = _live_asset_group_text_assets_state (customer_id )
2537+ live_state ["responsive_search_ads" ] = _live_responsive_search_ads_state (customer_id )
23672538 return _compute_diff_states (state_file , live_state )
23682539
23692540
@@ -2395,6 +2566,24 @@ def _change_summary(ch):
23952566 return f"+ snippet { d .get ('header' ,'' )} : { ', ' .join (d .get ('values' ,[]))} "
23962567 if op == "prune" :
23972568 return f"- snippet [{ d ['id' ]} ] { d .get ('header' ,'' )} "
2569+ if kind == "keyword" :
2570+ d = ch ["data" ]
2571+ if op == "create" :
2572+ return f"+ keyword [{ d ['match_type' ]} ] '{ d ['text' ]} ' → ad_group { d ['ad_group_id' ]} "
2573+ if op == "prune" :
2574+ return f"- keyword [{ d ['id' ]} ] '{ d ['text' ]} ' ({ d ['match_type' ]} )"
2575+ if kind == "asset_group_text_asset" :
2576+ d = ch ["data" ]
2577+ if op == "create" :
2578+ return f"+ { d ['field_type' ]:<13} '{ d ['text' ]} ' → asset_group { d ['asset_group_id' ]} "
2579+ if op == "prune" :
2580+ return f"- { d ['field_type' ]:<13} [{ d ['id' ]} ] '{ d ['text' ]} '"
2581+ if kind == "responsive_search_ad" :
2582+ d = ch ["data" ]
2583+ if op == "create" :
2584+ return f"+ RSA ({ len (d .get ('headlines' ,[]))} h + { len (d .get ('descriptions' ,[]))} d) → ad_group { d ['ad_group_id' ]} "
2585+ if op == "prune" :
2586+ return f"- RSA [{ d ['id' ]} ] in ad_group { d ['ad_group_id' ]} "
23982587 return f"? { kind } { op } "
23992588
24002589
@@ -2462,15 +2651,58 @@ def _apply_change(ch, customer_id):
24622651 }}, customer_id )
24632652 _link_to_customer (rn , "STRUCTURED_SNIPPET" , customer_id )
24642653 return f"created snippet '{ d ['header' ]} '"
2654+ if op == "create" :
2655+ d = ch ["data" ]
2656+ if kind == "keyword" :
2657+ ag_rn = f"customers/{ customer_id } /adGroups/{ d ['ad_group_id' ]} "
2658+ body = {"operations" : [{"create" : {
2659+ "adGroup" : ag_rn , "status" : "ENABLED" ,
2660+ "keyword" : {"text" : d ["text" ], "matchType" : d ["match_type" ]},
2661+ }}]}
2662+ api_call ("POST" , "adGroupCriteria:mutate" , body , customer_id )
2663+ return f"created keyword '{ d ['text' ]} ' [{ d ['match_type' ]} ]"
2664+ if kind == "asset_group_text_asset" :
2665+ text_rn = _create_text_asset (d ["text" ], customer_id )
2666+ _link_asset_to_group (text_rn , d ["asset_group_id" ], d ["field_type" ], customer_id )
2667+ return f"created { d ['field_type' ]} '{ d ['text' ]} '"
2668+ if kind == "responsive_search_ad" :
2669+ ag_rn = f"customers/{ customer_id } /adGroups/{ d ['ad_group_id' ]} "
2670+ body = {"operations" : [{"create" : {
2671+ "adGroup" : ag_rn ,
2672+ "status" : d .get ("status" , "ENABLED" ),
2673+ "ad" : {
2674+ "finalUrls" : [d ["final_url" ]],
2675+ "responsiveSearchAd" : {
2676+ "headlines" : [{"text" : h } for h in d ["headlines" ]],
2677+ "descriptions" : [{"text" : dd } for dd in d ["descriptions" ]],
2678+ },
2679+ },
2680+ }}]}
2681+ api_call ("POST" , "adGroupAds:mutate" , body , customer_id )
2682+ return f"created RSA in ad_group { d ['ad_group_id' ]} "
24652683 if op == "prune" :
24662684 d = ch ["data" ]
2467- # Remove the customer_asset link (asset stays in library; can clean
2468- # later via `gads cleanup-orphans`).
2469- ft = {"sitelink" : "SITELINK" , "callout" : "CALLOUT" , "snippet" : "STRUCTURED_SNIPPET" }[kind ]
2470- resource = f"customers/{ customer_id } /customerAssets/{ d ['id' ]} ~{ ft } "
2471- body = {"operations" : [{"remove" : resource }]}
2472- api_call ("POST" , "customerAssets:mutate" , body , customer_id )
2473- return f"pruned { kind } [{ d ['id' ]} ]"
2685+ if kind in ("sitelink" , "callout" , "snippet" ):
2686+ ft = {"sitelink" : "SITELINK" , "callout" : "CALLOUT" , "snippet" : "STRUCTURED_SNIPPET" }[kind ]
2687+ resource = f"customers/{ customer_id } /customerAssets/{ d ['id' ]} ~{ ft } "
2688+ body = {"operations" : [{"remove" : resource }]}
2689+ api_call ("POST" , "customerAssets:mutate" , body , customer_id )
2690+ return f"pruned { kind } [{ d ['id' ]} ]"
2691+ if kind == "keyword" :
2692+ resource = f"customers/{ customer_id } /adGroupCriteria/{ d ['ad_group_id' ]} ~{ d ['id' ]} "
2693+ body = {"operations" : [{"remove" : resource }]}
2694+ api_call ("POST" , "adGroupCriteria:mutate" , body , customer_id )
2695+ return f"pruned keyword [{ d ['id' ]} ] '{ d ['text' ]} '"
2696+ if kind == "asset_group_text_asset" :
2697+ resource = f"customers/{ customer_id } /assetGroupAssets/{ d ['asset_group_id' ]} ~{ d ['id' ]} ~{ d ['field_type' ]} "
2698+ body = {"operations" : [{"remove" : resource }]}
2699+ api_call ("POST" , "assetGroupAssets:mutate" , body , customer_id )
2700+ return f"unlinked { d ['field_type' ]} [{ d ['id' ]} ] from asset_group { d ['asset_group_id' ]} "
2701+ if kind == "responsive_search_ad" :
2702+ resource = f"customers/{ customer_id } /adGroupAds/{ d ['ad_group_id' ]} ~{ d ['id' ]} "
2703+ body = {"operations" : [{"remove" : resource }]}
2704+ api_call ("POST" , "adGroupAds:mutate" , body , customer_id )
2705+ return f"pruned RSA [{ d ['id' ]} ]"
24742706 return f"skipped { kind } { op } "
24752707
24762708
@@ -3110,9 +3342,11 @@ def main():
31103342 p .set_defaults (func = cmd_audit_log )
31113343
31123344 p = sub .add_parser ("export" ,
3113- help = "Export live account state to a TOML file (v1: campaigns) " )
3345+ help = "Export live account state to a TOML file" )
31143346 p .add_argument ("-o" , "--output" , default = "-" ,
31153347 help = "Output file path (default: stdout)" )
3348+ p .add_argument ("--full" , action = "store_true" ,
3349+ help = "Also export ad groups, keywords, asset-group text assets, and RSAs (v3 schema)" )
31163350 p .set_defaults (func = cmd_export )
31173351
31183352 p = sub .add_parser ("plan" ,
0 commit comments