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.
@@ -0,0 +1,961 @@
1
+ """Optimization CLI commands for campaign management."""
2
+
3
+ import time
4
+ from collections import defaultdict
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from datetime import date, timedelta
8
+ from decimal import Decimal
9
+ from typing import Annotated, Any, TypeVar
10
+
11
+ import typer
12
+ from asa_api_client.exceptions import AppleSearchAdsError, NotFoundError
13
+ from asa_api_client.models import (
14
+ AdGroupCreate,
15
+ AdGroupUpdate,
16
+ CampaignCreate,
17
+ CampaignStatus,
18
+ CampaignSupplySource,
19
+ GranularityType,
20
+ KeywordCreate,
21
+ KeywordMatchType,
22
+ Money,
23
+ NegativeKeywordCreate,
24
+ Selector,
25
+ )
26
+ from rich.table import Table
27
+
28
+ from asa_api_cli.utils import (
29
+ console,
30
+ enum_value,
31
+ get_client,
32
+ handle_api_error,
33
+ print_error,
34
+ print_info,
35
+ print_result_panel,
36
+ print_success,
37
+ print_warning,
38
+ spinner,
39
+ )
40
+
41
+ app = typer.Typer(help="Optimization commands for campaigns")
42
+
43
+ T = TypeVar("T")
44
+
45
+
46
+ def wait_for_resource(
47
+ check_fn: Callable[[], T],
48
+ max_attempts: int = 10,
49
+ delay: float = 0.5,
50
+ ) -> T:
51
+ """Wait for a resource to become available by polling.
52
+
53
+ Args:
54
+ check_fn: Function that returns the resource or raises NotFoundError.
55
+ max_attempts: Maximum number of attempts before giving up.
56
+ delay: Delay in seconds between attempts.
57
+
58
+ Returns:
59
+ The resource once available.
60
+
61
+ Raises:
62
+ NotFoundError: If resource not available after max_attempts.
63
+ """
64
+ for attempt in range(max_attempts):
65
+ try:
66
+ return check_fn()
67
+ except NotFoundError:
68
+ if attempt < max_attempts - 1:
69
+ time.sleep(delay)
70
+ else:
71
+ raise
72
+ # Should never reach here, but satisfy type checker
73
+ raise NotFoundError("Resource not found after maximum attempts")
74
+
75
+
76
+ @dataclass
77
+ class BidDiscrepancy:
78
+ """Represents a bid discrepancy between ad group and keywords."""
79
+
80
+ campaign_id: int
81
+ campaign_name: str
82
+ ad_group_id: int
83
+ ad_group_name: str
84
+ ad_group_bid: Decimal
85
+ keyword_avg_bid: Decimal
86
+ keyword_min_bid: Decimal
87
+ keyword_max_bid: Decimal
88
+ keyword_count: int
89
+ currency: str
90
+
91
+ @property
92
+ def difference_pct(self) -> float:
93
+ """Percentage difference between keyword avg and ad group bid."""
94
+ if self.ad_group_bid == 0:
95
+ return 0.0
96
+ return float((self.keyword_avg_bid - self.ad_group_bid) / self.ad_group_bid * 100)
97
+
98
+
99
+ def _format_bid(amount: Decimal, currency: str) -> str:
100
+ """Format a bid amount with currency."""
101
+ return f"{amount:.2f} {currency}"
102
+
103
+
104
+ @app.command("bid-check")
105
+ def check_bid_discrepancies(
106
+ threshold: Annotated[
107
+ float,
108
+ typer.Option(
109
+ "--threshold",
110
+ "-t",
111
+ help="Minimum percentage difference to flag (default: 20%)",
112
+ ),
113
+ ] = 20.0,
114
+ auto_fix: Annotated[
115
+ bool,
116
+ typer.Option(
117
+ "--auto-fix",
118
+ help="Automatically apply suggested changes without prompting",
119
+ ),
120
+ ] = False,
121
+ dry_run: Annotated[
122
+ bool,
123
+ typer.Option(
124
+ "--dry-run",
125
+ "-n",
126
+ help="Show what would be changed without making changes",
127
+ ),
128
+ ] = False,
129
+ ) -> None:
130
+ """Check for bid discrepancies between ad group and keyword levels.
131
+
132
+ Scans all enabled campaigns and ad groups, comparing the ad group default
133
+ bid to the average of keyword-level bids. Flags cases where keywords have
134
+ materially higher bids than the ad group default.
135
+
136
+ This is useful because when keyword bids are much higher than the ad group
137
+ default, it may indicate the ad group bid should be raised to improve
138
+ competitiveness for new keywords that inherit the default bid.
139
+
140
+ Examples:
141
+ asa optimize bid-check # Check with 20% threshold
142
+ asa optimize bid-check --threshold 50 # Flag only >50% differences
143
+ asa optimize bid-check --dry-run # Preview without changes
144
+ asa optimize bid-check --auto-fix # Apply all suggestions
145
+ """
146
+ client = get_client()
147
+ discrepancies: list[BidDiscrepancy] = []
148
+
149
+ try:
150
+ with client:
151
+ # Get all enabled campaigns
152
+ with spinner("Scanning enabled campaigns..."):
153
+ campaigns = list(client.campaigns.find(Selector().where("status", "==", "ENABLED")))
154
+
155
+ print_info(f"Found {len(campaigns)} enabled campaigns")
156
+ console.print()
157
+
158
+ # Scan each campaign's ad groups
159
+ for campaign in campaigns:
160
+ with spinner(f"Scanning {campaign.name}..."):
161
+ try:
162
+ ad_groups = list(
163
+ client.campaigns(campaign.id).ad_groups.find(Selector().where("status", "==", "ENABLED"))
164
+ )
165
+ except AppleSearchAdsError:
166
+ # Skip campaigns we can't access
167
+ continue
168
+
169
+ for ag in ad_groups:
170
+ # Get keywords for this ad group
171
+ try:
172
+ keywords = list(client.campaigns(campaign.id).ad_groups(ag.id).keywords.list())
173
+ except AppleSearchAdsError:
174
+ continue
175
+
176
+ if not keywords:
177
+ continue
178
+
179
+ # Calculate keyword bid statistics
180
+ keyword_bids = [Decimal(kw.bid_amount.amount) for kw in keywords if kw.bid_amount]
181
+
182
+ if not keyword_bids:
183
+ continue
184
+
185
+ ad_group_bid = Decimal(ag.default_bid_amount.amount)
186
+ keyword_avg = Decimal(sum(keyword_bids) / len(keyword_bids))
187
+ keyword_min = min(keyword_bids)
188
+ keyword_max = max(keyword_bids)
189
+ currency = ag.default_bid_amount.currency
190
+
191
+ # Check if there's a material discrepancy
192
+ # (keywords are higher than ad group bid)
193
+ if ad_group_bid > 0:
194
+ diff_pct = float((keyword_avg - ad_group_bid) / ad_group_bid * 100)
195
+ if diff_pct >= threshold:
196
+ discrepancies.append(
197
+ BidDiscrepancy(
198
+ campaign_id=campaign.id,
199
+ campaign_name=campaign.name,
200
+ ad_group_id=ag.id,
201
+ ad_group_name=ag.name,
202
+ ad_group_bid=ad_group_bid,
203
+ keyword_avg_bid=keyword_avg,
204
+ keyword_min_bid=keyword_min,
205
+ keyword_max_bid=keyword_max,
206
+ keyword_count=len(keyword_bids),
207
+ currency=currency,
208
+ )
209
+ )
210
+
211
+ if not discrepancies:
212
+ print_success(f"No bid discrepancies found above {threshold}% threshold")
213
+ return
214
+
215
+ # Sort by difference percentage descending
216
+ discrepancies.sort(key=lambda d: d.difference_pct, reverse=True)
217
+
218
+ # Display summary table
219
+ console.print()
220
+ table = Table(
221
+ title=f"Bid Discrepancies Found ({len(discrepancies)} ad groups)",
222
+ show_header=True,
223
+ header_style="header",
224
+ )
225
+ table.add_column("Campaign", style="cyan")
226
+ table.add_column("Ad Group", style="cyan")
227
+ table.add_column("Ad Group Bid", justify="right")
228
+ table.add_column("Keyword Avg", justify="right", style="yellow")
229
+ table.add_column("Diff %", justify="right", style="red")
230
+ table.add_column("Keywords", justify="center")
231
+
232
+ for d in discrepancies:
233
+ table.add_row(
234
+ d.campaign_name[:25] + ("..." if len(d.campaign_name) > 25 else ""),
235
+ d.ad_group_name[:20] + ("..." if len(d.ad_group_name) > 20 else ""),
236
+ _format_bid(d.ad_group_bid, d.currency),
237
+ _format_bid(d.keyword_avg_bid, d.currency),
238
+ f"+{d.difference_pct:.0f}%",
239
+ str(d.keyword_count),
240
+ )
241
+
242
+ console.print(table)
243
+ console.print()
244
+
245
+ if dry_run:
246
+ print_info("Dry run mode - no changes will be made")
247
+ return
248
+
249
+ # Interactive mode - process each discrepancy
250
+ changes_made = 0
251
+ for i, d in enumerate(discrepancies, 1):
252
+ console.rule(f"[bold]{i}/{len(discrepancies)}")
253
+ console.print()
254
+
255
+ # Show details
256
+ console.print(f"[bold]Campaign:[/bold] {d.campaign_name}")
257
+ console.print(f"[bold]Ad Group:[/bold] {d.ad_group_name}")
258
+ console.print()
259
+ console.print(f" Current ad group bid: [dim]{_format_bid(d.ad_group_bid, d.currency)}[/dim]")
260
+ console.print(f" Keyword average bid: [yellow]{_format_bid(d.keyword_avg_bid, d.currency)}[/yellow]")
261
+ min_bid = _format_bid(d.keyword_min_bid, d.currency)
262
+ max_bid = _format_bid(d.keyword_max_bid, d.currency)
263
+ console.print(f" Keyword range: {min_bid} - {max_bid}")
264
+ console.print(f" Difference: [red]+{d.difference_pct:.0f}%[/red]")
265
+ console.print()
266
+
267
+ suggested_bid = round(d.keyword_avg_bid, 2)
268
+
269
+ if auto_fix:
270
+ # Apply suggested change without prompting
271
+ new_bid = suggested_bid
272
+ action = "apply"
273
+ else:
274
+ # Interactive prompt
275
+ console.print(f"[bold]Suggested new bid:[/bold] {_format_bid(suggested_bid, d.currency)}")
276
+ console.print()
277
+
278
+ action = typer.prompt(
279
+ "Action",
280
+ type=str,
281
+ default="apply",
282
+ show_default=True,
283
+ prompt_suffix=" [apply/custom/skip/quit]: ",
284
+ ).lower()
285
+
286
+ if action == "quit" or action == "q":
287
+ print_info("Quitting...")
288
+ break
289
+ elif action == "skip" or action == "s":
290
+ console.print("[dim]Skipped[/dim]")
291
+ console.print()
292
+ continue
293
+ elif action == "custom" or action == "c":
294
+ new_bid_str = typer.prompt(
295
+ f"Enter new bid ({d.currency})",
296
+ type=str,
297
+ )
298
+ try:
299
+ new_bid = Decimal(new_bid_str)
300
+ except Exception:
301
+ print_warning("Invalid bid amount, skipping")
302
+ continue
303
+ elif action == "apply" or action == "a":
304
+ new_bid = suggested_bid
305
+ else:
306
+ print_warning(f"Unknown action '{action}', skipping")
307
+ continue
308
+
309
+ # Apply the change
310
+ with spinner("Updating ad group bid..."):
311
+ client.campaigns(d.campaign_id).ad_groups.update(
312
+ d.ad_group_id,
313
+ data=AdGroupUpdate(
314
+ default_bid_amount=Money(
315
+ amount=str(new_bid),
316
+ currency=d.currency,
317
+ )
318
+ ),
319
+ )
320
+
321
+ print_success(
322
+ f"Updated bid: {_format_bid(d.ad_group_bid, d.currency)} → {_format_bid(new_bid, d.currency)}"
323
+ )
324
+ changes_made += 1
325
+ console.print()
326
+
327
+ # Summary
328
+ console.print()
329
+ if changes_made > 0:
330
+ print_result_panel(
331
+ "Optimization Complete",
332
+ {
333
+ "Discrepancies found": str(len(discrepancies)),
334
+ "Changes made": str(changes_made),
335
+ },
336
+ )
337
+ else:
338
+ print_info("No changes made")
339
+
340
+ except AppleSearchAdsError as e:
341
+ handle_api_error(e)
342
+ raise typer.Exit(1) from None
343
+
344
+
345
+ @dataclass
346
+ class KeywordPlan:
347
+ """Planned keyword for the new campaign."""
348
+
349
+ text: str
350
+ bid: Decimal
351
+ currency: str
352
+ source_count: int # Number of source campaigns this keyword appeared in
353
+ impressions: int = 0 # Total impressions in last 90 days
354
+ source_bids: list[Decimal] = field(default_factory=list)
355
+
356
+
357
+ @dataclass
358
+ class AdGroupPlan:
359
+ """Planned ad group for the new campaign."""
360
+
361
+ name: str
362
+ keyword: KeywordPlan
363
+ negatives: list[str] = field(default_factory=list)
364
+
365
+
366
+ @dataclass
367
+ class CampaignPlan:
368
+ """Plan for the new campaign."""
369
+
370
+ name: str
371
+ country: str
372
+ adam_id: int
373
+ daily_budget: Decimal
374
+ currency: str
375
+ ad_groups: list[AdGroupPlan] = field(default_factory=list)
376
+
377
+
378
+ @dataclass
379
+ class CampaignNameParts:
380
+ """Parsed campaign name parts."""
381
+
382
+ app_name: str
383
+ country: str
384
+ campaign_type: str # Generic, Competitor, Brand
385
+ match_type: str # EM (Exact Match), BM (Broad Match)
386
+ original: str
387
+
388
+ @classmethod
389
+ def parse(cls, name: str) -> "CampaignNameParts | None":
390
+ """Parse campaign name in format: App Name - Country - Type - Match.
391
+
392
+ Examples:
393
+ 'Chippy Tools - US - Generic - Exact Match' -> parts
394
+ 'Concrete Tools - AU - Competitor - EM' -> parts
395
+ """
396
+ parts = [p.strip() for p in name.split(" - ")]
397
+ if len(parts) < 4:
398
+ return None
399
+
400
+ # Last part is match type
401
+ match_type_raw = parts[-1].upper()
402
+ if match_type_raw in ("EM", "EXACT MATCH"):
403
+ match_type = "EM"
404
+ elif match_type_raw in ("BM", "BROAD MATCH", "SM", "SEARCH MATCH"):
405
+ match_type = "BM"
406
+ else:
407
+ return None
408
+
409
+ # Second to last is campaign type
410
+ campaign_type = parts[-2]
411
+
412
+ # Second part is country (could be multi-letter code)
413
+ country = parts[1].upper()
414
+
415
+ # Everything before country is app name
416
+ app_name = parts[0]
417
+
418
+ return cls(
419
+ app_name=app_name,
420
+ country=country,
421
+ campaign_type=campaign_type,
422
+ match_type=match_type,
423
+ original=name,
424
+ )
425
+
426
+ def with_country(self, new_country: str) -> str:
427
+ """Generate new campaign name with different country."""
428
+ match_type_full = "Exact Match" if self.match_type == "EM" else "Broad Match"
429
+ return f"{self.app_name} - {new_country} - {self.campaign_type} - {match_type_full}"
430
+
431
+
432
+ def _select_campaigns_interactive(
433
+ campaigns: list[Any],
434
+ campaign_type_filter: str | None = None,
435
+ match_type_filter: str | None = None,
436
+ ) -> list[Any]:
437
+ """Interactive campaign selection with checkboxes.
438
+
439
+ Args:
440
+ campaigns: List of Campaign objects
441
+ campaign_type_filter: Filter by type (Generic, Competitor, Brand)
442
+ match_type_filter: Filter by match type (EM, BM)
443
+
444
+ Returns:
445
+ List of selected Campaign objects
446
+ """
447
+ # Parse and filter campaigns
448
+ parsed_campaigns: list[tuple[Any, CampaignNameParts]] = []
449
+
450
+ for c in campaigns:
451
+ parsed = CampaignNameParts.parse(c.name)
452
+ if parsed:
453
+ # Apply filters
454
+ if campaign_type_filter and parsed.campaign_type.lower() != campaign_type_filter.lower():
455
+ continue
456
+ if match_type_filter and parsed.match_type != match_type_filter.upper():
457
+ continue
458
+ parsed_campaigns.append((c, parsed))
459
+
460
+ if not parsed_campaigns:
461
+ return []
462
+
463
+ # Group by app name for easier selection
464
+ apps: dict[str, list[tuple[Any, CampaignNameParts]]] = defaultdict(list)
465
+ for c, parsed in parsed_campaigns:
466
+ apps[parsed.app_name].append((c, parsed))
467
+
468
+ # Display selection table
469
+ console.print()
470
+ console.print("[bold]Available campaigns:[/bold]")
471
+ console.print()
472
+
473
+ table = Table(show_header=True, header_style="bold")
474
+ table.add_column("#", justify="right", style="dim")
475
+ table.add_column("App")
476
+ table.add_column("Country")
477
+ table.add_column("Type")
478
+ table.add_column("Match")
479
+ table.add_column("Status")
480
+ table.add_column("ID", style="dim")
481
+
482
+ campaign_list: list[tuple[Any, CampaignNameParts]] = []
483
+ idx = 1
484
+ sorted_apps = sorted(apps.keys())
485
+ for app_idx, app_name in enumerate(sorted_apps):
486
+ app_campaigns = sorted(apps[app_name], key=lambda x: (x[1].campaign_type, x[1].country))
487
+ last_type = None
488
+ for _camp_idx, (c, parsed) in enumerate(app_campaigns):
489
+ # Add a dim divider row between campaign types (within same app)
490
+ if last_type is not None and parsed.campaign_type != last_type:
491
+ table.add_row(
492
+ "[dim]·[/dim]",
493
+ "[dim]·[/dim]",
494
+ "[dim]·[/dim]",
495
+ "[dim]·[/dim]",
496
+ "[dim]·[/dim]",
497
+ "[dim]·[/dim]",
498
+ "[dim]·[/dim]",
499
+ style="dim",
500
+ )
501
+
502
+ campaign_list.append((c, parsed))
503
+ status_color = "green" if enum_value(c.status) == "ENABLED" else "yellow"
504
+
505
+ table.add_row(
506
+ str(idx),
507
+ parsed.app_name[:20],
508
+ parsed.country,
509
+ parsed.campaign_type,
510
+ parsed.match_type,
511
+ f"[{status_color}]{enum_value(c.status)}[/{status_color}]",
512
+ str(c.id),
513
+ )
514
+ idx += 1
515
+ last_type = parsed.campaign_type
516
+ # Add section divider after each app group (except the last one)
517
+ if app_idx < len(sorted_apps) - 1:
518
+ table.add_section()
519
+
520
+ console.print(table)
521
+ console.print()
522
+
523
+ # Get selection
524
+ console.print("[dim]Enter campaign numbers separated by commas, ranges (1-3), or 'all'[/dim]")
525
+ selection = typer.prompt("Select campaigns", default="all")
526
+
527
+ if selection.lower() == "all":
528
+ return [c for c, _ in campaign_list]
529
+
530
+ # Parse selection (e.g., "1,2,5-7,10")
531
+ selected_indices: set[int] = set()
532
+ for part in selection.split(","):
533
+ part = part.strip()
534
+ if "-" in part:
535
+ try:
536
+ start, end = part.split("-")
537
+ selected_indices.update(range(int(start), int(end) + 1))
538
+ except ValueError:
539
+ continue
540
+ else:
541
+ try:
542
+ selected_indices.add(int(part))
543
+ except ValueError:
544
+ continue
545
+
546
+ return [c for i, (c, _) in enumerate(campaign_list, 1) if i in selected_indices]
547
+
548
+
549
+ @app.command("expand")
550
+ def expand_campaign(
551
+ source_campaigns: Annotated[
552
+ list[int] | None,
553
+ typer.Argument(help="Campaign IDs to use as source (interactive if omitted)"),
554
+ ] = None,
555
+ target_country: Annotated[
556
+ str | None,
557
+ typer.Option("--country", "-c", help="Target country code (e.g., CA, DE, FR)"),
558
+ ] = None,
559
+ campaign_type: Annotated[
560
+ str | None,
561
+ typer.Option("--type", "-t", help="Filter by campaign type (Generic, Competitor, Brand)"),
562
+ ] = None,
563
+ match_type: Annotated[
564
+ str | None,
565
+ typer.Option("--match", "-m", help="Filter by match type (EM or BM)"),
566
+ ] = None,
567
+ campaign_name: Annotated[
568
+ str | None,
569
+ typer.Option("--name", "-n", help="Name for the new campaign (auto-generated if not provided)"),
570
+ ] = None,
571
+ daily_budget: Annotated[
572
+ float | None,
573
+ typer.Option("--budget", "-b", help="Daily budget (copies from source if not provided)"),
574
+ ] = None,
575
+ dry_run: Annotated[
576
+ bool,
577
+ typer.Option("--dry-run", help="Preview the plan without creating anything"),
578
+ ] = False,
579
+ skip_negatives: Annotated[
580
+ bool,
581
+ typer.Option("--skip-negatives", help="Skip creating cross-negative keywords"),
582
+ ] = False,
583
+ paused: Annotated[
584
+ bool,
585
+ typer.Option("--paused", "-p", help="Create campaign in PAUSED state"),
586
+ ] = False,
587
+ ) -> None:
588
+ """Expand campaigns to a new market.
589
+
590
+ Creates a new SKAG (Single Keyword Ad Group) campaign in a target market
591
+ based on one or more source campaigns. Keywords are extracted from all
592
+ source campaigns and bids are averaged if a keyword appears in multiple sources.
593
+
594
+ Campaign naming follows: App Name - Country - Type - Match Type
595
+ Where Match Type is EM (Exact Match) or BM (Broad Match).
596
+
597
+ Structure created:
598
+ - One ad group per keyword (named "Exact - {keyword}")
599
+ - Each ad group contains one exact match keyword
600
+ - Cross-negatives: each keyword is added as exact negative to all other ad groups
601
+
602
+ Examples:
603
+ # Interactive mode - select campaigns and enter country
604
+ asa optimize expand
605
+
606
+ # Interactive with filters
607
+ asa optimize expand --type Generic --match EM
608
+
609
+ # Non-interactive with campaign IDs
610
+ asa optimize expand 123456789 --country CA
611
+
612
+ # Multiple campaigns (bids will be averaged)
613
+ asa optimize expand 123 456 789 --country CA
614
+
615
+ # Custom name and budget
616
+ asa optimize expand 123 --country DE --name "My App - DE - Generic" --budget 50
617
+
618
+ # Preview without creating
619
+ asa optimize expand --country CA --dry-run
620
+
621
+ # Create paused (for review before enabling)
622
+ asa optimize expand --country CA --paused
623
+ """
624
+ client = get_client()
625
+
626
+ try:
627
+ with client:
628
+ # Step 1: Get source campaigns (interactive or from arguments)
629
+ source_campaign_data = []
630
+
631
+ if source_campaigns:
632
+ # Non-interactive: load specified campaign IDs
633
+ with spinner("Loading source campaigns..."):
634
+ for campaign_id in source_campaigns:
635
+ try:
636
+ campaign = client.campaigns.get(campaign_id)
637
+ source_campaign_data.append(campaign)
638
+ except AppleSearchAdsError as e:
639
+ print_error("Error", f"Could not load campaign {campaign_id}: {e.message}")
640
+ raise typer.Exit(1) from None
641
+ else:
642
+ # Interactive: show all campaigns and let user select
643
+ with spinner("Loading campaigns..."):
644
+ all_campaigns = list(client.campaigns.list())
645
+
646
+ source_campaign_data = _select_campaigns_interactive(
647
+ all_campaigns,
648
+ campaign_type_filter=campaign_type,
649
+ match_type_filter=match_type,
650
+ )
651
+
652
+ if not source_campaign_data:
653
+ print_error("Error", "No campaigns selected")
654
+ raise typer.Exit(1)
655
+
656
+ # Display selected source campaigns
657
+ console.print()
658
+ print_info(f"Selected source campaigns ({len(source_campaign_data)}):")
659
+ for c in source_campaign_data:
660
+ countries = ", ".join(c.countries_or_regions[:3])
661
+ if len(c.countries_or_regions) > 3:
662
+ countries += "..."
663
+ console.print(f" • {c.name} ({countries})")
664
+ console.print()
665
+
666
+ # Step 2: Get target country (interactive if not provided)
667
+ if not target_country:
668
+ target_country = typer.prompt("Target country code (e.g., CA, DE, FR)")
669
+ target_country = target_country.upper()
670
+
671
+ # Get adam_id and currency from first source
672
+ adam_id = source_campaign_data[0].adam_id
673
+ currency = (
674
+ source_campaign_data[0].daily_budget_amount.currency
675
+ if source_campaign_data[0].daily_budget_amount
676
+ else "USD"
677
+ )
678
+
679
+ # Step 2: Extract keywords with impressions from last 90 days
680
+ # Use reports API to get keywords that have actually had impressions
681
+ keyword_bids: dict[str, list[Decimal]] = defaultdict(list)
682
+ keyword_impressions: dict[str, int] = defaultdict(int)
683
+
684
+ end_date = date.today()
685
+ start_date = end_date - timedelta(days=90)
686
+
687
+ for campaign in source_campaign_data:
688
+ with spinner(f"Fetching keyword performance for {campaign.name} (90 days)..."):
689
+ try:
690
+ # Get keyword report for last 90 days
691
+ report = client.reports.keywords(
692
+ campaign_id=campaign.id,
693
+ start_date=start_date,
694
+ end_date=end_date,
695
+ granularity=GranularityType.DAILY,
696
+ )
697
+
698
+ for row in report.row:
699
+ if row.metadata.keyword and row.total:
700
+ kw_text = row.metadata.keyword.lower()
701
+ impressions = row.total.impressions
702
+
703
+ # Only include keywords with impressions
704
+ if impressions > 0:
705
+ keyword_impressions[kw_text] += impressions
706
+
707
+ # Get bid from report metadata or use a default
708
+ if row.metadata.bid_amount:
709
+ keyword_bids[kw_text].append(Decimal(row.metadata.bid_amount.amount))
710
+
711
+ except AppleSearchAdsError as e:
712
+ print_warning(f"Could not get report for {campaign.name}: {e.message}")
713
+ continue
714
+
715
+ # Filter to only keywords that had impressions
716
+ active_keywords = set(keyword_impressions.keys())
717
+ keyword_bids = {k: v for k, v in keyword_bids.items() if k in active_keywords}
718
+
719
+ if not keyword_bids:
720
+ print_error("Error", "No keywords with impressions found in last 90 days")
721
+ raise typer.Exit(1)
722
+
723
+ total_impressions = sum(keyword_impressions.values())
724
+ print_info(f"Found {len(keyword_bids)} keywords with {total_impressions:,} impressions in last 90 days")
725
+
726
+ # Step 3: Calculate average bids
727
+ keyword_plans: list[KeywordPlan] = []
728
+ for text, bids in keyword_bids.items():
729
+ avg_bid = Decimal(sum(bids) / len(bids))
730
+ keyword_plans.append(
731
+ KeywordPlan(
732
+ text=text,
733
+ bid=round(avg_bid, 2),
734
+ currency=currency,
735
+ source_count=len(bids),
736
+ impressions=keyword_impressions[text],
737
+ source_bids=bids,
738
+ )
739
+ )
740
+
741
+ # Sort by impressions descending (most popular keywords first)
742
+ keyword_plans.sort(key=lambda k: k.impressions, reverse=True)
743
+ console.print()
744
+
745
+ # Step 4: Build campaign plan
746
+ # Generate campaign name using parsed naming convention
747
+ if campaign_name:
748
+ plan_name = campaign_name
749
+ else:
750
+ # Try to parse first source campaign name and generate new name
751
+ parsed = CampaignNameParts.parse(source_campaign_data[0].name)
752
+ if parsed:
753
+ plan_name = parsed.with_country(target_country)
754
+ else:
755
+ # Fallback for campaigns that don't match the pattern
756
+ plan_name = f"{source_campaign_data[0].name} - {target_country}"
757
+
758
+ # Use provided budget or average from sources
759
+ if daily_budget is not None:
760
+ plan_budget = Decimal(str(daily_budget))
761
+ else:
762
+ source_budgets = [
763
+ Decimal(c.daily_budget_amount.amount) for c in source_campaign_data if c.daily_budget_amount
764
+ ]
765
+ if source_budgets:
766
+ plan_budget = Decimal(sum(source_budgets) / len(source_budgets))
767
+ else:
768
+ plan_budget = Decimal("100")
769
+
770
+ # Create ad group plans (SKAG structure)
771
+ ad_group_plans: list[AdGroupPlan] = []
772
+ all_keywords = [kp.text for kp in keyword_plans]
773
+
774
+ for kp in keyword_plans:
775
+ # Ad group name: "Exact - {keyword}"
776
+ ag_name = f"Exact - {kp.text.title()}"
777
+ if len(ag_name) > 200:
778
+ ag_name = ag_name[:197] + "..."
779
+
780
+ # Cross-negatives: all other keywords
781
+ negatives = [k for k in all_keywords if k != kp.text] if not skip_negatives else []
782
+
783
+ ad_group_plans.append(
784
+ AdGroupPlan(
785
+ name=ag_name,
786
+ keyword=kp,
787
+ negatives=negatives,
788
+ )
789
+ )
790
+
791
+ campaign_plan = CampaignPlan(
792
+ name=plan_name,
793
+ country=target_country.upper(),
794
+ adam_id=adam_id,
795
+ daily_budget=plan_budget,
796
+ currency=currency,
797
+ ad_groups=ad_group_plans,
798
+ )
799
+
800
+ # Step 5: Display plan
801
+ console.print()
802
+ console.rule("[bold]Campaign Plan")
803
+ console.print()
804
+
805
+ # Campaign overview
806
+ table = Table(show_header=False, box=None)
807
+ table.add_column("Label", style="bold")
808
+ table.add_column("Value")
809
+ table.add_row("Campaign Name", campaign_plan.name)
810
+ table.add_row("Target Country", campaign_plan.country)
811
+ table.add_row("Daily Budget", f"{campaign_plan.daily_budget:.2f} {campaign_plan.currency}")
812
+ table.add_row("Ad Groups", str(len(campaign_plan.ad_groups)))
813
+ table.add_row("Status", "PAUSED" if paused else "ENABLED")
814
+ if not skip_negatives:
815
+ total_negatives = sum(len(ag.negatives) for ag in campaign_plan.ad_groups)
816
+ table.add_row("Cross-Negatives", str(total_negatives))
817
+ console.print(table)
818
+ console.print()
819
+
820
+ # Ad group details
821
+ console.print("[bold]Ad Groups & Keywords (sorted by impressions):[/bold]")
822
+ console.print()
823
+
824
+ ag_table = Table(show_header=True, header_style="bold")
825
+ ag_table.add_column("#", justify="right", style="dim")
826
+ ag_table.add_column("Ad Group")
827
+ ag_table.add_column("Keyword")
828
+ ag_table.add_column("Bid", justify="right")
829
+ ag_table.add_column("Impr (90d)", justify="right")
830
+
831
+ for i, ag in enumerate(campaign_plan.ad_groups[:20], 1): # Show first 20
832
+ ag_table.add_row(
833
+ str(i),
834
+ ag.name[:30] + ("..." if len(ag.name) > 30 else ""),
835
+ ag.keyword.text,
836
+ f"{ag.keyword.bid:.2f} {ag.keyword.currency}",
837
+ f"{ag.keyword.impressions:,}",
838
+ )
839
+
840
+ if len(campaign_plan.ad_groups) > 20:
841
+ ag_table.add_row(
842
+ "...",
843
+ f"[dim]... and {len(campaign_plan.ad_groups) - 20} more[/dim]",
844
+ "",
845
+ "",
846
+ "",
847
+ )
848
+
849
+ console.print(ag_table)
850
+ console.print()
851
+
852
+ if dry_run:
853
+ print_info("Dry run mode - no changes will be made")
854
+ return
855
+
856
+ # Step 6: Confirm and create
857
+ if not typer.confirm("Create this campaign?", default=True):
858
+ print_info("Cancelled")
859
+ return
860
+
861
+ console.print()
862
+
863
+ # Create campaign
864
+ with spinner("Creating campaign..."):
865
+ new_campaign = client.campaigns.create(
866
+ CampaignCreate(
867
+ name=campaign_plan.name,
868
+ adam_id=campaign_plan.adam_id,
869
+ countries_or_regions=[campaign_plan.country],
870
+ daily_budget_amount=Money(
871
+ amount=str(campaign_plan.daily_budget),
872
+ currency=campaign_plan.currency,
873
+ ),
874
+ supply_sources=[CampaignSupplySource.APPSTORE_SEARCH_RESULTS],
875
+ status=CampaignStatus.PAUSED if paused else CampaignStatus.ENABLED,
876
+ )
877
+ )
878
+
879
+ print_success(f"Created campaign: {new_campaign.name} (ID: {new_campaign.id})")
880
+
881
+ # Wait for campaign to be available
882
+ wait_for_resource(
883
+ lambda: client.campaigns.get(new_campaign.id),
884
+ max_attempts=10,
885
+ delay=0.5,
886
+ )
887
+
888
+ # Create ad groups with keywords and negatives
889
+ created_ad_groups = 0
890
+ created_keywords = 0
891
+ created_negatives = 0
892
+
893
+ print_info(f"Creating {len(campaign_plan.ad_groups)} ad groups...")
894
+
895
+ for i, ag_plan in enumerate(campaign_plan.ad_groups, 1):
896
+ # Create ad group
897
+ with spinner(f"[{i}/{len(campaign_plan.ad_groups)}] Creating ad group: {ag_plan.name[:30]}..."):
898
+ new_ag = client.campaigns(new_campaign.id).ad_groups.create(
899
+ AdGroupCreate(
900
+ name=ag_plan.name,
901
+ default_bid_amount=Money(
902
+ amount=str(ag_plan.keyword.bid),
903
+ currency=ag_plan.keyword.currency,
904
+ ),
905
+ automated_keywords_opt_in=False,
906
+ )
907
+ )
908
+ created_ad_groups += 1
909
+
910
+ print_success(f"[{i}/{len(campaign_plan.ad_groups)}] Created ad group: {new_ag.name} (ID: {new_ag.id})")
911
+
912
+ # Create keyword (must use bulk endpoint)
913
+ client.campaigns(new_campaign.id).ad_groups(new_ag.id).keywords.create_bulk(
914
+ [
915
+ KeywordCreate(
916
+ text=ag_plan.keyword.text,
917
+ match_type=KeywordMatchType.EXACT,
918
+ bid_amount=Money(
919
+ amount=str(ag_plan.keyword.bid),
920
+ currency=ag_plan.keyword.currency,
921
+ ),
922
+ )
923
+ ]
924
+ )
925
+ created_keywords += 1
926
+
927
+ # Create negative keywords (bulk for efficiency)
928
+ if ag_plan.negatives:
929
+ try:
930
+ neg_keywords = [
931
+ NegativeKeywordCreate(
932
+ text=neg_text,
933
+ match_type=KeywordMatchType.EXACT,
934
+ )
935
+ for neg_text in ag_plan.negatives
936
+ ]
937
+ neg_resource = client.campaigns(new_campaign.id).ad_groups(new_ag.id).negative_keywords
938
+ result = neg_resource.create_bulk(neg_keywords)
939
+ created_negatives += len(result.data)
940
+ except AppleSearchAdsError:
941
+ # Skip if negative keyword creation fails
942
+ pass
943
+
944
+ # Summary
945
+ console.print()
946
+ print_result_panel(
947
+ "Campaign Created Successfully",
948
+ {
949
+ "Campaign ID": str(new_campaign.id),
950
+ "Campaign Name": new_campaign.name,
951
+ "Target Country": campaign_plan.country,
952
+ "Ad Groups": str(created_ad_groups),
953
+ "Keywords": str(created_keywords),
954
+ "Negative Keywords": str(created_negatives),
955
+ "Status": "PAUSED" if paused else "ENABLED",
956
+ },
957
+ )
958
+
959
+ except AppleSearchAdsError as e:
960
+ handle_api_error(e)
961
+ raise typer.Exit(1) from None