apple-search-ads-client 1.0.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.
Potentially problematic release.
This version of apple-search-ads-client might be problematic. Click here for more details.
- apple_search_ads/__init__.py +13 -0
- apple_search_ads/client.py +646 -0
- apple_search_ads/exceptions.py +33 -0
- apple_search_ads_client-1.0.0.dist-info/METADATA +367 -0
- apple_search_ads_client-1.0.0.dist-info/RECORD +8 -0
- apple_search_ads_client-1.0.0.dist-info/WHEEL +5 -0
- apple_search_ads_client-1.0.0.dist-info/licenses/LICENSE +21 -0
- apple_search_ads_client-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Apple Search Ads Python Client
|
|
3
|
+
|
|
4
|
+
A Python client library for Apple Search Ads API v5.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import AppleSearchAdsClient
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__author__ = "Your Name"
|
|
11
|
+
__email__ = "your.email@example.com"
|
|
12
|
+
|
|
13
|
+
__all__ = ["AppleSearchAdsClient"]
|
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Apple Search Ads API Client for Python
|
|
3
|
+
|
|
4
|
+
A Python client for interacting with Apple Search Ads API v5.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
import time
|
|
9
|
+
import os
|
|
10
|
+
import requests
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import Dict, List, Optional, Any, Union
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from ratelimit import limits, sleep_and_retry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AppleSearchAdsClient:
|
|
18
|
+
"""
|
|
19
|
+
Client for Apple Search Ads API v5.
|
|
20
|
+
|
|
21
|
+
This client provides methods to interact with Apple Search Ads API,
|
|
22
|
+
including campaign management, reporting, and spend tracking.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
client_id: Apple Search Ads client ID
|
|
26
|
+
team_id: Apple Search Ads team ID
|
|
27
|
+
key_id: Apple Search Ads key ID
|
|
28
|
+
private_key_path: Path to the private key .p8 file
|
|
29
|
+
private_key_content: Private key content as string (alternative to file path)
|
|
30
|
+
org_id: Optional organization ID (will be fetched automatically if not provided)
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
>>> client = AppleSearchAdsClient(
|
|
34
|
+
... client_id="your_client_id",
|
|
35
|
+
... team_id="your_team_id",
|
|
36
|
+
... key_id="your_key_id",
|
|
37
|
+
... private_key_path="/path/to/private_key.p8"
|
|
38
|
+
... )
|
|
39
|
+
>>> campaigns = client.get_campaigns()
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
BASE_URL = "https://api.searchads.apple.com/api/v5"
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
client_id: Optional[str] = None,
|
|
47
|
+
team_id: Optional[str] = None,
|
|
48
|
+
key_id: Optional[str] = None,
|
|
49
|
+
private_key_path: Optional[str] = None,
|
|
50
|
+
private_key_content: Optional[str] = None,
|
|
51
|
+
org_id: Optional[str] = None
|
|
52
|
+
):
|
|
53
|
+
# Try to get credentials from parameters, then environment variables
|
|
54
|
+
self.client_id = client_id or os.environ.get('APPLE_SEARCH_ADS_CLIENT_ID')
|
|
55
|
+
self.team_id = team_id or os.environ.get('APPLE_SEARCH_ADS_TEAM_ID')
|
|
56
|
+
self.key_id = key_id or os.environ.get('APPLE_SEARCH_ADS_KEY_ID')
|
|
57
|
+
self.private_key_path = private_key_path or os.environ.get('APPLE_SEARCH_ADS_PRIVATE_KEY_PATH')
|
|
58
|
+
self.private_key_content = private_key_content
|
|
59
|
+
|
|
60
|
+
# Validate required credentials
|
|
61
|
+
if not all([self.client_id, self.team_id, self.key_id]):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"Missing required credentials. Please provide client_id, team_id, and key_id "
|
|
64
|
+
"either as parameters or environment variables."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if not self.private_key_path and not self.private_key_content:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"Missing private key. Please provide either private_key_path or private_key_content."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self.org_id = org_id
|
|
73
|
+
self._token = None
|
|
74
|
+
self._token_expiry = None
|
|
75
|
+
|
|
76
|
+
def _load_private_key(self) -> str:
|
|
77
|
+
"""Load private key from file or content."""
|
|
78
|
+
if self.private_key_content:
|
|
79
|
+
return self.private_key_content
|
|
80
|
+
|
|
81
|
+
if not os.path.exists(self.private_key_path):
|
|
82
|
+
raise FileNotFoundError(f"Private key file not found: {self.private_key_path}")
|
|
83
|
+
|
|
84
|
+
with open(self.private_key_path, 'r') as f:
|
|
85
|
+
return f.read()
|
|
86
|
+
|
|
87
|
+
def _generate_client_secret(self) -> str:
|
|
88
|
+
"""Generate client secret JWT for Apple Search Ads."""
|
|
89
|
+
# Token expires in 180 days (max allowed by Apple)
|
|
90
|
+
expiry = int(time.time() + 86400 * 180)
|
|
91
|
+
|
|
92
|
+
payload = {
|
|
93
|
+
"sub": self.client_id,
|
|
94
|
+
"aud": "https://appleid.apple.com",
|
|
95
|
+
"iat": int(time.time()),
|
|
96
|
+
"exp": expiry,
|
|
97
|
+
"iss": self.team_id
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
headers = {
|
|
101
|
+
"alg": "ES256",
|
|
102
|
+
"kid": self.key_id
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private_key = self._load_private_key()
|
|
106
|
+
|
|
107
|
+
return jwt.encode(
|
|
108
|
+
payload,
|
|
109
|
+
private_key,
|
|
110
|
+
algorithm="ES256",
|
|
111
|
+
headers=headers
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _get_access_token(self) -> str:
|
|
115
|
+
"""Get access token using client credentials flow."""
|
|
116
|
+
if self._token and self._token_expiry and time.time() < self._token_expiry:
|
|
117
|
+
return self._token
|
|
118
|
+
|
|
119
|
+
token_url = "https://appleid.apple.com/auth/oauth2/token"
|
|
120
|
+
|
|
121
|
+
data = {
|
|
122
|
+
"grant_type": "client_credentials",
|
|
123
|
+
"client_id": self.client_id,
|
|
124
|
+
"client_secret": self._generate_client_secret(),
|
|
125
|
+
"scope": "searchadsorg"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
response = requests.post(token_url, data=data)
|
|
129
|
+
response.raise_for_status()
|
|
130
|
+
|
|
131
|
+
token_data = response.json()
|
|
132
|
+
self._token = token_data["access_token"]
|
|
133
|
+
# Token expires in 1 hour, refresh 5 minutes before
|
|
134
|
+
self._token_expiry = time.time() + 3300
|
|
135
|
+
|
|
136
|
+
return self._token
|
|
137
|
+
|
|
138
|
+
def _get_headers(self, include_org_context: bool = True) -> Dict[str, str]:
|
|
139
|
+
"""Get headers for API requests."""
|
|
140
|
+
headers = {
|
|
141
|
+
"Authorization": f"Bearer {self._get_access_token()}",
|
|
142
|
+
"Content-Type": "application/json"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Add organization context if we have it (not needed for ACLs endpoint)
|
|
146
|
+
if include_org_context and self.org_id:
|
|
147
|
+
headers["X-AP-Context"] = f"orgId={self.org_id}"
|
|
148
|
+
|
|
149
|
+
return headers
|
|
150
|
+
|
|
151
|
+
def _get_org_id(self) -> str:
|
|
152
|
+
"""Get the organization ID."""
|
|
153
|
+
if self.org_id:
|
|
154
|
+
return self.org_id
|
|
155
|
+
|
|
156
|
+
response = self._make_request(f"{self.BASE_URL}/acls", include_org_context=False)
|
|
157
|
+
|
|
158
|
+
if response and "data" in response and len(response["data"]) > 0:
|
|
159
|
+
self.org_id = str(response["data"][0]["orgId"])
|
|
160
|
+
return self.org_id
|
|
161
|
+
|
|
162
|
+
raise ValueError("No organization found for this account")
|
|
163
|
+
|
|
164
|
+
@sleep_and_retry
|
|
165
|
+
@limits(calls=10, period=1) # Apple Search Ads rate limit
|
|
166
|
+
def _make_request(
|
|
167
|
+
self,
|
|
168
|
+
url: str,
|
|
169
|
+
method: str = "GET",
|
|
170
|
+
json_data: Optional[Dict] = None,
|
|
171
|
+
params: Optional[Dict] = None,
|
|
172
|
+
include_org_context: bool = True
|
|
173
|
+
) -> Dict:
|
|
174
|
+
"""Make a rate-limited request to the API."""
|
|
175
|
+
response = requests.request(
|
|
176
|
+
method=method,
|
|
177
|
+
url=url,
|
|
178
|
+
headers=self._get_headers(include_org_context=include_org_context),
|
|
179
|
+
json=json_data,
|
|
180
|
+
params=params
|
|
181
|
+
)
|
|
182
|
+
response.raise_for_status()
|
|
183
|
+
return response.json()
|
|
184
|
+
|
|
185
|
+
def get_all_organizations(self) -> List[Dict]:
|
|
186
|
+
"""
|
|
187
|
+
Get all organizations the user has access to.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
List of organization dictionaries containing orgId, orgName, etc.
|
|
191
|
+
"""
|
|
192
|
+
response = self._make_request(f"{self.BASE_URL}/acls", include_org_context=False)
|
|
193
|
+
|
|
194
|
+
if response and "data" in response:
|
|
195
|
+
return response["data"]
|
|
196
|
+
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_adgroups(self, campaign_id: str) -> List[Dict]:
|
|
201
|
+
"""
|
|
202
|
+
Get all ad groups for a specific campaign.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
campaign_id: The campaign ID to get ad groups for
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of ad group dictionaries
|
|
209
|
+
"""
|
|
210
|
+
# Ensure we have org_id for the context header
|
|
211
|
+
if not self.org_id:
|
|
212
|
+
self._get_org_id()
|
|
213
|
+
|
|
214
|
+
url = f"{self.BASE_URL}/campaigns/{campaign_id}/adgroups"
|
|
215
|
+
|
|
216
|
+
params = {
|
|
217
|
+
"limit": 1000
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
response = self._make_request(url, params=params)
|
|
221
|
+
return response.get("data", [])
|
|
222
|
+
|
|
223
|
+
def get_campaigns(self, org_id: Optional[str] = None) -> List[Dict]:
|
|
224
|
+
"""
|
|
225
|
+
Get all campaigns for a specific organization or the default one.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
org_id: Optional organization ID. If not provided, uses the default org.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
List of campaign dictionaries.
|
|
232
|
+
"""
|
|
233
|
+
# Use provided org_id or get the default one
|
|
234
|
+
original_org_id = None
|
|
235
|
+
if org_id:
|
|
236
|
+
original_org_id = self.org_id
|
|
237
|
+
self.org_id = str(org_id)
|
|
238
|
+
elif not self.org_id:
|
|
239
|
+
self._get_org_id()
|
|
240
|
+
|
|
241
|
+
url = f"{self.BASE_URL}/campaigns"
|
|
242
|
+
|
|
243
|
+
params = {
|
|
244
|
+
"limit": 1000
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
response = self._make_request(url, params=params)
|
|
249
|
+
campaigns = response.get("data", [])
|
|
250
|
+
|
|
251
|
+
# Add org_id to each campaign for tracking
|
|
252
|
+
for campaign in campaigns:
|
|
253
|
+
campaign["fetched_org_id"] = self.org_id
|
|
254
|
+
|
|
255
|
+
return campaigns
|
|
256
|
+
finally:
|
|
257
|
+
# Restore original org_id if we changed it
|
|
258
|
+
if original_org_id is not None:
|
|
259
|
+
self.org_id = original_org_id
|
|
260
|
+
|
|
261
|
+
def get_all_campaigns(self) -> List[Dict]:
|
|
262
|
+
"""
|
|
263
|
+
Get campaigns from all organizations.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
List of all campaigns across all organizations.
|
|
267
|
+
"""
|
|
268
|
+
all_campaigns = []
|
|
269
|
+
organizations = self.get_all_organizations()
|
|
270
|
+
|
|
271
|
+
for org in organizations:
|
|
272
|
+
org_id = str(org["orgId"])
|
|
273
|
+
org_name = org.get("orgName", "Unknown")
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
campaigns = self.get_campaigns(org_id=org_id)
|
|
277
|
+
|
|
278
|
+
# Add organization info to each campaign
|
|
279
|
+
for campaign in campaigns:
|
|
280
|
+
campaign["org_name"] = org_name
|
|
281
|
+
campaign["parent_org_id"] = org.get("parentOrgId")
|
|
282
|
+
|
|
283
|
+
all_campaigns.extend(campaigns)
|
|
284
|
+
except Exception as e:
|
|
285
|
+
print(f"Error fetching campaigns from {org_name}: {e}")
|
|
286
|
+
|
|
287
|
+
return all_campaigns
|
|
288
|
+
|
|
289
|
+
def get_campaign_report(
|
|
290
|
+
self,
|
|
291
|
+
start_date: Union[datetime, str],
|
|
292
|
+
end_date: Union[datetime, str],
|
|
293
|
+
granularity: str = "DAILY"
|
|
294
|
+
) -> pd.DataFrame:
|
|
295
|
+
"""
|
|
296
|
+
Get campaign performance report.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
start_date: Start date for the report (datetime or YYYY-MM-DD string)
|
|
300
|
+
end_date: End date for the report (datetime or YYYY-MM-DD string)
|
|
301
|
+
granularity: DAILY, WEEKLY, or MONTHLY
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
DataFrame with campaign performance metrics.
|
|
305
|
+
"""
|
|
306
|
+
# Ensure we have org_id for the context header
|
|
307
|
+
if not self.org_id:
|
|
308
|
+
self._get_org_id()
|
|
309
|
+
|
|
310
|
+
# Convert string dates to datetime if needed
|
|
311
|
+
if isinstance(start_date, str):
|
|
312
|
+
start_date = datetime.strptime(start_date, "%Y-%m-%d")
|
|
313
|
+
if isinstance(end_date, str):
|
|
314
|
+
end_date = datetime.strptime(end_date, "%Y-%m-%d")
|
|
315
|
+
|
|
316
|
+
url = f"{self.BASE_URL}/reports/campaigns"
|
|
317
|
+
|
|
318
|
+
# Apple Search Ads API uses date-only format
|
|
319
|
+
request_data = {
|
|
320
|
+
"startTime": start_date.strftime("%Y-%m-%d"),
|
|
321
|
+
"endTime": end_date.strftime("%Y-%m-%d"),
|
|
322
|
+
"granularity": granularity,
|
|
323
|
+
"selector": {
|
|
324
|
+
"orderBy": [{"field": "localSpend", "sortOrder": "DESCENDING"}],
|
|
325
|
+
"pagination": {"limit": 1000}
|
|
326
|
+
},
|
|
327
|
+
"returnRowTotals": True,
|
|
328
|
+
"returnRecordsWithNoMetrics": False
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
response = self._make_request(
|
|
332
|
+
url,
|
|
333
|
+
method="POST",
|
|
334
|
+
json_data=request_data
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Handle different response structures
|
|
338
|
+
rows = []
|
|
339
|
+
if response and "data" in response:
|
|
340
|
+
if "reportingDataResponse" in response["data"] and "row" in response["data"]["reportingDataResponse"]:
|
|
341
|
+
# New response structure
|
|
342
|
+
rows = response["data"]["reportingDataResponse"]["row"]
|
|
343
|
+
elif "rows" in response["data"]:
|
|
344
|
+
# Old response structure
|
|
345
|
+
rows = response["data"]["rows"]
|
|
346
|
+
|
|
347
|
+
if rows:
|
|
348
|
+
# Extract data into a flat structure
|
|
349
|
+
data = []
|
|
350
|
+
for row in rows:
|
|
351
|
+
metadata = row.get("metadata", {})
|
|
352
|
+
|
|
353
|
+
# For the new structure, we need to process granularity data
|
|
354
|
+
if "granularity" in row:
|
|
355
|
+
# New structure: iterate through each day in granularity
|
|
356
|
+
for day_data in row["granularity"]:
|
|
357
|
+
data.append({
|
|
358
|
+
"date": day_data.get("date"),
|
|
359
|
+
"campaign_id": metadata.get("campaignId"),
|
|
360
|
+
"campaign_name": metadata.get("campaignName"),
|
|
361
|
+
"campaign_status": metadata.get("campaignStatus"),
|
|
362
|
+
"app_name": metadata.get("app", {}).get("appName") if "app" in metadata else metadata.get("appName"),
|
|
363
|
+
"adam_id": metadata.get("adamId"),
|
|
364
|
+
"impressions": day_data.get("impressions", 0),
|
|
365
|
+
"taps": day_data.get("taps", 0),
|
|
366
|
+
"installs": day_data.get("totalInstalls", 0),
|
|
367
|
+
"spend": float(day_data.get("localSpend", {}).get("amount", 0)),
|
|
368
|
+
"currency": day_data.get("localSpend", {}).get("currency", "USD"),
|
|
369
|
+
"avg_cpa": float(day_data.get("totalAvgCPI", {}).get("amount", 0)),
|
|
370
|
+
"avg_cpt": float(day_data.get("avgCPT", {}).get("amount", 0)),
|
|
371
|
+
"ttr": day_data.get("ttr", 0),
|
|
372
|
+
"conversion_rate": day_data.get("totalInstallRate", 0)
|
|
373
|
+
})
|
|
374
|
+
else:
|
|
375
|
+
# Old structure
|
|
376
|
+
metrics = row.get("metrics", {})
|
|
377
|
+
|
|
378
|
+
data.append({
|
|
379
|
+
"date": metadata.get("date"),
|
|
380
|
+
"campaign_id": metadata.get("campaignId"),
|
|
381
|
+
"campaign_name": metadata.get("campaignName"),
|
|
382
|
+
"campaign_status": metadata.get("campaignStatus"),
|
|
383
|
+
"app_name": metadata.get("appName"),
|
|
384
|
+
"adam_id": metadata.get("adamId"),
|
|
385
|
+
"impressions": metrics.get("impressions", 0),
|
|
386
|
+
"taps": metrics.get("taps", 0),
|
|
387
|
+
"installs": metrics.get("installs", 0),
|
|
388
|
+
"spend": float(metrics.get("localSpend", {}).get("amount", 0)),
|
|
389
|
+
"currency": metrics.get("localSpend", {}).get("currency", "USD"),
|
|
390
|
+
"avg_cpa": float(metrics.get("avgCPA", {}).get("amount", 0)),
|
|
391
|
+
"avg_cpt": float(metrics.get("avgCPT", {}).get("amount", 0)),
|
|
392
|
+
"ttr": metrics.get("ttr", 0),
|
|
393
|
+
"conversion_rate": metrics.get("conversionRate", 0)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
return pd.DataFrame(data)
|
|
397
|
+
|
|
398
|
+
return pd.DataFrame()
|
|
399
|
+
|
|
400
|
+
def get_daily_spend(
|
|
401
|
+
self,
|
|
402
|
+
days: int = 30,
|
|
403
|
+
fetch_all_orgs: bool = True
|
|
404
|
+
) -> pd.DataFrame:
|
|
405
|
+
"""
|
|
406
|
+
Get daily spend across all campaigns.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
days: Number of days to fetch
|
|
410
|
+
fetch_all_orgs: If True, fetches from all organizations
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
DataFrame with daily spend metrics.
|
|
414
|
+
"""
|
|
415
|
+
end_date = datetime.now()
|
|
416
|
+
start_date = end_date - timedelta(days=days)
|
|
417
|
+
|
|
418
|
+
return self.get_daily_spend_with_dates(start_date, end_date, fetch_all_orgs)
|
|
419
|
+
|
|
420
|
+
def get_daily_spend_with_dates(
|
|
421
|
+
self,
|
|
422
|
+
start_date: Union[datetime, str],
|
|
423
|
+
end_date: Union[datetime, str],
|
|
424
|
+
fetch_all_orgs: bool = True
|
|
425
|
+
) -> pd.DataFrame:
|
|
426
|
+
"""
|
|
427
|
+
Get daily spend across all campaigns for a specific date range.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
start_date: Start date for the report
|
|
431
|
+
end_date: End date for the report
|
|
432
|
+
fetch_all_orgs: If True, fetches from all organizations
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
DataFrame with daily spend metrics.
|
|
436
|
+
"""
|
|
437
|
+
all_campaign_data = []
|
|
438
|
+
|
|
439
|
+
if fetch_all_orgs:
|
|
440
|
+
organizations = self.get_all_organizations()
|
|
441
|
+
|
|
442
|
+
for org in organizations:
|
|
443
|
+
org_id = str(org["orgId"])
|
|
444
|
+
|
|
445
|
+
# Set org context
|
|
446
|
+
current_org_id = self.org_id
|
|
447
|
+
self.org_id = org_id
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
# Get campaign report for this org
|
|
451
|
+
org_campaign_df = self.get_campaign_report(start_date, end_date)
|
|
452
|
+
if not org_campaign_df.empty:
|
|
453
|
+
all_campaign_data.append(org_campaign_df)
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
finally:
|
|
457
|
+
# Restore original org_id
|
|
458
|
+
self.org_id = current_org_id
|
|
459
|
+
else:
|
|
460
|
+
campaign_df = self.get_campaign_report(start_date, end_date)
|
|
461
|
+
if not campaign_df.empty:
|
|
462
|
+
all_campaign_data.append(campaign_df)
|
|
463
|
+
|
|
464
|
+
if not all_campaign_data:
|
|
465
|
+
return pd.DataFrame()
|
|
466
|
+
|
|
467
|
+
# Combine all campaign data
|
|
468
|
+
campaign_df = pd.concat(all_campaign_data, ignore_index=True)
|
|
469
|
+
|
|
470
|
+
# Group by date
|
|
471
|
+
daily_df = campaign_df.groupby('date').agg({
|
|
472
|
+
'spend': 'sum',
|
|
473
|
+
'impressions': 'sum',
|
|
474
|
+
'taps': 'sum',
|
|
475
|
+
'installs': 'sum'
|
|
476
|
+
}).reset_index()
|
|
477
|
+
|
|
478
|
+
# Calculate average metrics
|
|
479
|
+
daily_df['avg_cpi'] = daily_df.apply(
|
|
480
|
+
lambda row: row['spend'] / row['installs'] if row['installs'] > 0 else 0,
|
|
481
|
+
axis=1
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
daily_df['avg_cpt'] = daily_df.apply(
|
|
485
|
+
lambda row: row['spend'] / row['taps'] if row['taps'] > 0 else 0,
|
|
486
|
+
axis=1
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
daily_df['conversion_rate'] = daily_df.apply(
|
|
490
|
+
lambda row: row['installs'] / row['taps'] * 100 if row['taps'] > 0 else 0,
|
|
491
|
+
axis=1
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
return daily_df
|
|
496
|
+
|
|
497
|
+
def get_campaigns_with_details(self, fetch_all_orgs: bool = True) -> List[Dict]:
|
|
498
|
+
"""
|
|
499
|
+
Get all campaigns with their app details including adamId.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
fetch_all_orgs: If True, fetches from all organizations
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
List of campaign dictionaries with app details.
|
|
506
|
+
"""
|
|
507
|
+
if fetch_all_orgs:
|
|
508
|
+
campaigns = self.get_all_campaigns()
|
|
509
|
+
else:
|
|
510
|
+
if not self.org_id:
|
|
511
|
+
self._get_org_id()
|
|
512
|
+
campaigns = self.get_campaigns()
|
|
513
|
+
|
|
514
|
+
return campaigns
|
|
515
|
+
|
|
516
|
+
def get_daily_spend_by_app(
|
|
517
|
+
self,
|
|
518
|
+
start_date: Union[datetime, str],
|
|
519
|
+
end_date: Union[datetime, str],
|
|
520
|
+
fetch_all_orgs: bool = True
|
|
521
|
+
) -> pd.DataFrame:
|
|
522
|
+
"""
|
|
523
|
+
Get daily advertising spend grouped by app (adamId).
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
start_date: Start date for the report
|
|
527
|
+
end_date: End date for the report
|
|
528
|
+
fetch_all_orgs: If True, fetches from all organizations
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
DataFrame with columns:
|
|
532
|
+
- date: The date
|
|
533
|
+
- app_id: Apple App Store ID (adamId)
|
|
534
|
+
- spend: Total spend in USD
|
|
535
|
+
- impressions: Total impressions
|
|
536
|
+
- taps: Total taps on ads
|
|
537
|
+
- installs: Total conversions/installs
|
|
538
|
+
- campaigns: Number of active campaigns
|
|
539
|
+
"""
|
|
540
|
+
# First, get campaign-to-app mapping
|
|
541
|
+
campaigns = self.get_campaigns_with_details(fetch_all_orgs=fetch_all_orgs)
|
|
542
|
+
campaign_to_app = {str(c["id"]): str(c.get("adamId")) for c in campaigns if c.get("adamId")}
|
|
543
|
+
|
|
544
|
+
# Get campaign reports from all organizations
|
|
545
|
+
all_campaign_data = []
|
|
546
|
+
|
|
547
|
+
if fetch_all_orgs:
|
|
548
|
+
organizations = self.get_all_organizations()
|
|
549
|
+
|
|
550
|
+
for org in organizations:
|
|
551
|
+
org_id = str(org["orgId"])
|
|
552
|
+
org_name = org.get("orgName", "Unknown")
|
|
553
|
+
|
|
554
|
+
# Set org context
|
|
555
|
+
current_org_id = self.org_id
|
|
556
|
+
self.org_id = org_id
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
# Get campaign report for this org
|
|
560
|
+
org_campaign_df = self.get_campaign_report(start_date, end_date)
|
|
561
|
+
if not org_campaign_df.empty:
|
|
562
|
+
# Add org info to the dataframe
|
|
563
|
+
org_campaign_df["org_id"] = org_id
|
|
564
|
+
org_campaign_df["org_name"] = org_name
|
|
565
|
+
all_campaign_data.append(org_campaign_df)
|
|
566
|
+
except Exception:
|
|
567
|
+
pass
|
|
568
|
+
finally:
|
|
569
|
+
# Restore original org_id
|
|
570
|
+
self.org_id = current_org_id
|
|
571
|
+
else:
|
|
572
|
+
# Just get from default org
|
|
573
|
+
campaign_df = self.get_campaign_report(start_date, end_date)
|
|
574
|
+
if not campaign_df.empty:
|
|
575
|
+
all_campaign_data.append(campaign_df)
|
|
576
|
+
|
|
577
|
+
if not all_campaign_data:
|
|
578
|
+
return pd.DataFrame()
|
|
579
|
+
|
|
580
|
+
# Combine all campaign data
|
|
581
|
+
campaign_df = pd.concat(all_campaign_data, ignore_index=True)
|
|
582
|
+
|
|
583
|
+
# Convert campaign_id to string for mapping
|
|
584
|
+
campaign_df["campaign_id"] = campaign_df["campaign_id"].astype(str)
|
|
585
|
+
|
|
586
|
+
# Map campaigns to apps
|
|
587
|
+
campaign_df["app_id"] = campaign_df["campaign_id"].map(campaign_to_app)
|
|
588
|
+
|
|
589
|
+
# Filter out campaigns without app mapping
|
|
590
|
+
app_df = campaign_df[campaign_df["app_id"].notna()].copy()
|
|
591
|
+
|
|
592
|
+
if app_df.empty:
|
|
593
|
+
return pd.DataFrame()
|
|
594
|
+
|
|
595
|
+
# Aggregate by date and app
|
|
596
|
+
aggregated = app_df.groupby(['date', 'app_id']).agg({
|
|
597
|
+
'spend': 'sum',
|
|
598
|
+
'impressions': 'sum',
|
|
599
|
+
'taps': 'sum',
|
|
600
|
+
'installs': 'sum',
|
|
601
|
+
'campaign_id': 'nunique'
|
|
602
|
+
}).reset_index()
|
|
603
|
+
|
|
604
|
+
# Rename columns to match standard format
|
|
605
|
+
aggregated.rename(columns={
|
|
606
|
+
'campaign_id': 'campaigns'
|
|
607
|
+
}, inplace=True)
|
|
608
|
+
|
|
609
|
+
# Add derived metrics
|
|
610
|
+
aggregated['cpi'] = aggregated.apply(
|
|
611
|
+
lambda x: x['spend'] / x['installs'] if x['installs'] > 0 else 0,
|
|
612
|
+
axis=1
|
|
613
|
+
).round(2)
|
|
614
|
+
|
|
615
|
+
aggregated['ctr'] = aggregated.apply(
|
|
616
|
+
lambda x: (x['taps'] / x['impressions'] * 100) if x['impressions'] > 0 else 0,
|
|
617
|
+
axis=1
|
|
618
|
+
).round(2)
|
|
619
|
+
|
|
620
|
+
aggregated['cvr'] = aggregated.apply(
|
|
621
|
+
lambda x: (x['installs'] / x['taps'] * 100) if x['taps'] > 0 else 0,
|
|
622
|
+
axis=1
|
|
623
|
+
).round(2)
|
|
624
|
+
|
|
625
|
+
# Sort by date and app
|
|
626
|
+
aggregated.sort_values(['date', 'app_id'], inplace=True)
|
|
627
|
+
|
|
628
|
+
# Filter to ensure we only return data within the requested date range
|
|
629
|
+
if isinstance(start_date, str):
|
|
630
|
+
start_date = datetime.strptime(start_date, "%Y-%m-%d")
|
|
631
|
+
if isinstance(end_date, str):
|
|
632
|
+
end_date = datetime.strptime(end_date, "%Y-%m-%d")
|
|
633
|
+
|
|
634
|
+
aggregated['date_dt'] = pd.to_datetime(aggregated['date'])
|
|
635
|
+
start_date_only = start_date.date() if hasattr(start_date, 'date') else start_date
|
|
636
|
+
end_date_only = end_date.date() if hasattr(end_date, 'date') else end_date
|
|
637
|
+
|
|
638
|
+
aggregated = aggregated[
|
|
639
|
+
(aggregated['date_dt'].dt.date >= start_date_only) &
|
|
640
|
+
(aggregated['date_dt'].dt.date <= end_date_only)
|
|
641
|
+
].copy()
|
|
642
|
+
|
|
643
|
+
# Drop the temporary datetime column
|
|
644
|
+
aggregated.drop('date_dt', axis=1, inplace=True)
|
|
645
|
+
|
|
646
|
+
return aggregated
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for Apple Search Ads Python Client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AppleSearchAdsError(Exception):
|
|
7
|
+
"""Base exception for Apple Search Ads API errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthenticationError(AppleSearchAdsError):
|
|
12
|
+
"""Raised when authentication fails."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimitError(AppleSearchAdsError):
|
|
17
|
+
"""Raised when API rate limit is exceeded."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvalidRequestError(AppleSearchAdsError):
|
|
22
|
+
"""Raised when the API request is invalid."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OrganizationNotFoundError(AppleSearchAdsError):
|
|
27
|
+
"""Raised when no organization is found for the account."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigurationError(AppleSearchAdsError):
|
|
32
|
+
"""Raised when the client is not configured properly."""
|
|
33
|
+
pass
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apple-search-ads-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Python client for Apple Search Ads API v5
|
|
5
|
+
Home-page: https://github.com/bickster/apple-search-ads-python
|
|
6
|
+
Author: Bickster LLC
|
|
7
|
+
Author-email: Bickster LLC <support@bickster.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/bickster/apple-search-ads-python
|
|
10
|
+
Project-URL: Documentation, https://apple-search-ads-python.readthedocs.io/
|
|
11
|
+
Project-URL: Repository, https://github.com/bickster/apple-search-ads-python
|
|
12
|
+
Project-URL: Issues, https://github.com/bickster/apple-search-ads-python/issues
|
|
13
|
+
Keywords: apple,search,ads,api,marketing,advertising,ios,app,store
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Operating System :: OS Independent
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: PyJWT>=2.8.0
|
|
31
|
+
Requires-Dist: cryptography>=41.0.0
|
|
32
|
+
Requires-Dist: requests>=2.31.0
|
|
33
|
+
Requires-Dist: pandas>=2.0.0
|
|
34
|
+
Requires-Dist: ratelimit>=2.2.1
|
|
35
|
+
Requires-Dist: python-dateutil>=2.8.0
|
|
36
|
+
Provides-Extra: dev
|
|
37
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
38
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
39
|
+
Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
|
|
40
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
41
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
42
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
43
|
+
Requires-Dist: sphinx>=6.0.0; extra == "dev"
|
|
44
|
+
Requires-Dist: sphinx-rtd-theme>=1.0.0; extra == "dev"
|
|
45
|
+
Dynamic: author
|
|
46
|
+
Dynamic: home-page
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
Dynamic: requires-python
|
|
49
|
+
|
|
50
|
+
# Apple Search Ads Python Client
|
|
51
|
+
|
|
52
|
+
A Python client library for Apple Search Ads API v5, providing a simple and intuitive interface for managing and reporting on Apple Search Ads campaigns.
|
|
53
|
+
|
|
54
|
+
## Features
|
|
55
|
+
|
|
56
|
+
- 🔐 OAuth2 authentication with JWT
|
|
57
|
+
- 📊 Campaign performance reporting
|
|
58
|
+
- 🏢 Multi-organization support
|
|
59
|
+
- 💰 Spend tracking by app
|
|
60
|
+
- ⚡ Built-in rate limiting
|
|
61
|
+
- 🐼 Pandas DataFrames for easy data manipulation
|
|
62
|
+
- 🔄 Automatic token refresh
|
|
63
|
+
- 🎯 Type hints for better IDE support
|
|
64
|
+
- ✅ 100% test coverage
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install apple-search-ads-client
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from apple_search_ads import AppleSearchAdsClient
|
|
76
|
+
|
|
77
|
+
# Initialize the client
|
|
78
|
+
client = AppleSearchAdsClient(
|
|
79
|
+
client_id="your_client_id",
|
|
80
|
+
team_id="your_team_id",
|
|
81
|
+
key_id="your_key_id",
|
|
82
|
+
private_key_path="/path/to/private_key.p8"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Get all campaigns
|
|
86
|
+
campaigns = client.get_campaigns()
|
|
87
|
+
|
|
88
|
+
# Get daily spend for the last 30 days
|
|
89
|
+
spend_df = client.get_daily_spend(days=30)
|
|
90
|
+
print(spend_df)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Authentication
|
|
94
|
+
|
|
95
|
+
### Prerequisites
|
|
96
|
+
|
|
97
|
+
1. An Apple Search Ads account with API access
|
|
98
|
+
2. API credentials from the Apple Search Ads UI:
|
|
99
|
+
- Client ID
|
|
100
|
+
- Team ID
|
|
101
|
+
- Key ID
|
|
102
|
+
- Private key file (.p8)
|
|
103
|
+
|
|
104
|
+
### Setting up credentials
|
|
105
|
+
|
|
106
|
+
You can provide credentials in three ways:
|
|
107
|
+
|
|
108
|
+
#### 1. Direct parameters (recommended)
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
client = AppleSearchAdsClient(
|
|
112
|
+
client_id="your_client_id",
|
|
113
|
+
team_id="your_team_id",
|
|
114
|
+
key_id="your_key_id",
|
|
115
|
+
private_key_path="/path/to/private_key.p8"
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
#### 2. Environment variables
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
export APPLE_SEARCH_ADS_CLIENT_ID="your_client_id"
|
|
123
|
+
export APPLE_SEARCH_ADS_TEAM_ID="your_team_id"
|
|
124
|
+
export APPLE_SEARCH_ADS_KEY_ID="your_key_id"
|
|
125
|
+
export APPLE_SEARCH_ADS_PRIVATE_KEY_PATH="/path/to/private_key.p8"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
client = AppleSearchAdsClient() # Will use environment variables
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### 3. Private key content
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
# Useful for environments where file access is limited
|
|
136
|
+
with open("private_key.p8", "r") as f:
|
|
137
|
+
private_key_content = f.read()
|
|
138
|
+
|
|
139
|
+
client = AppleSearchAdsClient(
|
|
140
|
+
client_id="your_client_id",
|
|
141
|
+
team_id="your_team_id",
|
|
142
|
+
key_id="your_key_id",
|
|
143
|
+
private_key_content=private_key_content
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Usage Examples
|
|
148
|
+
|
|
149
|
+
### Get all organizations
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# List all organizations you have access to
|
|
153
|
+
orgs = client.get_all_organizations()
|
|
154
|
+
for org in orgs:
|
|
155
|
+
print(f"{org['orgName']} - {org['orgId']}")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Get campaign performance report
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from datetime import datetime, timedelta
|
|
162
|
+
|
|
163
|
+
# Get campaign performance for the last 7 days
|
|
164
|
+
end_date = datetime.now()
|
|
165
|
+
start_date = end_date - timedelta(days=7)
|
|
166
|
+
|
|
167
|
+
report_df = client.get_campaign_report(
|
|
168
|
+
start_date=start_date,
|
|
169
|
+
end_date=end_date,
|
|
170
|
+
granularity="DAILY" # Options: DAILY, WEEKLY, MONTHLY
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Display key metrics
|
|
174
|
+
print(report_df[['date', 'campaign_name', 'spend', 'installs', 'taps']])
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Track spend by app
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
# Get daily spend grouped by app
|
|
181
|
+
app_spend_df = client.get_daily_spend_by_app(
|
|
182
|
+
start_date="2024-01-01",
|
|
183
|
+
end_date="2024-01-31",
|
|
184
|
+
fetch_all_orgs=True # Fetch from all organizations
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Group by app and sum
|
|
188
|
+
app_totals = app_spend_df.groupby('app_id').agg({
|
|
189
|
+
'spend': 'sum',
|
|
190
|
+
'installs': 'sum',
|
|
191
|
+
'impressions': 'sum'
|
|
192
|
+
}).round(2)
|
|
193
|
+
|
|
194
|
+
print(app_totals)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Get campaigns from all organizations
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# Fetch campaigns across all organizations
|
|
201
|
+
all_campaigns = client.get_all_campaigns()
|
|
202
|
+
|
|
203
|
+
# Filter active campaigns
|
|
204
|
+
active_campaigns = [c for c in all_campaigns if c['status'] == 'ENABLED']
|
|
205
|
+
|
|
206
|
+
print(f"Found {len(active_campaigns)} active campaigns across all orgs")
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Working with specific organization
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
# Get campaigns for a specific org
|
|
213
|
+
org_id = "123456"
|
|
214
|
+
campaigns = client.get_campaigns(org_id=org_id)
|
|
215
|
+
|
|
216
|
+
# The client will use this org for subsequent requests
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Working with ad groups
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
# Get ad groups for a campaign
|
|
223
|
+
campaign_id = "1234567890"
|
|
224
|
+
adgroups = client.get_adgroups(campaign_id)
|
|
225
|
+
|
|
226
|
+
for adgroup in adgroups:
|
|
227
|
+
print(f"Ad Group: {adgroup['name']} (Status: {adgroup['status']})")
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## API Reference
|
|
231
|
+
|
|
232
|
+
### Client initialization
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
AppleSearchAdsClient(
|
|
236
|
+
client_id: Optional[str] = None,
|
|
237
|
+
team_id: Optional[str] = None,
|
|
238
|
+
key_id: Optional[str] = None,
|
|
239
|
+
private_key_path: Optional[str] = None,
|
|
240
|
+
private_key_content: Optional[str] = None,
|
|
241
|
+
org_id: Optional[str] = None
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Methods
|
|
246
|
+
|
|
247
|
+
#### Organizations
|
|
248
|
+
|
|
249
|
+
- `get_all_organizations()` - Get all organizations
|
|
250
|
+
- `get_campaigns(org_id: Optional[str] = None)` - Get campaigns for an organization
|
|
251
|
+
- `get_all_campaigns()` - Get campaigns from all organizations
|
|
252
|
+
|
|
253
|
+
#### Reporting
|
|
254
|
+
|
|
255
|
+
- `get_campaign_report(start_date, end_date, granularity="DAILY")` - Get campaign performance report
|
|
256
|
+
- `get_daily_spend(days=30, fetch_all_orgs=True)` - Get daily spend for the last N days
|
|
257
|
+
- `get_daily_spend_with_dates(start_date, end_date, fetch_all_orgs=True)` - Get daily spend for date range
|
|
258
|
+
- `get_daily_spend_by_app(start_date, end_date, fetch_all_orgs=True)` - Get spend grouped by app
|
|
259
|
+
|
|
260
|
+
#### Campaign Management
|
|
261
|
+
|
|
262
|
+
- `get_campaigns_with_details(fetch_all_orgs=True)` - Get campaigns with app details
|
|
263
|
+
- `get_adgroups(campaign_id)` - Get ad groups for a specific campaign
|
|
264
|
+
|
|
265
|
+
## DataFrame Output
|
|
266
|
+
|
|
267
|
+
All reporting methods return pandas DataFrames for easy data manipulation:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
# Example: Calculate weekly totals
|
|
271
|
+
daily_spend = client.get_daily_spend(days=30)
|
|
272
|
+
daily_spend['week'] = pd.to_datetime(daily_spend['date']).dt.isocalendar().week
|
|
273
|
+
weekly_totals = daily_spend.groupby('week')['spend'].sum()
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Rate Limiting
|
|
277
|
+
|
|
278
|
+
The client includes built-in rate limiting to respect Apple's API limits (10 requests per second). You don't need to implement any additional rate limiting.
|
|
279
|
+
|
|
280
|
+
## Error Handling
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from apple_search_ads.exceptions import (
|
|
284
|
+
AuthenticationError,
|
|
285
|
+
RateLimitError,
|
|
286
|
+
OrganizationNotFoundError
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
campaigns = client.get_campaigns()
|
|
291
|
+
except AuthenticationError as e:
|
|
292
|
+
print(f"Authentication failed: {e}")
|
|
293
|
+
except RateLimitError as e:
|
|
294
|
+
print(f"Rate limit exceeded: {e}")
|
|
295
|
+
except Exception as e:
|
|
296
|
+
print(f"An error occurred: {e}")
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Best Practices
|
|
300
|
+
|
|
301
|
+
1. **Reuse client instances**: Create one client and reuse it for multiple requests
|
|
302
|
+
2. **Use date ranges wisely**: Large date ranges may result in slower responses
|
|
303
|
+
3. **Cache organization IDs**: If working with specific orgs frequently, cache their IDs
|
|
304
|
+
4. **Monitor rate limits**: Although built-in rate limiting is included, be mindful of your usage
|
|
305
|
+
5. **Use DataFrame operations**: Leverage pandas for data aggregation and analysis
|
|
306
|
+
|
|
307
|
+
## Requirements
|
|
308
|
+
|
|
309
|
+
- Python 3.8 or higher
|
|
310
|
+
- See `requirements.txt` for package dependencies
|
|
311
|
+
|
|
312
|
+
## Testing
|
|
313
|
+
|
|
314
|
+
This project maintains **100% test coverage**. The test suite includes:
|
|
315
|
+
|
|
316
|
+
- Unit tests with mocked API responses
|
|
317
|
+
- Exception handling tests
|
|
318
|
+
- Edge case coverage
|
|
319
|
+
- Legacy API format compatibility tests
|
|
320
|
+
- Comprehensive integration tests
|
|
321
|
+
|
|
322
|
+
### Running Tests
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
# Run all tests with coverage report
|
|
326
|
+
pytest tests -v --cov=apple_search_ads --cov-report=term-missing
|
|
327
|
+
|
|
328
|
+
# Run tests in parallel for faster execution
|
|
329
|
+
pytest tests -n auto
|
|
330
|
+
|
|
331
|
+
# Generate HTML coverage report
|
|
332
|
+
pytest tests --cov=apple_search_ads --cov-report=html
|
|
333
|
+
|
|
334
|
+
# Run integration tests (requires credentials)
|
|
335
|
+
pytest tests/test_integration.py -v
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
For detailed testing documentation, see [TESTING.md](TESTING.md).
|
|
339
|
+
For integration testing setup, see [docs/integration-testing.md](docs/integration-testing.md).
|
|
340
|
+
|
|
341
|
+
## Contributing
|
|
342
|
+
|
|
343
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
344
|
+
|
|
345
|
+
1. Fork the repository
|
|
346
|
+
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
|
347
|
+
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
|
348
|
+
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
|
349
|
+
5. Open a Pull Request
|
|
350
|
+
|
|
351
|
+
## License
|
|
352
|
+
|
|
353
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
354
|
+
|
|
355
|
+
## Support
|
|
356
|
+
|
|
357
|
+
- 🐛 Issues: [GitHub Issues](https://github.com/bickster/apple-search-ads-python/issues)
|
|
358
|
+
- 📖 Documentation: [Read the Docs](https://apple-search-ads-python.readthedocs.io/)
|
|
359
|
+
|
|
360
|
+
## Changelog
|
|
361
|
+
|
|
362
|
+
See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
|
|
363
|
+
|
|
364
|
+
## Acknowledgments
|
|
365
|
+
|
|
366
|
+
- Apple for providing the Search Ads API
|
|
367
|
+
- The Python community for excellent libraries used in this project
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
apple_search_ads/__init__.py,sha256=ezclNXkjRbYpsSrx1TV8DrRV1dGH5Pf4nephHtY_DtM,255
|
|
2
|
+
apple_search_ads/client.py,sha256=J9Fw_ghyms2P1x5K7LXAjZ02twhTeH9YvfE5Fa9Vh-w,23708
|
|
3
|
+
apple_search_ads/exceptions.py,sha256=NNnpFwGZHtfKI_nAp35zz_s02C-zWhONSDDoEsP66Rc,732
|
|
4
|
+
apple_search_ads_client-1.0.0.dist-info/licenses/LICENSE,sha256=UtWDM6w7nMyyFqRyOZnsYCr408jfBPWGJGVXAx8pIKM,1065
|
|
5
|
+
apple_search_ads_client-1.0.0.dist-info/METADATA,sha256=coZ0bOeYsXu7Ocmy_21dAp4o6NPfCur8Nbi9zDfd8a0,10296
|
|
6
|
+
apple_search_ads_client-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
apple_search_ads_client-1.0.0.dist-info/top_level.txt,sha256=VhpVXXfA5PVMfMnKWEerJP-NvvjQIUQdV5h-jU2CJyQ,17
|
|
8
|
+
apple_search_ads_client-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Your Name
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apple_search_ads
|