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/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
|