amazon-ads-cli 0.1.2__tar.gz → 0.1.4__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stellar Aether
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-ads-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: CLI tool for Amazon Advertising API v3
5
5
  Home-page: https://github.com/stellaraether/amazon-ads-cli
6
6
  Author: Lunan Li
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.9
14
14
  Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Requires-Python: >=3.8
17
+ License-File: LICENSE
17
18
  Requires-Dist: click>=8.0
18
19
  Requires-Dist: python-amazon-ad-api>=0.8.0
19
20
  Requires-Dist: requests>=2.27.0
@@ -21,6 +22,7 @@ Dynamic: author
21
22
  Dynamic: author-email
22
23
  Dynamic: classifier
23
24
  Dynamic: home-page
25
+ Dynamic: license-file
24
26
  Dynamic: requires-dist
25
27
  Dynamic: requires-python
26
28
  Dynamic: summary
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m amazon_ads_cli."""
2
+
3
+ from amazon_ads_cli.main import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,825 @@
1
+ #!/usr/bin/env python3
2
+ """Amazon Ads CLI - Command line interface for Amazon Advertising API v3."""
3
+
4
+ import json
5
+ import os
6
+ from datetime import datetime, timedelta
7
+
8
+ import click
9
+ import yaml
10
+ from ad_api.api import reports, sponsored_products
11
+ from ad_api.base import Marketplaces
12
+
13
+ DEFAULT_CREDENTIALS_PATH = os.path.expanduser("~/.config/python-ad-api/credentials.yml")
14
+
15
+
16
+ def _check_path():
17
+ """Check if the CLI is accessible in PATH and warn if not."""
18
+ import shutil
19
+ import sys
20
+
21
+ if not shutil.which("amz-ads"):
22
+ print(
23
+ "\n⚠️ Note: 'amz-ads' is not in your PATH.",
24
+ file=sys.stderr,
25
+ )
26
+ print(
27
+ " You can still use: python3 -m amazon_ads_cli",
28
+ file=sys.stderr,
29
+ )
30
+ print(
31
+ " To add to PATH, add this to your shell config:",
32
+ file=sys.stderr,
33
+ )
34
+ print(
35
+ f' export PATH="{sys.prefix}/bin:$PATH"',
36
+ file=sys.stderr,
37
+ )
38
+ print("", file=sys.stderr)
39
+
40
+
41
+ @click.group()
42
+ @click.option("--profile", "-p", default="default", help="Credential profile")
43
+ @click.pass_context
44
+ def cli(ctx, profile):
45
+ """Amazon Ads CLI - Manage campaigns, keywords, and reports."""
46
+ _check_path()
47
+ ctx.ensure_object(dict)
48
+ ctx.obj["profile"] = profile
49
+
50
+
51
+ @cli.group()
52
+ def auth():
53
+ """Authentication commands."""
54
+ pass
55
+
56
+
57
+ @auth.command("setup")
58
+ @click.option(
59
+ "--path", default=DEFAULT_CREDENTIALS_PATH, help="Path to save credentials"
60
+ )
61
+ @click.pass_context
62
+ def auth_setup(ctx, path):
63
+ """Interactive setup for Amazon Ads API credentials."""
64
+ click.echo("🔐 Amazon Ads API Credential Setup")
65
+ click.echo("=" * 50)
66
+ click.echo()
67
+ click.echo("You'll need the following from your Amazon Developer account:")
68
+ click.echo(" 1. Refresh Token (from LWA authorization)")
69
+ click.echo(" 2. Client ID (from your app registration)")
70
+ click.echo(" 3. Client Secret (from your app registration)")
71
+ click.echo(" 4. Profile ID (your Amazon Ads account ID)")
72
+ click.echo()
73
+
74
+ profile = click.prompt("Profile name", default="default")
75
+ refresh_token = click.prompt("Refresh token", hide_input=True)
76
+ client_id = click.prompt("Client ID")
77
+ client_secret = click.prompt("Client secret", hide_input=True)
78
+ profile_id = click.prompt("Profile ID (numeric)")
79
+
80
+ credentials = {
81
+ "version": "1.0",
82
+ profile: {
83
+ "refresh_token": refresh_token,
84
+ "client_id": client_id,
85
+ "client_secret": client_secret,
86
+ "profile_id": profile_id,
87
+ },
88
+ }
89
+
90
+ # Merge with existing if present
91
+ if os.path.exists(path):
92
+ try:
93
+ with open(path, "r") as f:
94
+ existing = yaml.safe_load(f) or {}
95
+ existing[profile] = credentials[profile]
96
+ credentials = existing
97
+ click.echo(f"\n📝 Merged with existing credentials at {path}")
98
+ except Exception as e:
99
+ click.echo(f"⚠️ Could not read existing file: {e}")
100
+
101
+ os.makedirs(os.path.dirname(path), exist_ok=True)
102
+ with open(path, "w") as f:
103
+ yaml.dump(credentials, f, default_flow_style=False, sort_keys=False)
104
+
105
+ click.echo(f"✅ Credentials saved to {path}")
106
+ click.echo(f" Profile: {profile}")
107
+ click.echo(f" Profile ID: {profile_id}")
108
+ click.echo()
109
+ click.echo(
110
+ "You can now use: python -m amazon_ads_cli.main --profile {profile} campaigns list"
111
+ )
112
+
113
+
114
+ @auth.command("show")
115
+ @click.option(
116
+ "--path", default=DEFAULT_CREDENTIALS_PATH, help="Path to credentials file"
117
+ )
118
+ @click.pass_context
119
+ def auth_show(ctx, path):
120
+ """Show configured profiles (without secrets)."""
121
+ if not os.path.exists(path):
122
+ click.echo(f"❌ No credentials file found at {path}")
123
+ click.echo("Run: python -m amazon_ads_cli.main auth setup")
124
+ return
125
+
126
+ with open(path, "r") as f:
127
+ creds = yaml.safe_load(f) or {}
128
+
129
+ click.echo(f"\n📄 Credentials file: {path}")
130
+ click.echo("-" * 40)
131
+
132
+ for profile, data in creds.items():
133
+ if profile == "version":
134
+ continue
135
+ click.echo(f"Profile: {profile}")
136
+ click.echo(f" Client ID: {data.get('client_id', 'N/A')[:20]}...")
137
+ click.echo(f" Profile ID: {data.get('profile_id', 'N/A')}")
138
+ click.echo()
139
+
140
+
141
+ @cli.group()
142
+ def campaigns():
143
+ """Campaign management commands."""
144
+ pass
145
+
146
+
147
+ @campaigns.command("list")
148
+ @click.pass_context
149
+ def list_campaigns(ctx):
150
+ """List all campaigns."""
151
+ result = sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).list_campaigns(
152
+ body={}
153
+ )
154
+ campaigns = result.payload.get("campaigns", [])
155
+
156
+ click.echo(f"\n{'ID':<20} {'Campaign':<28} {'State':<10} {'Budget':<10} {'Type'}")
157
+ click.echo("-" * 85)
158
+ for camp in campaigns:
159
+ cid = camp["campaignId"][:18]
160
+ name = camp["name"][:26]
161
+ state = camp["state"]
162
+ budget = f"${camp['budget']['budget']}"
163
+ ctype = camp.get("targetingType", "N/A")
164
+ click.echo(f"{cid:<20} {name:<28} {state:<10} {budget:<10} {ctype}")
165
+
166
+
167
+ @campaigns.command("show")
168
+ @click.argument("campaign-id")
169
+ @click.pass_context
170
+ def show_campaign(ctx, campaign_id):
171
+ """Show full details for a campaign."""
172
+ result = sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).list_campaigns(
173
+ body={"campaignIdFilter": {"include": [campaign_id]}}
174
+ )
175
+ campaigns = result.payload.get("campaigns", [])
176
+ if not campaigns:
177
+ click.echo(f"❌ Campaign {campaign_id} not found")
178
+ return
179
+
180
+ camp = campaigns[0]
181
+ click.echo(f"\n📋 Campaign: {camp['name']}")
182
+ click.echo(f" ID: {camp['campaignId']}")
183
+ click.echo(f" State: {camp['state']}")
184
+ click.echo(
185
+ f" Budget: ${camp['budget']['budget']}/{camp['budget']['budgetType'].lower()}"
186
+ )
187
+ click.echo(f" Type: {camp.get('targetingType', 'N/A')}")
188
+ click.echo(f" Start: {camp.get('startDate', 'N/A')}")
189
+ click.echo(f" End: {camp.get('endDate', 'N/A') or 'No end date'}")
190
+
191
+
192
+ @campaigns.command("pause")
193
+ @click.argument("campaign-id")
194
+ @click.pass_context
195
+ def pause_campaign(ctx, campaign_id):
196
+ """Pause a campaign."""
197
+ try:
198
+ sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
199
+ body={"campaigns": [{"campaignId": campaign_id, "state": "PAUSED"}]}
200
+ )
201
+ click.echo(f"✅ Campaign {campaign_id} paused")
202
+ except Exception as e:
203
+ click.echo(f"❌ Error: {e}")
204
+
205
+
206
+ @campaigns.command("enable")
207
+ @click.argument("campaign-id")
208
+ @click.pass_context
209
+ def enable_campaign(ctx, campaign_id):
210
+ """Enable a campaign."""
211
+ try:
212
+ sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
213
+ body={"campaigns": [{"campaignId": campaign_id, "state": "ENABLED"}]}
214
+ )
215
+ click.echo(f"✅ Campaign {campaign_id} enabled")
216
+ except Exception as e:
217
+ click.echo(f"❌ Error: {e}")
218
+
219
+
220
+ @campaigns.command("budget")
221
+ @click.argument("campaign-id")
222
+ @click.argument("amount", type=float)
223
+ @click.pass_context
224
+ def set_budget(ctx, campaign_id, amount):
225
+ """Set campaign daily budget."""
226
+ try:
227
+ sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
228
+ body={
229
+ "campaigns": [
230
+ {
231
+ "campaignId": campaign_id,
232
+ "budget": {"budget": amount, "budgetType": "DAILY"},
233
+ }
234
+ ]
235
+ }
236
+ )
237
+ click.echo(f"✅ Campaign {campaign_id} budget set to ${amount}/day")
238
+ except Exception as e:
239
+ click.echo(f"❌ Error: {e}")
240
+
241
+
242
+ @cli.group()
243
+ def adgroups():
244
+ """Ad group management commands."""
245
+ pass
246
+
247
+
248
+ @adgroups.command("list")
249
+ @click.option("--campaign-id", help="Filter by campaign ID")
250
+ @click.pass_context
251
+ def list_adgroups(ctx, campaign_id):
252
+ """List all ad groups."""
253
+ body = {}
254
+ if campaign_id:
255
+ body["campaignIdFilter"] = {"include": [campaign_id]}
256
+
257
+ result = sponsored_products.AdGroupsV3(marketplace=Marketplaces.NA).list_ad_groups(
258
+ body=body
259
+ )
260
+ ad_groups = result.payload.get("adGroups", [])
261
+
262
+ click.echo(f"\n{'ID':<20} {'Campaign ID':<20} {'Name':<30} {'State'}")
263
+ click.echo("-" * 85)
264
+ for ag in ad_groups:
265
+ ag_id = ag["adGroupId"][:18]
266
+ camp_id = ag["campaignId"][:18]
267
+ name = ag["name"][:28]
268
+ state = ag["state"]
269
+ click.echo(f"{ag_id:<20} {camp_id:<20} {name:<30} {state}")
270
+
271
+
272
+ @cli.group()
273
+ def keywords():
274
+ """Keyword management commands."""
275
+ pass
276
+
277
+
278
+ @keywords.command("list")
279
+ @click.argument("campaign-id")
280
+ @click.pass_context
281
+ def list_keywords(ctx, campaign_id):
282
+ """List keywords for a campaign."""
283
+ result = sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).list_keywords(
284
+ body={}
285
+ )
286
+ keywords = [
287
+ k
288
+ for k in result.payload.get("keywords", [])
289
+ if k.get("campaignId") == campaign_id
290
+ ]
291
+
292
+ click.echo(f"\n{'Keyword':<35} {'Match':<10} {'Bid':<8} {'State'}")
293
+ click.echo("-" * 70)
294
+ for kw in keywords:
295
+ text = kw["keywordText"][:33]
296
+ match = kw["matchType"]
297
+ bid = f"${kw['bid']}"
298
+ state = kw["state"]
299
+ click.echo(f"{text:<35} {match:<10} {bid:<8} {state}")
300
+
301
+
302
+ @keywords.command("list-all")
303
+ @click.pass_context
304
+ def list_all_keywords(ctx):
305
+ """List all keywords across all campaigns."""
306
+ result = sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).list_keywords(
307
+ body={}
308
+ )
309
+ keywords = result.payload.get("keywords", [])
310
+
311
+ click.echo(
312
+ f"\n{'Campaign ID':<20} {'Keyword':<35} {'Match':<10} {'Bid':<8} {'State'}"
313
+ )
314
+ click.echo("-" * 90)
315
+ for kw in keywords:
316
+ camp_id = kw.get("campaignId", "N/A")[:18]
317
+ text = kw["keywordText"][:33]
318
+ match = kw["matchType"]
319
+ bid = f"${kw['bid']}"
320
+ state = kw["state"]
321
+ click.echo(f"{camp_id:<20} {text:<35} {match:<10} {bid:<8} {state}")
322
+
323
+
324
+ @keywords.command("add")
325
+ @click.argument("campaign-id")
326
+ @click.argument("ad-group-id")
327
+ @click.argument("keyword-text")
328
+ @click.option("--match-type", default="EXACT", help="Match type: EXACT, PHRASE, BROAD")
329
+ @click.option("--bid", default=1.0, help="Bid amount")
330
+ @click.pass_context
331
+ def add_keyword(ctx, campaign_id, ad_group_id, keyword_text, match_type, bid):
332
+ """Add a keyword to a campaign."""
333
+ try:
334
+ sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).create_keyword(
335
+ body={
336
+ "keywords": [
337
+ {
338
+ "campaignId": campaign_id,
339
+ "adGroupId": ad_group_id,
340
+ "keywordText": keyword_text,
341
+ "matchType": match_type,
342
+ "bid": bid,
343
+ "state": "ENABLED",
344
+ }
345
+ ]
346
+ }
347
+ )
348
+ click.echo(f"✅ Added keyword: {keyword_text} ({match_type}) - ${bid}")
349
+ except Exception as e:
350
+ click.echo(f"❌ Error: {e}")
351
+
352
+
353
+ @keywords.command("bid")
354
+ @click.argument("keyword-id")
355
+ @click.argument("amount", type=float)
356
+ @click.pass_context
357
+ def set_bid(ctx, keyword_id, amount):
358
+ """Update keyword bid."""
359
+ try:
360
+ sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).edit_keyword(
361
+ keywordId=keyword_id,
362
+ body={"keywords": [{"keywordId": keyword_id, "bid": amount}]},
363
+ )
364
+ click.echo(f"✅ Keyword {keyword_id} bid updated to ${amount}")
365
+ except Exception as e:
366
+ click.echo(f"❌ Error: {e}")
367
+
368
+
369
+ @cli.group()
370
+ def negatives():
371
+ """Negative keyword management commands."""
372
+ pass
373
+
374
+
375
+ @negatives.command("list")
376
+ @click.argument("campaign-id")
377
+ @click.pass_context
378
+ def list_negatives(ctx, campaign_id):
379
+ """List negative keywords for a campaign."""
380
+ result = sponsored_products.NegativeKeywordsV3(
381
+ marketplace=Marketplaces.NA
382
+ ).list_negative_keywords(
383
+ body={
384
+ "campaignIdFilter": {"include": [campaign_id]},
385
+ "stateFilter": {"include": ["ENABLED"]},
386
+ }
387
+ )
388
+ negatives = result.payload.get("negativeKeywords", [])
389
+
390
+ click.echo(f"\n{'Negative Keyword':<35} {'Match':<15}")
391
+ click.echo("-" * 55)
392
+ for neg in negatives:
393
+ text = neg["keywordText"][:33]
394
+ match = neg["matchType"]
395
+ click.echo(f"{text:<35} {match:<15}")
396
+
397
+
398
+ @negatives.command("list-all")
399
+ @click.pass_context
400
+ def list_all_negatives(ctx):
401
+ """List all negative keywords across all campaigns."""
402
+ result = sponsored_products.NegativeKeywordsV3(
403
+ marketplace=Marketplaces.NA
404
+ ).list_negative_keywords(body={"stateFilter": {"include": ["ENABLED"]}})
405
+ negatives = result.payload.get("negativeKeywords", [])
406
+
407
+ click.echo(f"\n{'Campaign ID':<20} {'Negative Keyword':<35} {'Match':<15}")
408
+ click.echo("-" * 80)
409
+ for neg in negatives:
410
+ camp_id = neg.get("campaignId", "N/A")[:18]
411
+ text = neg["keywordText"][:33]
412
+ match = neg["matchType"]
413
+ click.echo(f"{camp_id:<20} {text:<35} {match:<15}")
414
+
415
+
416
+ @negatives.command("add")
417
+ @click.argument("campaign-id")
418
+ @click.argument("ad-group-id")
419
+ @click.argument("keyword-text")
420
+ @click.option(
421
+ "--match-type",
422
+ default="NEGATIVE_PHRASE",
423
+ help="Match type: NEGATIVE_EXACT, NEGATIVE_PHRASE",
424
+ )
425
+ @click.pass_context
426
+ def add_negative(ctx, campaign_id, ad_group_id, keyword_text, match_type):
427
+ """Add a negative keyword to a campaign."""
428
+ try:
429
+ sponsored_products.NegativeKeywordsV3(
430
+ marketplace=Marketplaces.NA
431
+ ).create_negative_keyword(
432
+ body={
433
+ "negativeKeywords": [
434
+ {
435
+ "campaignId": campaign_id,
436
+ "adGroupId": ad_group_id,
437
+ "keywordText": keyword_text,
438
+ "matchType": match_type,
439
+ "state": "ENABLED",
440
+ }
441
+ ]
442
+ }
443
+ )
444
+ click.echo(f"✅ Added negative keyword: {keyword_text} ({match_type})")
445
+ except Exception as e:
446
+ click.echo(f"❌ Error: {e}")
447
+
448
+
449
+ @negatives.command("remove")
450
+ @click.argument("negative-keyword-id")
451
+ @click.pass_context
452
+ def remove_negative(ctx, negative_keyword_id):
453
+ """Remove a negative keyword by ID."""
454
+ try:
455
+ sponsored_products.NegativeKeywordsV3(
456
+ marketplace=Marketplaces.NA
457
+ ).delete_negative_keywords(
458
+ body={"negativeKeywordIdFilter": {"include": [negative_keyword_id]}}
459
+ )
460
+ click.echo(f"✅ Removed negative keyword: {negative_keyword_id}")
461
+ except Exception as e:
462
+ click.echo(f"❌ Error: {e}")
463
+
464
+
465
+ @cli.group()
466
+ def targets():
467
+ """Product target management commands."""
468
+ pass
469
+
470
+
471
+ @targets.command("list-all")
472
+ @click.pass_context
473
+ def list_all_targets(ctx):
474
+ """List all product targets across all campaigns."""
475
+ result = sponsored_products.TargetsV3(
476
+ marketplace=Marketplaces.NA
477
+ ).list_product_targets(body={})
478
+ targets_list = result.payload.get("productTargets", [])
479
+
480
+ click.echo(
481
+ f"\n{'Campaign ID':<20} {'Ad Group ID':<20} {'Expression':<40} {'State'}"
482
+ )
483
+ click.echo("-" * 95)
484
+ for t in targets_list:
485
+ camp_id = t.get("campaignId", "N/A")[:18]
486
+ ag_id = t.get("adGroupId", "N/A")[:18]
487
+ expr = str(t.get("expression", []))[:38]
488
+ state = t.get("state", "N/A")
489
+ click.echo(f"{camp_id:<20} {ag_id:<20} {expr:<40} {state}")
490
+
491
+
492
+ @cli.group()
493
+ def report():
494
+ """Report commands."""
495
+ pass
496
+
497
+
498
+ @report.command("today")
499
+ @click.pass_context
500
+ def report_today(ctx):
501
+ """Get today's performance report."""
502
+ today = datetime.now().strftime("%Y-%m-%d")
503
+
504
+ click.echo(f"Requesting report for {today}...")
505
+
506
+ report_body = {
507
+ "name": f"SP_Today_{today}",
508
+ "startDate": today,
509
+ "endDate": today,
510
+ "configuration": {
511
+ "adProduct": "SPONSORED_PRODUCTS",
512
+ "columns": [
513
+ "impressions",
514
+ "clicks",
515
+ "cost",
516
+ "purchases14d",
517
+ "sales14d",
518
+ "campaignName",
519
+ "campaignId",
520
+ ],
521
+ "reportTypeId": "spCampaigns",
522
+ "format": "GZIP_JSON",
523
+ "groupBy": ["campaign"],
524
+ "timeUnit": "SUMMARY",
525
+ },
526
+ }
527
+
528
+ try:
529
+ # Submit report
530
+ result = reports.Reports(marketplace=Marketplaces.NA).post_report(
531
+ body=report_body
532
+ )
533
+ report_id = result.payload["reportId"]
534
+
535
+ click.echo(f"Report submitted: {report_id}")
536
+ click.echo("Polling for completion...")
537
+
538
+ # Poll
539
+ import time
540
+
541
+ for i in range(20):
542
+ result = reports.Reports(marketplace=Marketplaces.NA).get_report(
543
+ reportId=report_id
544
+ )
545
+ status = result.payload.get("status")
546
+
547
+ if status == "COMPLETED":
548
+ # Download
549
+ import gzip
550
+
551
+ import requests
552
+
553
+ url = result.payload.get("url")
554
+ response = requests.get(url)
555
+ data = gzip.decompress(response.content)
556
+ report_data = json.loads(data)
557
+
558
+ click.echo(
559
+ f"\n{'Campaign':<30} {'Impr':>8} {'Clicks':>7} {'Spend':>8} {'Sales':>8} {'ACOS'}"
560
+ )
561
+ click.echo("-" * 75)
562
+
563
+ for row in report_data:
564
+ camp_name = row.get("campaignName", "N/A")[:28]
565
+ impr = int(row.get("impressions", 0) or 0)
566
+ clicks = int(row.get("clicks", 0) or 0)
567
+ cost = float(row.get("cost", 0) or 0)
568
+ sales = float(row.get("sales14d", 0) or 0)
569
+ acos = (cost / sales * 100) if sales > 0 else 0
570
+
571
+ click.echo(
572
+ f"{camp_name:<30} {impr:>8} {clicks:>7} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%"
573
+ )
574
+
575
+ return
576
+
577
+ elif status == "FAILED":
578
+ click.echo(f"❌ Report failed: {result.payload.get('failureReason')}")
579
+ return
580
+
581
+ time.sleep(3)
582
+
583
+ click.echo("⏳ Report still processing...")
584
+
585
+ except Exception as e:
586
+ click.echo(f"❌ Error: {e}")
587
+
588
+
589
+ @report.command("status")
590
+ @click.argument("report-id")
591
+ @click.pass_context
592
+ def report_status(ctx, report_id):
593
+ """Check status of an existing report."""
594
+ try:
595
+ result = reports.Reports(marketplace=Marketplaces.NA).get_report(
596
+ reportId=report_id
597
+ )
598
+ payload = result.payload
599
+
600
+ status = payload.get("status")
601
+ name = payload.get("name", "N/A")
602
+ start = payload.get("startDate", "N/A")
603
+ end = payload.get("endDate", "N/A")
604
+ created = payload.get("createdAt", "N/A")
605
+ updated = payload.get("updatedAt", "N/A")
606
+
607
+ click.echo(f"\n📊 Report: {name}")
608
+ click.echo(f" ID: {report_id}")
609
+ click.echo(f" Status: {status}")
610
+ click.echo(f" Date Range: {start} to {end}")
611
+ click.echo(f" Created: {created}")
612
+ click.echo(f" Updated: {updated}")
613
+
614
+ if status == "COMPLETED":
615
+ size = payload.get("fileSize", "N/A")
616
+ click.echo(f" File Size: {size}")
617
+ click.echo("\n✅ Report is ready. Download with:")
618
+ click.echo(f" amz-ads report download {report_id}")
619
+ elif status == "FAILED":
620
+ reason = payload.get("failureReason", "Unknown")
621
+ click.echo(f"\n❌ Failed: {reason}")
622
+ else:
623
+ click.echo("\n⏳ Still processing. Check again later.")
624
+
625
+ except Exception as e:
626
+ click.echo(f"❌ Error: {e}")
627
+
628
+
629
+ @report.command("download")
630
+ @click.argument("report-id")
631
+ @click.option(
632
+ "--format",
633
+ "fmt",
634
+ default="table",
635
+ type=click.Choice(["table", "json", "csv"]),
636
+ help="Output format",
637
+ )
638
+ @click.option("--output", "-o", help="Save to file instead of stdout")
639
+ @click.pass_context
640
+ def report_download(ctx, report_id, fmt, output):
641
+ """Download a completed report by ID."""
642
+ try:
643
+ result = reports.Reports(marketplace=Marketplaces.NA).get_report(
644
+ reportId=report_id
645
+ )
646
+ status = result.payload.get("status")
647
+
648
+ if status != "COMPLETED":
649
+ click.echo(f"❌ Report is not ready (status: {status})")
650
+ click.echo(f" Check status: amz-ads report status {report_id}")
651
+ return
652
+
653
+ url = result.payload.get("url")
654
+ if not url:
655
+ click.echo("❌ No download URL available")
656
+ return
657
+
658
+ import gzip
659
+
660
+ import requests
661
+
662
+ click.echo("Downloading report...")
663
+ response = requests.get(url)
664
+ data = gzip.decompress(response.content)
665
+ report_data = json.loads(data)
666
+
667
+ if fmt == "json":
668
+ output_text = json.dumps(report_data, indent=2)
669
+ elif fmt == "csv":
670
+ if not report_data:
671
+ click.echo("❌ Report is empty")
672
+ return
673
+ import csv
674
+ import io
675
+
676
+ buf = io.StringIO()
677
+ writer = csv.DictWriter(buf, fieldnames=report_data[0].keys())
678
+ writer.writeheader()
679
+ writer.writerows(report_data)
680
+ output_text = buf.getvalue()
681
+ else:
682
+ # Table format
683
+ if not report_data:
684
+ click.echo("❌ Report is empty")
685
+ return
686
+
687
+ # Detect report type by columns
688
+ columns = report_data[0].keys()
689
+
690
+ if "searchTerm" in columns:
691
+ # Search terms report
692
+ report_data.sort(
693
+ key=lambda x: float(x.get("cost", 0) or 0), reverse=True
694
+ )
695
+ output_text = f"\n{'Search Term':<40} {'Campaign':<20} {'Spend':>8} {'Sales':>8} {'ACOS'}\n"
696
+ output_text += "-" * 90 + "\n"
697
+ for row in report_data[:50]:
698
+ term = row.get("searchTerm", "N/A")[:38]
699
+ camp = row.get("campaignName", "N/A")[:18]
700
+ cost = float(row.get("cost", 0) or 0)
701
+ sales = float(row.get("sales14d", 0) or 0)
702
+ acos = (cost / sales * 100) if sales > 0 else 0
703
+ output_text += f"{term:<40} {camp:<20} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%\n"
704
+ else:
705
+ # Campaign report
706
+ output_text = f"\n{'Campaign':<30} {'Impr':>8} {'Clicks':>7} {'Spend':>8} {'Sales':>8} {'ACOS'}\n"
707
+ output_text += "-" * 75 + "\n"
708
+ for row in report_data:
709
+ camp_name = row.get("campaignName", "N/A")[:28]
710
+ impr = int(row.get("impressions", 0) or 0)
711
+ clicks = int(row.get("clicks", 0) or 0)
712
+ cost = float(row.get("cost", 0) or 0)
713
+ sales = float(row.get("sales14d", 0) or 0)
714
+ acos = (cost / sales * 100) if sales > 0 else 0
715
+ output_text += f"{camp_name:<30} {impr:>8} {clicks:>7} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%\n"
716
+
717
+ if output:
718
+ with open(output, "w") as f:
719
+ f.write(output_text)
720
+ click.echo(f"✅ Saved to {output}")
721
+ else:
722
+ click.echo(output_text)
723
+
724
+ except Exception as e:
725
+ click.echo(f"❌ Error: {e}")
726
+
727
+
728
+ @report.command("search-terms")
729
+ @click.option("--days", default=7, help="Number of days to look back")
730
+ @click.pass_context
731
+ def search_terms_report(ctx, days):
732
+ """Get search term report."""
733
+ end_date = datetime.now().strftime("%Y-%m-%d")
734
+ start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
735
+
736
+ click.echo(f"Requesting search term report: {start_date} to {end_date}...")
737
+
738
+ report_body = {
739
+ "name": f"SP_SearchTerms_{start_date}_{end_date}",
740
+ "startDate": start_date,
741
+ "endDate": end_date,
742
+ "configuration": {
743
+ "adProduct": "SPONSORED_PRODUCTS",
744
+ "columns": [
745
+ "impressions",
746
+ "clicks",
747
+ "cost",
748
+ "purchases14d",
749
+ "sales14d",
750
+ "searchTerm",
751
+ "matchType",
752
+ "campaignName",
753
+ "keyword",
754
+ ],
755
+ "reportTypeId": "spSearchTerm",
756
+ "format": "GZIP_JSON",
757
+ "groupBy": ["searchTerm"],
758
+ "timeUnit": "SUMMARY",
759
+ },
760
+ }
761
+
762
+ try:
763
+ result = reports.Reports(marketplace=Marketplaces.NA).post_report(
764
+ body=report_body
765
+ )
766
+ report_id = result.payload["reportId"]
767
+
768
+ click.echo(f"Report submitted: {report_id}")
769
+ click.echo("Polling for completion...")
770
+
771
+ import time
772
+
773
+ for i in range(30):
774
+ result = reports.Reports(marketplace=Marketplaces.NA).get_report(
775
+ reportId=report_id
776
+ )
777
+ status = result.payload.get("status")
778
+
779
+ if status == "COMPLETED":
780
+ import gzip
781
+
782
+ import requests
783
+
784
+ url = result.payload.get("url")
785
+ response = requests.get(url)
786
+ data = gzip.decompress(response.content)
787
+ report_data = json.loads(data)
788
+
789
+ # Sort by cost (highest first)
790
+ report_data.sort(
791
+ key=lambda x: float(x.get("cost", 0) or 0), reverse=True
792
+ )
793
+
794
+ click.echo(
795
+ f"\n{'Search Term':<40} {'Campaign':<20} {'Spend':>8} {'Sales':>8} {'ACOS'}"
796
+ )
797
+ click.echo("-" * 90)
798
+
799
+ for row in report_data[:20]:
800
+ term = row.get("searchTerm", "N/A")[:38]
801
+ camp = row.get("campaignName", "N/A")[:18]
802
+ cost = float(row.get("cost", 0) or 0)
803
+ sales = float(row.get("sales14d", 0) or 0)
804
+ acos = (cost / sales * 100) if sales > 0 else 0
805
+
806
+ click.echo(
807
+ f"{term:<40} {camp:<20} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%"
808
+ )
809
+
810
+ return
811
+
812
+ elif status == "FAILED":
813
+ click.echo(f"❌ Report failed: {result.payload.get('failureReason')}")
814
+ return
815
+
816
+ time.sleep(5)
817
+
818
+ click.echo("⏳ Report still processing...")
819
+
820
+ except Exception as e:
821
+ click.echo(f"❌ Error: {e}")
822
+
823
+
824
+ if __name__ == "__main__":
825
+ cli()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-ads-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: CLI tool for Amazon Advertising API v3
5
5
  Home-page: https://github.com/stellaraether/amazon-ads-cli
