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/__init__.py +15 -0
- asa_api_cli/ad_groups.py +270 -0
- asa_api_cli/auth.py +186 -0
- asa_api_cli/brand.py +809 -0
- asa_api_cli/campaigns.py +371 -0
- asa_api_cli/keywords.py +514 -0
- asa_api_cli/main.py +76 -0
- asa_api_cli/optimize.py +961 -0
- asa_api_cli/reports.py +440 -0
- asa_api_cli/utils.py +503 -0
- asa_api_cli-0.1.0.dist-info/METADATA +196 -0
- asa_api_cli-0.1.0.dist-info/RECORD +15 -0
- asa_api_cli-0.1.0.dist-info/WHEEL +4 -0
- asa_api_cli-0.1.0.dist-info/entry_points.txt +2 -0
- asa_api_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
asa_api_cli/optimize.py
ADDED
|
@@ -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
|