asa-api-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
asa_api_cli/brand.py ADDED
@@ -0,0 +1,809 @@
1
+ """Brand campaign CLI commands.
2
+
3
+ This module provides the `brand` command for creating brand protection campaigns.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ from decimal import Decimal
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from asa_api_client.exceptions import AppleSearchAdsError
12
+ from asa_api_client.models import (
13
+ AdGroupCreate,
14
+ CampaignCreate,
15
+ CampaignStatus,
16
+ CampaignSupplySource,
17
+ KeywordCreate,
18
+ KeywordMatchType,
19
+ Money,
20
+ )
21
+ from rich.table import Table
22
+
23
+ from asa_api_cli.optimize import CampaignNameParts, wait_for_resource
24
+ from asa_api_cli.utils import (
25
+ console,
26
+ get_client,
27
+ handle_api_error,
28
+ print_error,
29
+ print_info,
30
+ print_result_panel,
31
+ print_success,
32
+ print_warning,
33
+ spinner,
34
+ )
35
+
36
+ app = typer.Typer(
37
+ name="brand",
38
+ help="Create brand protection campaigns.",
39
+ )
40
+
41
+ # All 91 Apple Search Ads supported countries (as of December 2024)
42
+ # Organized by region for easier maintenance
43
+ ALL_COUNTRIES = {
44
+ # Africa, Middle East, and India (19)
45
+ "africa_middle_east_india": [
46
+ "DZ", # Algeria
47
+ "AM", # Armenia
48
+ "BH", # Bahrain
49
+ "EG", # Egypt
50
+ "GH", # Ghana
51
+ "IN", # India
52
+ "IQ", # Iraq
53
+ "IL", # Israel
54
+ "JO", # Jordan
55
+ "KE", # Kenya
56
+ "KW", # Kuwait
57
+ "LB", # Lebanon
58
+ "MA", # Morocco
59
+ "OM", # Oman
60
+ "PK", # Pakistan
61
+ "QA", # Qatar
62
+ "SA", # Saudi Arabia
63
+ "ZA", # South Africa
64
+ "AE", # United Arab Emirates
65
+ ],
66
+ # Asia Pacific (18)
67
+ "asia_pacific": [
68
+ "AU", # Australia
69
+ "KH", # Cambodia
70
+ "CN", # China mainland (requires special documentation)
71
+ "HK", # Hong Kong
72
+ "ID", # Indonesia
73
+ "JP", # Japan
74
+ "MO", # Macau
75
+ "MY", # Malaysia
76
+ "MN", # Mongolia
77
+ "NP", # Nepal
78
+ "NZ", # New Zealand
79
+ "PH", # Philippines
80
+ "SG", # Singapore
81
+ "KR", # South Korea
82
+ "LK", # Sri Lanka
83
+ "TW", # Taiwan
84
+ "TH", # Thailand
85
+ "VN", # Vietnam
86
+ ],
87
+ # Europe (37)
88
+ "europe": [
89
+ "AL", # Albania
90
+ "AT", # Austria
91
+ "AZ", # Azerbaijan
92
+ "BE", # Belgium
93
+ "BG", # Bulgaria
94
+ "HR", # Croatia
95
+ "CY", # Cyprus
96
+ "CZ", # Czech Republic
97
+ "DK", # Denmark
98
+ "EE", # Estonia
99
+ "FI", # Finland
100
+ "FR", # France
101
+ "DE", # Germany
102
+ "GR", # Greece
103
+ "HU", # Hungary
104
+ "IS", # Iceland
105
+ "IE", # Ireland
106
+ "IT", # Italy
107
+ "KZ", # Kazakhstan
108
+ "KG", # Kyrgyzstan
109
+ "LV", # Latvia
110
+ "LU", # Luxembourg
111
+ "NL", # Netherlands
112
+ "NO", # Norway
113
+ "PL", # Poland
114
+ "PT", # Portugal
115
+ "RO", # Romania
116
+ "RU", # Russia
117
+ "SK", # Slovakia
118
+ "SI", # Slovenia
119
+ "ES", # Spain
120
+ "SE", # Sweden
121
+ "CH", # Switzerland
122
+ "TR", # Türkiye
123
+ "GB", # UK
124
+ "UA", # Ukraine
125
+ "UZ", # Uzbekistan
126
+ ],
127
+ # Latin America and the Caribbean (15)
128
+ "latin_america": [
129
+ "AR", # Argentina
130
+ "BO", # Bolivia
131
+ "BR", # Brazil
132
+ "CL", # Chile
133
+ "CO", # Colombia
134
+ "CR", # Costa Rica
135
+ "DO", # Dominican Republic
136
+ "EC", # Ecuador
137
+ "SV", # El Salvador
138
+ "GT", # Guatemala
139
+ "HN", # Honduras
140
+ "MX", # Mexico
141
+ "PA", # Panamá
142
+ "PY", # Paraguay
143
+ "PE", # Peru
144
+ ],
145
+ # North America (2)
146
+ "north_america": [
147
+ "CA", # Canada
148
+ "US", # United States
149
+ ],
150
+ }
151
+
152
+ # China requires special business documentation
153
+ CHINA_COUNTRIES = ["CN"]
154
+
155
+ # Preset groups
156
+ COUNTRY_PRESETS = {
157
+ "english": ["US", "GB", "CA", "AU", "NZ", "IE"],
158
+ "tier1": ["US", "GB", "CA", "AU"],
159
+ "europe": ALL_COUNTRIES["europe"],
160
+ "asia": [c for c in ALL_COUNTRIES["asia_pacific"] if c != "CN"],
161
+ "latam": ALL_COUNTRIES["latin_america"],
162
+ }
163
+
164
+
165
+ def get_all_countries(include_china: bool = False) -> list[str]:
166
+ """Get all supported countries.
167
+
168
+ Args:
169
+ include_china: Whether to include China (requires special documentation).
170
+
171
+ Returns:
172
+ List of country codes.
173
+ """
174
+ countries = []
175
+ for region_countries in ALL_COUNTRIES.values():
176
+ for code in region_countries:
177
+ if code == "CN" and not include_china:
178
+ continue
179
+ countries.append(code)
180
+ return countries
181
+
182
+
183
+ def get_country_count() -> int:
184
+ """Get total number of supported countries (excluding China)."""
185
+ return len(get_all_countries(include_china=False))
186
+
187
+
188
+ # Country code to name mapping for display
189
+ COUNTRY_NAMES = {
190
+ "DZ": "Algeria",
191
+ "AM": "Armenia",
192
+ "BH": "Bahrain",
193
+ "EG": "Egypt",
194
+ "GH": "Ghana",
195
+ "IN": "India",
196
+ "IQ": "Iraq",
197
+ "IL": "Israel",
198
+ "JO": "Jordan",
199
+ "KE": "Kenya",
200
+ "KW": "Kuwait",
201
+ "LB": "Lebanon",
202
+ "MA": "Morocco",
203
+ "OM": "Oman",
204
+ "PK": "Pakistan",
205
+ "QA": "Qatar",
206
+ "SA": "Saudi Arabia",
207
+ "ZA": "South Africa",
208
+ "AE": "UAE",
209
+ "AU": "Australia",
210
+ "KH": "Cambodia",
211
+ "CN": "China",
212
+ "HK": "Hong Kong",
213
+ "ID": "Indonesia",
214
+ "JP": "Japan",
215
+ "MO": "Macau",
216
+ "MY": "Malaysia",
217
+ "MN": "Mongolia",
218
+ "NP": "Nepal",
219
+ "NZ": "New Zealand",
220
+ "PH": "Philippines",
221
+ "SG": "Singapore",
222
+ "KR": "South Korea",
223
+ "LK": "Sri Lanka",
224
+ "TW": "Taiwan",
225
+ "TH": "Thailand",
226
+ "VN": "Vietnam",
227
+ "AL": "Albania",
228
+ "AT": "Austria",
229
+ "AZ": "Azerbaijan",
230
+ "BE": "Belgium",
231
+ "BG": "Bulgaria",
232
+ "HR": "Croatia",
233
+ "CY": "Cyprus",
234
+ "CZ": "Czech Republic",
235
+ "DK": "Denmark",
236
+ "EE": "Estonia",
237
+ "FI": "Finland",
238
+ "FR": "France",
239
+ "DE": "Germany",
240
+ "GR": "Greece",
241
+ "HU": "Hungary",
242
+ "IS": "Iceland",
243
+ "IE": "Ireland",
244
+ "IT": "Italy",
245
+ "KZ": "Kazakhstan",
246
+ "KG": "Kyrgyzstan",
247
+ "LV": "Latvia",
248
+ "LU": "Luxembourg",
249
+ "NL": "Netherlands",
250
+ "NO": "Norway",
251
+ "PL": "Poland",
252
+ "PT": "Portugal",
253
+ "RO": "Romania",
254
+ "RU": "Russia",
255
+ "SK": "Slovakia",
256
+ "SI": "Slovenia",
257
+ "ES": "Spain",
258
+ "SE": "Sweden",
259
+ "CH": "Switzerland",
260
+ "TR": "Türkiye",
261
+ "GB": "UK",
262
+ "UA": "Ukraine",
263
+ "UZ": "Uzbekistan",
264
+ "AR": "Argentina",
265
+ "BO": "Bolivia",
266
+ "BR": "Brazil",
267
+ "CL": "Chile",
268
+ "CO": "Colombia",
269
+ "CR": "Costa Rica",
270
+ "DO": "Dominican Republic",
271
+ "EC": "Ecuador",
272
+ "SV": "El Salvador",
273
+ "GT": "Guatemala",
274
+ "HN": "Honduras",
275
+ "MX": "Mexico",
276
+ "PA": "Panamá",
277
+ "PY": "Paraguay",
278
+ "PE": "Peru",
279
+ "CA": "Canada",
280
+ "US": "United States",
281
+ }
282
+
283
+
284
+ @dataclass
285
+ class BrandCampaignPlan:
286
+ """Plan for creating a brand campaign."""
287
+
288
+ app_name: str
289
+ adam_id: int
290
+ country: str
291
+ name: str
292
+ keywords: list[str]
293
+ daily_budget: Decimal
294
+ currency: str
295
+ default_bid: Decimal
296
+
297
+
298
+ def _select_countries_interactive(include_china: bool = False) -> list[str]:
299
+ """Interactive country selection.
300
+
301
+ Returns:
302
+ List of selected country codes.
303
+ """
304
+ from rich.rule import Rule
305
+
306
+ console.print()
307
+ console.print(Rule("Select Target Countries"))
308
+ console.print()
309
+
310
+ # Show presets
311
+ table = Table(title="Country Presets", show_header=True)
312
+ table.add_column("#", style="dim", width=4)
313
+ table.add_column("Preset")
314
+ table.add_column("Countries")
315
+ table.add_column("Count", justify="right")
316
+
317
+ presets_list = [
318
+ ("all", "All countries (excl. China)", get_country_count()),
319
+ ("english", "English-speaking", len(COUNTRY_PRESETS["english"])),
320
+ ("tier1", "Tier 1 (US, GB, CA, AU)", len(COUNTRY_PRESETS["tier1"])),
321
+ ("europe", "Europe", len(COUNTRY_PRESETS["europe"])),
322
+ ("asia", "Asia Pacific (excl. China)", len(COUNTRY_PRESETS["asia"])),
323
+ ("latam", "Latin America", len(COUNTRY_PRESETS["latam"])),
324
+ ]
325
+
326
+ for i, (key, desc, count) in enumerate(presets_list, 1):
327
+ table.add_row(str(i), key, desc, str(count))
328
+
329
+ console.print(table)
330
+ console.print()
331
+ console.print("[dim]Or enter country codes separated by commas (e.g., US,GB,AU)[/dim]")
332
+ console.print()
333
+
334
+ selection = typer.prompt("Select preset (1-6) or enter country codes").strip()
335
+
336
+ # Check if it's a preset number
337
+ try:
338
+ preset_idx = int(selection) - 1
339
+ if 0 <= preset_idx < len(presets_list):
340
+ preset_key = presets_list[preset_idx][0]
341
+ if preset_key == "all":
342
+ countries = get_all_countries(include_china=include_china)
343
+ else:
344
+ countries = COUNTRY_PRESETS[preset_key].copy()
345
+ return countries
346
+ except ValueError:
347
+ pass
348
+
349
+ # Check if it's a preset name
350
+ selection_lower = selection.lower()
351
+ if selection_lower == "all":
352
+ return get_all_countries(include_china=include_china)
353
+ if selection_lower in COUNTRY_PRESETS:
354
+ return COUNTRY_PRESETS[selection_lower].copy()
355
+
356
+ # Parse as comma-separated country codes
357
+ countries = []
358
+ all_valid = get_all_countries(include_china=True)
359
+
360
+ for code in selection.upper().replace(" ", "").split(","):
361
+ if not code:
362
+ continue
363
+ if code not in all_valid:
364
+ print_warning(f"Unknown country code: {code}")
365
+ continue
366
+ if code in CHINA_COUNTRIES and not include_china:
367
+ print_warning(
368
+ f"Skipping {code} - China requires special business documentation. Use --include-china to include."
369
+ )
370
+ continue
371
+ if code not in countries:
372
+ countries.append(code)
373
+
374
+ return countries
375
+
376
+
377
+ def _select_app_interactive(client: Any) -> tuple[int, str, str]:
378
+ """Interactive app selection.
379
+
380
+ Returns:
381
+ Tuple of (adam_id, app_name, currency).
382
+ """
383
+ console.print()
384
+ print_info("Select an app to create brand campaigns for:")
385
+
386
+ with spinner("Loading campaigns..."):
387
+ all_campaigns = client.campaigns.list()
388
+
389
+ # Group by adam_id to show unique apps
390
+ apps: dict[int, tuple[str, str]] = {} # adam_id -> (app_name, currency)
391
+ for camp in all_campaigns.data:
392
+ if camp.adam_id and camp.adam_id not in apps:
393
+ parsed = CampaignNameParts.parse(camp.name)
394
+ name = parsed.app_name if parsed else camp.name.split(" - ")[0]
395
+ currency = camp.daily_budget_amount.currency if camp.daily_budget_amount else "USD"
396
+ apps[camp.adam_id] = (name, currency)
397
+
398
+ if not apps:
399
+ print_error("No apps", "No campaigns found to get app information from")
400
+ raise typer.Exit(1)
401
+
402
+ # Display apps
403
+ table = Table(show_header=True)
404
+ table.add_column("#", style="dim", width=4)
405
+ table.add_column("App Name")
406
+ table.add_column("Adam ID", style="dim")
407
+
408
+ app_list = list(apps.items())
409
+ for i, (aid, (name, _)) in enumerate(app_list, 1):
410
+ table.add_row(str(i), name, str(aid))
411
+
412
+ console.print(table)
413
+ console.print()
414
+
415
+ selection = typer.prompt("Select app number", default="1")
416
+ try:
417
+ idx = int(selection) - 1
418
+ if 0 <= idx < len(app_list):
419
+ adam_id, (app_name, currency) = app_list[idx]
420
+ return adam_id, app_name, currency
421
+ else:
422
+ print_error("Invalid selection", f"Please enter 1-{len(app_list)}")
423
+ raise typer.Exit(1)
424
+ except ValueError:
425
+ print_error("Invalid selection", "Please enter a number")
426
+ raise typer.Exit(1)
427
+
428
+
429
+ def _get_brand_keywords_interactive(brand_name: str | None = None) -> list[str]:
430
+ """Interactive brand keyword entry.
431
+
432
+ Args:
433
+ brand_name: Optional initial brand name.
434
+
435
+ Returns:
436
+ List of brand keywords.
437
+ """
438
+ console.print()
439
+
440
+ if brand_name:
441
+ keywords = [brand_name.lower()]
442
+ print_info(f"Brand name: {brand_name}")
443
+ else:
444
+ brand = typer.prompt("Enter your brand name").strip()
445
+ keywords = [brand.lower()]
446
+
447
+ # Ask for variants
448
+ console.print()
449
+ console.print("[dim]Enter brand name variants (common misspellings, abbreviations)[/dim]")
450
+ console.print("[dim]Press Enter with no input when done[/dim]")
451
+ console.print()
452
+
453
+ while True:
454
+ variant = typer.prompt("Add variant (or press Enter to continue)", default="").strip()
455
+ if not variant:
456
+ break
457
+ variant_lower = variant.lower()
458
+ if variant_lower not in keywords:
459
+ keywords.append(variant_lower)
460
+ print_success(f"Added: {variant_lower}")
461
+ else:
462
+ print_warning(f"Already added: {variant_lower}")
463
+
464
+ return keywords
465
+
466
+
467
+ def _get_budget_bid_interactive(
468
+ ref_budget: Decimal | None = None,
469
+ ref_bid: Decimal | None = None,
470
+ ref_currency: str = "USD",
471
+ ) -> tuple[Decimal, Decimal, str]:
472
+ """Interactive budget and bid entry.
473
+
474
+ Returns:
475
+ Tuple of (budget, bid, currency).
476
+ """
477
+ console.print()
478
+
479
+ # Budget
480
+ default_budget = str(ref_budget) if ref_budget else "50.00"
481
+ budget_input = typer.prompt(
482
+ f"Daily budget per campaign ({ref_currency})",
483
+ default=default_budget,
484
+ )
485
+ budget = Decimal(budget_input)
486
+
487
+ # Bid
488
+ default_bid = f"{ref_bid:.2f}" if ref_bid else "1.00"
489
+ bid_input = typer.prompt(
490
+ f"Default bid ({ref_currency})",
491
+ default=default_bid,
492
+ )
493
+ bid = Decimal(bid_input)
494
+
495
+ return budget, bid, ref_currency
496
+
497
+
498
+ @app.callback(invoke_without_command=True)
499
+ def create_brand_campaigns(
500
+ ctx: typer.Context,
501
+ brand_name: Annotated[
502
+ str | None,
503
+ typer.Argument(help="Brand name (optional, will prompt if not provided)"),
504
+ ] = None,
505
+ variants: Annotated[
506
+ list[str] | None,
507
+ typer.Option("--variant", "-v", help="Brand name variants"),
508
+ ] = None,
509
+ countries: Annotated[
510
+ list[str] | None,
511
+ typer.Option("--country", "-c", help="Target countries"),
512
+ ] = None,
513
+ reference_campaign: Annotated[
514
+ int | None,
515
+ typer.Option("--reference", "-r", help="Campaign ID to copy settings from"),
516
+ ] = None,
517
+ daily_budget: Annotated[
518
+ float | None,
519
+ typer.Option("--budget", "-b", help="Daily budget"),
520
+ ] = None,
521
+ default_bid: Annotated[
522
+ float | None,
523
+ typer.Option("--bid", help="Default bid amount"),
524
+ ] = None,
525
+ dry_run: Annotated[
526
+ bool,
527
+ typer.Option("--dry-run", "-n", help="Preview without creating"),
528
+ ] = False,
529
+ paused: Annotated[
530
+ bool,
531
+ typer.Option("--paused", "-p", help="Create in PAUSED status"),
532
+ ] = False,
533
+ include_china: Annotated[
534
+ bool,
535
+ typer.Option("--include-china", help="Include China (requires special docs)"),
536
+ ] = False,
537
+ ) -> None:
538
+ """Create brand protection campaigns.
539
+
540
+ Interactive mode (recommended):
541
+ asa brand
542
+
543
+ With arguments:
544
+ asa brand "Chippy Tools" -v "Chippy Tool" -c US -c GB
545
+
546
+ This creates single-keyword ad groups (SKAGs) for your brand name
547
+ and variants, helping protect your brand from competitors.
548
+ """
549
+ # Skip if a subcommand was invoked
550
+ if ctx.invoked_subcommand is not None:
551
+ return
552
+ from rich.rule import Rule
553
+
554
+ client = get_client()
555
+
556
+ try:
557
+ with client:
558
+ # Step 1: Get brand keywords
559
+ if brand_name or variants:
560
+ # Use provided arguments
561
+ all_keywords = [brand_name.lower()] if brand_name else []
562
+ if variants:
563
+ for v in variants:
564
+ kw = v.lower().strip()
565
+ if kw and kw not in all_keywords:
566
+ all_keywords.append(kw)
567
+ else:
568
+ # Interactive mode
569
+ all_keywords = _get_brand_keywords_interactive()
570
+
571
+ if not all_keywords:
572
+ print_error("No keywords", "At least one brand keyword is required")
573
+ raise typer.Exit(1)
574
+
575
+ print_info(f"Brand keywords ({len(all_keywords)}): {', '.join(all_keywords)}")
576
+
577
+ # Step 2: Get target countries
578
+ if countries:
579
+ target_countries = []
580
+ all_valid = get_all_countries(include_china=True)
581
+ for code in countries:
582
+ code_upper = code.upper()
583
+ if code_upper not in all_valid:
584
+ print_warning(f"Unknown country code: {code_upper}")
585
+ continue
586
+ if code_upper in CHINA_COUNTRIES and not include_china:
587
+ print_warning(f"Skipping {code_upper} - requires --include-china")
588
+ continue
589
+ target_countries.append(code_upper)
590
+ else:
591
+ target_countries = _select_countries_interactive(include_china)
592
+
593
+ if not target_countries:
594
+ print_error("No countries", "No valid target countries selected")
595
+ raise typer.Exit(1)
596
+
597
+ countries_preview = ", ".join(target_countries[:10])
598
+ suffix = "..." if len(target_countries) > 10 else ""
599
+ print_info(f"Target countries ({len(target_countries)}): {countries_preview}{suffix}")
600
+
601
+ # Step 3: Get reference campaign or select app
602
+ ref_budget: Decimal | None = None
603
+ ref_bid: Decimal | None = None
604
+ ref_currency = "USD"
605
+ adam_id: int | None = None
606
+ app_name: str | None = None
607
+
608
+ if reference_campaign:
609
+ with spinner("Loading reference campaign..."):
610
+ ref_camp = client.campaigns.get(reference_campaign)
611
+ adam_id = ref_camp.adam_id
612
+ parsed_name = CampaignNameParts.parse(ref_camp.name)
613
+ app_name = parsed_name.app_name if parsed_name else ref_camp.name.split(" - ")[0]
614
+
615
+ if ref_camp.daily_budget_amount:
616
+ ref_budget = Decimal(ref_camp.daily_budget_amount.amount)
617
+ ref_currency = ref_camp.daily_budget_amount.currency
618
+
619
+ # Get average bid from ad groups
620
+ ad_groups = client.campaigns(reference_campaign).ad_groups.list()
621
+ if ad_groups.data:
622
+ bids = [Decimal(ag.default_bid_amount.amount) for ag in ad_groups.data if ag.default_bid_amount]
623
+ if bids:
624
+ ref_bid = Decimal(sum(bids) / len(bids))
625
+
626
+ print_info(f"Reference: {ref_camp.name}")
627
+ if ref_budget:
628
+ bid_str = f"{ref_bid:.2f}" if ref_bid else "N/A"
629
+ print_info(f" Budget: {ref_budget} {ref_currency}, Avg bid: {bid_str} {ref_currency}")
630
+ else:
631
+ adam_id, app_name, ref_currency = _select_app_interactive(client)
632
+
633
+ # Step 4: Get budget and bid
634
+ if daily_budget is not None:
635
+ final_budget = Decimal(str(daily_budget))
636
+ elif ref_budget:
637
+ final_budget = ref_budget
638
+ else:
639
+ final_budget = None
640
+
641
+ if default_bid is not None:
642
+ final_bid = Decimal(str(default_bid))
643
+ elif ref_bid:
644
+ final_bid = ref_bid
645
+ else:
646
+ final_bid = None
647
+
648
+ # If not fully specified, prompt interactively
649
+ if final_budget is None or final_bid is None:
650
+ final_budget, final_bid, ref_currency = _get_budget_bid_interactive(
651
+ ref_budget=final_budget,
652
+ ref_bid=final_bid,
653
+ ref_currency=ref_currency,
654
+ )
655
+
656
+ # Step 5: Build campaign plans
657
+ campaign_plans: list[BrandCampaignPlan] = []
658
+
659
+ for country in target_countries:
660
+ campaign_name = f"{app_name} - {country} - Brand - EM"
661
+ campaign_plans.append(
662
+ BrandCampaignPlan(
663
+ app_name=app_name or all_keywords[0].title(),
664
+ adam_id=adam_id,
665
+ country=country,
666
+ name=campaign_name,
667
+ keywords=all_keywords,
668
+ daily_budget=final_budget,
669
+ currency=ref_currency,
670
+ default_bid=final_bid,
671
+ )
672
+ )
673
+
674
+ # Step 6: Display plan
675
+ console.print()
676
+ console.print(Rule("Brand Campaign Plan"))
677
+ console.print()
678
+
679
+ console.print(f"[bold]App:[/bold] {app_name} (Adam ID: {adam_id})")
680
+ console.print(f"[bold]Keywords:[/bold] {', '.join(all_keywords)}")
681
+ console.print(f"[bold]Daily Budget:[/bold] {final_budget} {ref_currency}")
682
+ console.print(f"[bold]Default Bid:[/bold] {final_bid:.2f} {ref_currency}")
683
+ console.print(f"[bold]Status:[/bold] {'PAUSED' if paused else 'ENABLED'}")
684
+ console.print()
685
+
686
+ # Show summary table for many countries, detailed for few
687
+ if len(campaign_plans) > 10:
688
+ console.print(f"[bold]Campaigns to create:[/bold] {len(campaign_plans)}")
689
+ console.print(f"[bold]Ad groups per campaign:[/bold] {len(all_keywords)}")
690
+ console.print(f"[bold]Total ad groups:[/bold] {len(campaign_plans) * len(all_keywords)}")
691
+ console.print()
692
+
693
+ # Group by region for display
694
+ console.print("[dim]Countries by region:[/dim]")
695
+ for region, codes in ALL_COUNTRIES.items():
696
+ region_countries = [c for c in target_countries if c in codes]
697
+ if region_countries:
698
+ region_name = region.replace("_", " ").title()
699
+ console.print(f" {region_name}: {', '.join(region_countries)}")
700
+ else:
701
+ table = Table(title=f"Campaigns to Create ({len(campaign_plans)})")
702
+ table.add_column("#", style="dim", width=4)
703
+ table.add_column("Campaign Name")
704
+ table.add_column("Country")
705
+ table.add_column("Ad Groups")
706
+
707
+ for i, plan in enumerate(campaign_plans, 1):
708
+ country_name = COUNTRY_NAMES.get(plan.country, plan.country)
709
+ table.add_row(str(i), plan.name, f"{plan.country} ({country_name})", str(len(plan.keywords)))
710
+
711
+ console.print(table)
712
+
713
+ console.print()
714
+
715
+ if dry_run:
716
+ print_info("Dry run - no campaigns created")
717
+ return
718
+
719
+ # Step 7: Confirm and create
720
+ total_budget = final_budget * len(campaign_plans)
721
+ console.print(f"[yellow]Total daily budget: {total_budget} {ref_currency}[/yellow]")
722
+ console.print()
723
+
724
+ if not typer.confirm(f"Create {len(campaign_plans)} brand campaign(s)?", default=True):
725
+ print_info("Cancelled")
726
+ return
727
+
728
+ # Step 8: Create campaigns
729
+ created_campaigns = 0
730
+ created_ad_groups = 0
731
+ created_keywords = 0
732
+
733
+ for plan_idx, plan in enumerate(campaign_plans, 1):
734
+ console.print()
735
+ console.print(f"[dim][{plan_idx}/{len(campaign_plans)}][/dim] Creating {plan.name}...")
736
+
737
+ with spinner("Creating campaign..."):
738
+ new_campaign = client.campaigns.create(
739
+ CampaignCreate(
740
+ name=plan.name,
741
+ adam_id=plan.adam_id,
742
+ countries_or_regions=[plan.country],
743
+ daily_budget_amount=Money(
744
+ amount=str(plan.daily_budget),
745
+ currency=plan.currency,
746
+ ),
747
+ supply_sources=[CampaignSupplySource.APPSTORE_SEARCH_RESULTS],
748
+ status=CampaignStatus.PAUSED if paused else CampaignStatus.ENABLED,
749
+ )
750
+ )
751
+
752
+ print_success(f"Created campaign (ID: {new_campaign.id})")
753
+ created_campaigns += 1
754
+
755
+ # Wait for campaign to be available
756
+ def get_campaign(cid: int = new_campaign.id) -> Any:
757
+ return client.campaigns.get(cid)
758
+
759
+ wait_for_resource(get_campaign, max_attempts=10, delay=0.5)
760
+
761
+ # Create ad groups for each keyword
762
+ for keyword in plan.keywords:
763
+ ag_name = f"Exact - {keyword.title()}"[:200]
764
+
765
+ with spinner(f" Creating: {ag_name}..."):
766
+ new_ag = client.campaigns(new_campaign.id).ad_groups.create(
767
+ AdGroupCreate(
768
+ name=ag_name,
769
+ default_bid_amount=Money(
770
+ amount=str(plan.default_bid),
771
+ currency=plan.currency,
772
+ ),
773
+ automated_keywords_opt_in=False,
774
+ )
775
+ )
776
+ created_ad_groups += 1
777
+
778
+ # Create keyword (must use bulk endpoint)
779
+ client.campaigns(new_campaign.id).ad_groups(new_ag.id).keywords.create_bulk(
780
+ [
781
+ KeywordCreate(
782
+ text=keyword,
783
+ match_type=KeywordMatchType.EXACT,
784
+ bid_amount=Money(
785
+ amount=str(plan.default_bid),
786
+ currency=plan.currency,
787
+ ),
788
+ )
789
+ ]
790
+ )
791
+ created_keywords += 1
792
+
793
+ print_success(f" {ag_name} → '{keyword}'")
794
+
795
+ # Summary
796
+ console.print()
797
+ print_result_panel(
798
+ "Brand Campaigns Created",
799
+ {
800
+ "Campaigns": str(created_campaigns),
801
+ "Ad Groups": str(created_ad_groups),
802
+ "Keywords": str(created_keywords),
803
+ "Status": "PAUSED" if paused else "ENABLED",
804
+ },
805
+ )
806
+
807
+ except AppleSearchAdsError as e:
808
+ handle_api_error(e)
809
+ raise typer.Exit(1) from None