6
6
  Author: Lunan Li
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.9
14
14
  Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Requires-Python: >=3.8
17
+ License-File: LICENSE
17
18
  Requires-Dist: click>=8.0
18
19
  Requires-Dist: python-amazon-ad-api>=0.8.0
19
20
  Requires-Dist: requests>=2.27.0
@@ -21,6 +22,7 @@ Dynamic: author
21
22
  Dynamic: author-email
22
23
  Dynamic: classifier
23
24
  Dynamic: home-page
25
+ Dynamic: license-file
24
26
  Dynamic: requires-dist
25
27
  Dynamic: requires-python
26
28
  Dynamic: summary
@@ -1,6 +1,8 @@
1
+ LICENSE
1
2
  README.md
2
3
  setup.py
3
4
  amazon_ads_cli/__init__.py
5
+ amazon_ads_cli/__main__.py
4
6
  amazon_ads_cli/main.py
5
7
  amazon_ads_cli.egg-info/PKG-INFO
6
8
  amazon_ads_cli.egg-info/SOURCES.txt
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="amazon-ads-cli",
5
- version="0.1.2",
5
+ version="0.1.4",
6
6
  description="CLI tool for Amazon Advertising API v3",
7
7
  author="Lunan Li",
8
8
  author_email="lunan@stellaraether.com",
