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.
- amazon_ads_cli-0.1.4/LICENSE +21 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/PKG-INFO +3 -1
- amazon_ads_cli-0.1.4/amazon_ads_cli/__main__.py +6 -0
- amazon_ads_cli-0.1.4/amazon_ads_cli/main.py +825 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/amazon_ads_cli.egg-info/PKG-INFO +3 -1
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/amazon_ads_cli.egg-info/SOURCES.txt +2 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/setup.py +1 -1
- amazon_ads_cli-0.1.2/amazon_ads_cli/main.py +0 -420
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/README.md +0 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/amazon_ads_cli/__init__.py +0 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/amazon_ads_cli.egg-info/dependency_links.txt +0 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/amazon_ads_cli.egg-info/entry_points.txt +0 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/amazon_ads_cli.egg-info/requires.txt +0 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/amazon_ads_cli.egg-info/top_level.txt +0 -0
- {amazon_ads_cli-0.1.2 → amazon_ads_cli-0.1.4}/setup.cfg +0 -0
|
@@ -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.
|
|
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,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.
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|