@@ -1,420 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Amazon Ads CLI - Command line interface for Amazon Advertising API v3."""
3
-
4
- import json
5
- from datetime import datetime, timedelta
6
-
7
- import click
8
- from ad_api.api import reports, sponsored_products
9
- from ad_api.base import Marketplaces
10
-
11
-
12
- @click.group()
13
- @click.option("--profile", "-p", default="default", help="Credential profile")
14
- @click.pass_context
15
- def cli(ctx, profile):
16
- """Amazon Ads CLI - Manage campaigns, keywords, and reports."""
17
- ctx.ensure_object(dict)
18
- ctx.obj["profile"] = profile
19
-
20
-
21
- @cli.group()
22
- def campaigns():
23
- """Campaign management commands."""
24
- pass
25
-
26
-
27
- @campaigns.command("list")
28
- @click.pass_context
29
- def list_campaigns(ctx):
30
- """List all campaigns."""
31
- result = sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).list_campaigns(
32
- body={}
33
- )
34
- campaigns = result.payload.get("campaigns", [])
35
-
36
- click.echo(f"\n{'Campaign':<30} {'State':<10} {'Budget':<10} {'Type'}")
37
- click.echo("-" * 65)
38
- for camp in campaigns:
39
- name = camp["name"][:28]
40
- state = camp["state"]
41
- budget = f"${camp['budget']['budget']}"
42
- ctype = camp.get("targetingType", "N/A")
43
- click.echo(f"{name:<30} {state:<10} {budget:<10} {ctype}")
44
-
45
-
46
- @campaigns.command("pause")
47
- @click.argument("campaign-id")
48
- @click.pass_context
49
- def pause_campaign(ctx, campaign_id):
50
- """Pause a campaign."""
51
- try:
52
- sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
53
- body={"campaigns": [{"campaignId": campaign_id, "state": "PAUSED"}]}
54
- )
55
- click.echo(f"✅ Campaign {campaign_id} paused")
56
- except Exception as e:
57
- click.echo(f"❌ Error: {e}")
58
-
59
-
60
- @campaigns.command("enable")
61
- @click.argument("campaign-id")
62
- @click.pass_context
63
- def enable_campaign(ctx, campaign_id):
64
- """Enable a campaign."""
65
- try:
66
- sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
67
- body={"campaigns": [{"campaignId": campaign_id, "state": "ENABLED"}]}
68
- )
69
- click.echo(f"✅ Campaign {campaign_id} enabled")
70
- except Exception as e:
71
- click.echo(f"❌ Error: {e}")
72
-
73
-
74
- @campaigns.command("budget")
75
- @click.argument("campaign-id")
76
- @click.argument("amount", type=float)
77
- @click.pass_context
78
- def set_budget(ctx, campaign_id, amount):
79
- """Set campaign daily budget."""
80
- try:
81
- sponsored_products.CampaignsV3(marketplace=Marketplaces.NA).edit_campaigns(
82
- body={
83
- "campaigns": [
84
- {
85
- "campaignId": campaign_id,
86
- "budget": {"budget": amount, "budgetType": "DAILY"},
87
- }
88
- ]
89
- }
90
- )
91
- click.echo(f"✅ Campaign {campaign_id} budget set to ${amount}/day")
92
- except Exception as e:
93
- click.echo(f"❌ Error: {e}")
94
-
95
-
96
- @cli.group()
97
- def keywords():
98
- """Keyword management commands."""
99
- pass
100
-
101
-
102
- @keywords.command("list")
103
- @click.argument("campaign-id")
104
- @click.pass_context
105
- def list_keywords(ctx, campaign_id):
106
- """List keywords for a campaign."""
107
- result = sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).list_keywords(
108
- body={}
109
- )
110
- keywords = [
111
- k
112
- for k in result.payload.get("keywords", [])
113
- if k.get("campaignId") == campaign_id
114
- ]
115
-
116
- click.echo(f"\n{'Keyword':<35} {'Match':<10} {'Bid':<8} {'State'}")
117
- click.echo("-" * 70)
118
- for kw in keywords:
119
- text = kw["keywordText"][:33]
120
- match = kw["matchType"]
121
- bid = f"${kw['bid']}"
122
- state = kw["state"]
123
- click.echo(f"{text:<35} {match:<10} {bid:<8} {state}")
124
-
125
-
126
- @keywords.command("add")
127
- @click.argument("campaign-id")
128
- @click.argument("ad-group-id")
129
- @click.argument("keyword-text")
130
- @click.option("--match-type", default="EXACT", help="Match type: EXACT, PHRASE, BROAD")
131
- @click.option("--bid", default=1.0, help="Bid amount")
132
- @click.pass_context
133
- def add_keyword(ctx, campaign_id, ad_group_id, keyword_text, match_type, bid):
134
- """Add a keyword to a campaign."""
135
- try:
136
- sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).create_keyword(
137
- body={
138
- "keywords": [
139
- {
140
- "campaignId": campaign_id,
141
- "adGroupId": ad_group_id,
142
- "keywordText": keyword_text,
143
- "matchType": match_type,
144
- "bid": bid,
145
- "state": "ENABLED",
146
- }
147
- ]
148
- }
149
- )
150
- click.echo(f"✅ Added keyword: {keyword_text} ({match_type}) - ${bid}")
151
- except Exception as e:
152
- click.echo(f"❌ Error: {e}")
153
-
154
-
155
- @keywords.command("bid")
156
- @click.argument("keyword-id")
157
- @click.argument("amount", type=float)
158
- @click.pass_context
159
- def set_bid(ctx, keyword_id, amount):
160
- """Update keyword bid."""
161
- try:
162
- sponsored_products.KeywordsV3(marketplace=Marketplaces.NA).edit_keyword(
163
- keywordId=keyword_id,
164
- body={"keywords": [{"keywordId": keyword_id, "bid": amount}]},
165
- )
166
- click.echo(f"✅ Keyword {keyword_id} bid updated to ${amount}")
167
- except Exception as e:
168
- click.echo(f"❌ Error: {e}")
169
-
170
-
171
- @cli.group()
172
- def negatives():
173
- """Negative keyword management commands."""
174
- pass
175
-
176
-
177
- @negatives.command("list")
178
- @click.argument("campaign-id")
179
- @click.pass_context
180
- def list_negatives(ctx, campaign_id):
181
- """List negative keywords for a campaign."""
182
- result = sponsored_products.NegativeKeywordsV3(
183
- marketplace=Marketplaces.NA
184
- ).list_negative_keywords(body={"campaignIdFilter": {"include": [campaign_id]}})
185
- negatives = result.payload.get("negativeKeywords", [])
186
-
187
- click.echo(f"\n{'Negative Keyword':<35} {'Match':<15}")
188
- click.echo("-" * 55)
189
- for neg in negatives:
190
- text = neg["keywordText"][:33]
191
- match = neg["matchType"]
192
- click.echo(f"{text:<35} {match:<15}")
193
-
194
-
195
- @negatives.command("add")
196
- @click.argument("campaign-id")
197
- @click.argument("keyword-text")
198
- @click.option(
199
- "--match-type",
200
- default="NEGATIVE_PHRASE",
201
- help="Match type: NEGATIVE_EXACT, NEGATIVE_PHRASE",
202
- )
203
- @click.pass_context
204
- def add_negative(ctx, campaign_id, keyword_text, match_type):
205
- """Add a negative keyword to a campaign."""
206
- try:
207
- sponsored_products.NegativeKeywordsV3(
208
- marketplace=Marketplaces.NA
209
- ).create_negative_keywords(
210
- body={
211
- "negativeKeywords": [
212
- {
213
- "campaignId": campaign_id,
214
- "keywordText": keyword_text,
215
- "matchType": match_type,
216
- "state": "ENABLED",
217
- }
218
- ]
219
- }
220
- )
221
- click.echo(f"✅ Added negative keyword: {keyword_text} ({match_type})")
222
- except Exception as e:
223
- click.echo(f"❌ Error: {e}")
224
-
225
-
226
- @cli.group()
227
- def report():
228
- """Report commands."""
229
- pass
230
-
231
-
232
- @report.command("today")
233
- @click.pass_context
234
- def report_today(ctx):
235
- """Get today's performance report."""
236
- today = datetime.now().strftime("%Y-%m-%d")
237
-
238
- click.echo(f"Requesting report for {today}...")
239
-
240
- report_body = {
241
- "name": f"SP_Today_{today}",
242
- "startDate": today,
243
- "endDate": today,
244
- "configuration": {
245
- "adProduct": "SPONSORED_PRODUCTS",
246
- "columns": [
247
- "impressions",
248
- "clicks",
249
- "cost",
250
- "purchases14d",
251
- "sales14d",
252
- "campaignName",
253
- "campaignId",
254
- ],
255
- "reportTypeId": "spCampaigns",
256
- "format": "GZIP_JSON",
257
- "groupBy": ["campaign"],
258
- "timeUnit": "SUMMARY",
259
- },
260
- }
261
-
262
- try:
263
- # Submit report
264
- result = reports.Reports(marketplace=Marketplaces.NA).post_report(
265
- body=report_body
266
- )
267
- report_id = result.payload["reportId"]
268
-
269
- click.echo(f"Report submitted: {report_id}")
270
- click.echo("Polling for completion...")
271
-
272
- # Poll
273
- import time
274
-
275
- for i in range(20):
276
- result = reports.Reports(marketplace=Marketplaces.NA).get_report(
277
- reportId=report_id
278
- )
279
- status = result.payload.get("status")
280
-
281
- if status == "COMPLETED":
282
- # Download
283
- import gzip
284
-
285
- import requests
286
-
287
- url = result.payload.get("url")
288
- response = requests.get(url)
289
- data = gzip.decompress(response.content)
290
- report_data = json.loads(data)
291
-
292
- click.echo(
293
- f"\n{'Campaign':<30} {'Impr':>8} {'Clicks':>7} {'Spend':>8} {'Sales':>8} {'ACOS'}"
294
- )
295
- click.echo("-" * 75)
296
-
297
- for row in report_data:
298
- camp_name = row.get("campaignName", "N/A")[:28]
299
- impr = int(row.get("impressions", 0) or 0)
300
- clicks = int(row.get("clicks", 0) or 0)
301
- cost = float(row.get("cost", 0) or 0)
302
- sales = float(row.get("sales14d", 0) or 0)
303
- acos = (cost / sales * 100) if sales > 0 else 0
304
-
305
- click.echo(
306
- f"{camp_name:<30} {impr:>8} {clicks:>7} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%"
307
- )
308
-
309
- return
310
-
311
- elif status == "FAILED":
312
- click.echo(f"❌ Report failed: {result.payload.get('failureReason')}")
313
- return
314
-
315
- time.sleep(3)
316
-
317
- click.echo("⏳ Report still processing...")
318
-
319
- except Exception as e:
320
- click.echo(f"❌ Error: {e}")
321
-
322
-
323
- @report.command("search-terms")
324
- @click.option("--days", default=7, help="Number of days to look back")
325
- @click.pass_context
326
- def search_terms_report(ctx, days):
327
- """Get search term report."""
328
- end_date = datetime.now().strftime("%Y-%m-%d")
329
- start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
330
-
331
- click.echo(f"Requesting search term report: {start_date} to {end_date}...")
332
-
333
- report_body = {
334
- "name": f"SP_SearchTerms_{start_date}_{end_date}",
335
- "startDate": start_date,
336
- "endDate": end_date,
337
- "configuration": {
338
- "adProduct": "SPONSORED_PRODUCTS",
339
- "columns": [
340
- "impressions",
341
- "clicks",
342
- "cost",
343
- "purchases14d",
344
- "sales14d",
345
- "searchTerm",
346
- "matchType",
347
- "campaignName",
348
- "keyword",
349
- ],
350
- "reportTypeId": "spSearchTerm",
351
- "format": "GZIP_JSON",
352
- "groupBy": ["searchTerm"],
353
- "timeUnit": "SUMMARY",
354
- },
355
- }
356
-
357
- try:
358
- result = reports.Reports(marketplace=Marketplaces.NA).post_report(
359
- body=report_body
360
- )
361
- report_id = result.payload["reportId"]
362
-
363
- click.echo(f"Report submitted: {report_id}")
364
- click.echo("Polling for completion...")
365
-
366
- import time
367
-
368
- for i in range(30):
369
- result = reports.Reports(marketplace=Marketplaces.NA).get_report(
370
- reportId=report_id
371
- )
372
- status = result.payload.get("status")
373
-
374
- if status == "COMPLETED":
375
- import gzip
376
-
377
- import requests
378
-
379
- url = result.payload.get("url")
380
- response = requests.get(url)
381
- data = gzip.decompress(response.content)
382
- report_data = json.loads(data)
383
-
384
- # Sort by cost (highest first)
385
- report_data.sort(
386
- key=lambda x: float(x.get("cost", 0) or 0), reverse=True
387
- )
388
-
389
- click.echo(
390
- f"\n{'Search Term':<40} {'Campaign':<20} {'Spend':>8} {'Sales':>8} {'ACOS'}"
391
- )
392
- click.echo("-" * 90)
393
-
394
- for row in report_data[:20]:
395
- term = row.get("searchTerm", "N/A")[:38]
396
- camp = row.get("campaignName", "N/A")[:18]
397
- cost = float(row.get("cost", 0) or 0)
398
- sales = float(row.get("sales14d", 0) or 0)
399
- acos = (cost / sales * 100) if sales > 0 else 0
400
-
401
- click.echo(
402
- f"{term:<40} {camp:<20} ${cost:>7.2f} ${sales:>7.2f} {acos:>5.1f}%"
403
- )
404
-
405
- return
406
-
407
- elif status == "FAILED":
408
- click.echo(f"❌ Report failed: {result.payload.get('failureReason')}")
409
- return
410
-
411
- time.sleep(5)
412
-
413
- click.echo("⏳ Report still processing...")
414
-
415
- except Exception as e:
416
- click.echo(f"❌ Error: {e}")
417
-
418
-
419
- if __name__ == "__main__":
420
- cli()
File without changes
File without changes