apple-search-ads-client 1.0.0__py3-none-any.whl → 1.0.3__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 +1 -1
- apple_search_ads/client.py +250 -257
- apple_search_ads/exceptions.py +7 -1
- {apple_search_ads_client-1.0.0.dist-info → apple_search_ads_client-1.0.3.dist-info}/METADATA +2 -3
- apple_search_ads_client-1.0.3.dist-info/RECORD +8 -0
- apple_search_ads_client-1.0.0.dist-info/RECORD +0 -8
- {apple_search_ads_client-1.0.0.dist-info → apple_search_ads_client-1.0.3.dist-info}/WHEEL +0 -0
- {apple_search_ads_client-1.0.0.dist-info → apple_search_ads_client-1.0.3.dist-info}/licenses/LICENSE +0 -0
- {apple_search_ads_client-1.0.0.dist-info → apple_search_ads_client-1.0.3.dist-info}/top_level.txt +0 -0
apple_search_ads/__init__.py
CHANGED
apple_search_ads/client.py
CHANGED
|
@@ -9,7 +9,7 @@ import time
|
|
|
9
9
|
import os
|
|
10
10
|
import requests
|
|
11
11
|
from datetime import datetime, timedelta
|
|
12
|
-
from typing import Dict, List, Optional,
|
|
12
|
+
from typing import Dict, List, Optional, Union, Any
|
|
13
13
|
import pandas as pd
|
|
14
14
|
from ratelimit import limits, sleep_and_retry
|
|
15
15
|
|
|
@@ -17,18 +17,18 @@ from ratelimit import limits, sleep_and_retry
|
|
|
17
17
|
class AppleSearchAdsClient:
|
|
18
18
|
"""
|
|
19
19
|
Client for Apple Search Ads API v5.
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
This client provides methods to interact with Apple Search Ads API,
|
|
22
22
|
including campaign management, reporting, and spend tracking.
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
Args:
|
|
25
25
|
client_id: Apple Search Ads client ID
|
|
26
|
-
team_id: Apple Search Ads team ID
|
|
26
|
+
team_id: Apple Search Ads team ID
|
|
27
27
|
key_id: Apple Search Ads key ID
|
|
28
28
|
private_key_path: Path to the private key .p8 file
|
|
29
29
|
private_key_content: Private key content as string (alternative to file path)
|
|
30
30
|
org_id: Optional organization ID (will be fetched automatically if not provided)
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
Example:
|
|
33
33
|
>>> client = AppleSearchAdsClient(
|
|
34
34
|
... client_id="your_client_id",
|
|
@@ -38,9 +38,9 @@ class AppleSearchAdsClient:
|
|
|
38
38
|
... )
|
|
39
39
|
>>> campaigns = client.get_campaigns()
|
|
40
40
|
"""
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
BASE_URL = "https://api.searchads.apple.com/api/v5"
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
def __init__(
|
|
45
45
|
self,
|
|
46
46
|
client_id: Optional[str] = None,
|
|
@@ -48,185 +48,181 @@ class AppleSearchAdsClient:
|
|
|
48
48
|
key_id: Optional[str] = None,
|
|
49
49
|
private_key_path: Optional[str] = None,
|
|
50
50
|
private_key_content: Optional[str] = None,
|
|
51
|
-
org_id: Optional[str] = None
|
|
51
|
+
org_id: Optional[str] = None,
|
|
52
52
|
):
|
|
53
53
|
# Try to get credentials from parameters, then environment variables
|
|
54
|
-
self.client_id = client_id or os.environ.get(
|
|
55
|
-
self.team_id = team_id or os.environ.get(
|
|
56
|
-
self.key_id = key_id or os.environ.get(
|
|
57
|
-
self.private_key_path = private_key_path or os.environ.get(
|
|
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(
|
|
58
|
+
"APPLE_SEARCH_ADS_PRIVATE_KEY_PATH"
|
|
59
|
+
)
|
|
58
60
|
self.private_key_content = private_key_content
|
|
59
|
-
|
|
61
|
+
|
|
60
62
|
# Validate required credentials
|
|
61
63
|
if not all([self.client_id, self.team_id, self.key_id]):
|
|
62
64
|
raise ValueError(
|
|
63
65
|
"Missing required credentials. Please provide client_id, team_id, and key_id "
|
|
64
66
|
"either as parameters or environment variables."
|
|
65
67
|
)
|
|
66
|
-
|
|
68
|
+
|
|
67
69
|
if not self.private_key_path and not self.private_key_content:
|
|
68
70
|
raise ValueError(
|
|
69
71
|
"Missing private key. Please provide either private_key_path or private_key_content."
|
|
70
72
|
)
|
|
71
|
-
|
|
73
|
+
|
|
72
74
|
self.org_id = org_id
|
|
73
|
-
self._token = None
|
|
74
|
-
self._token_expiry = None
|
|
75
|
-
|
|
75
|
+
self._token: Optional[str] = None
|
|
76
|
+
self._token_expiry: Optional[float] = None
|
|
77
|
+
|
|
76
78
|
def _load_private_key(self) -> str:
|
|
77
79
|
"""Load private key from file or content."""
|
|
78
80
|
if self.private_key_content:
|
|
79
81
|
return self.private_key_content
|
|
80
|
-
|
|
82
|
+
|
|
83
|
+
if not self.private_key_path:
|
|
84
|
+
raise ValueError("No private key path provided")
|
|
85
|
+
|
|
81
86
|
if not os.path.exists(self.private_key_path):
|
|
82
87
|
raise FileNotFoundError(f"Private key file not found: {self.private_key_path}")
|
|
83
|
-
|
|
84
|
-
with open(self.private_key_path,
|
|
88
|
+
|
|
89
|
+
with open(self.private_key_path, "r") as f:
|
|
85
90
|
return f.read()
|
|
86
|
-
|
|
91
|
+
|
|
87
92
|
def _generate_client_secret(self) -> str:
|
|
88
93
|
"""Generate client secret JWT for Apple Search Ads."""
|
|
89
94
|
# Token expires in 180 days (max allowed by Apple)
|
|
90
95
|
expiry = int(time.time() + 86400 * 180)
|
|
91
|
-
|
|
96
|
+
|
|
92
97
|
payload = {
|
|
93
98
|
"sub": self.client_id,
|
|
94
99
|
"aud": "https://appleid.apple.com",
|
|
95
100
|
"iat": int(time.time()),
|
|
96
101
|
"exp": expiry,
|
|
97
|
-
"iss": self.team_id
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
headers = {
|
|
101
|
-
"alg": "ES256",
|
|
102
|
-
"kid": self.key_id
|
|
102
|
+
"iss": self.team_id,
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
|
|
105
|
+
headers = {"alg": "ES256", "kid": self.key_id}
|
|
106
|
+
|
|
105
107
|
private_key = self._load_private_key()
|
|
106
|
-
|
|
107
|
-
return jwt.encode(
|
|
108
|
-
|
|
109
|
-
private_key,
|
|
110
|
-
algorithm="ES256",
|
|
111
|
-
headers=headers
|
|
112
|
-
)
|
|
113
|
-
|
|
108
|
+
|
|
109
|
+
return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
|
|
110
|
+
|
|
114
111
|
def _get_access_token(self) -> str:
|
|
115
112
|
"""Get access token using client credentials flow."""
|
|
116
113
|
if self._token and self._token_expiry and time.time() < self._token_expiry:
|
|
117
114
|
return self._token
|
|
118
|
-
|
|
115
|
+
|
|
119
116
|
token_url = "https://appleid.apple.com/auth/oauth2/token"
|
|
120
|
-
|
|
117
|
+
|
|
121
118
|
data = {
|
|
122
119
|
"grant_type": "client_credentials",
|
|
123
120
|
"client_id": self.client_id,
|
|
124
121
|
"client_secret": self._generate_client_secret(),
|
|
125
|
-
"scope": "searchadsorg"
|
|
122
|
+
"scope": "searchadsorg",
|
|
126
123
|
}
|
|
127
|
-
|
|
124
|
+
|
|
128
125
|
response = requests.post(token_url, data=data)
|
|
129
126
|
response.raise_for_status()
|
|
130
|
-
|
|
127
|
+
|
|
131
128
|
token_data = response.json()
|
|
132
129
|
self._token = token_data["access_token"]
|
|
133
130
|
# Token expires in 1 hour, refresh 5 minutes before
|
|
134
131
|
self._token_expiry = time.time() + 3300
|
|
135
|
-
|
|
132
|
+
|
|
133
|
+
if self._token is None:
|
|
134
|
+
raise ValueError("Failed to obtain access token")
|
|
136
135
|
return self._token
|
|
137
|
-
|
|
136
|
+
|
|
138
137
|
def _get_headers(self, include_org_context: bool = True) -> Dict[str, str]:
|
|
139
138
|
"""Get headers for API requests."""
|
|
140
139
|
headers = {
|
|
141
140
|
"Authorization": f"Bearer {self._get_access_token()}",
|
|
142
|
-
"Content-Type": "application/json"
|
|
141
|
+
"Content-Type": "application/json",
|
|
143
142
|
}
|
|
144
|
-
|
|
143
|
+
|
|
145
144
|
# Add organization context if we have it (not needed for ACLs endpoint)
|
|
146
145
|
if include_org_context and self.org_id:
|
|
147
146
|
headers["X-AP-Context"] = f"orgId={self.org_id}"
|
|
148
|
-
|
|
147
|
+
|
|
149
148
|
return headers
|
|
150
|
-
|
|
149
|
+
|
|
151
150
|
def _get_org_id(self) -> str:
|
|
152
151
|
"""Get the organization ID."""
|
|
153
152
|
if self.org_id:
|
|
154
153
|
return self.org_id
|
|
155
|
-
|
|
154
|
+
|
|
156
155
|
response = self._make_request(f"{self.BASE_URL}/acls", include_org_context=False)
|
|
157
|
-
|
|
156
|
+
|
|
158
157
|
if response and "data" in response and len(response["data"]) > 0:
|
|
159
158
|
self.org_id = str(response["data"][0]["orgId"])
|
|
160
159
|
return self.org_id
|
|
161
|
-
|
|
160
|
+
|
|
162
161
|
raise ValueError("No organization found for this account")
|
|
163
|
-
|
|
162
|
+
|
|
164
163
|
@sleep_and_retry
|
|
165
164
|
@limits(calls=10, period=1) # Apple Search Ads rate limit
|
|
166
165
|
def _make_request(
|
|
167
|
-
self,
|
|
168
|
-
url: str,
|
|
169
|
-
method: str = "GET",
|
|
170
|
-
json_data: Optional[Dict] = None,
|
|
166
|
+
self,
|
|
167
|
+
url: str,
|
|
168
|
+
method: str = "GET",
|
|
169
|
+
json_data: Optional[Dict] = None,
|
|
171
170
|
params: Optional[Dict] = None,
|
|
172
|
-
include_org_context: bool = True
|
|
173
|
-
) -> Dict:
|
|
171
|
+
include_org_context: bool = True,
|
|
172
|
+
) -> Dict[str, Any]:
|
|
174
173
|
"""Make a rate-limited request to the API."""
|
|
175
174
|
response = requests.request(
|
|
176
175
|
method=method,
|
|
177
176
|
url=url,
|
|
178
177
|
headers=self._get_headers(include_org_context=include_org_context),
|
|
179
178
|
json=json_data,
|
|
180
|
-
params=params
|
|
179
|
+
params=params,
|
|
181
180
|
)
|
|
182
181
|
response.raise_for_status()
|
|
183
182
|
return response.json()
|
|
184
|
-
|
|
185
|
-
def get_all_organizations(self) -> List[Dict]:
|
|
183
|
+
|
|
184
|
+
def get_all_organizations(self) -> List[Dict[str, Any]]:
|
|
186
185
|
"""
|
|
187
186
|
Get all organizations the user has access to.
|
|
188
|
-
|
|
187
|
+
|
|
189
188
|
Returns:
|
|
190
189
|
List of organization dictionaries containing orgId, orgName, etc.
|
|
191
190
|
"""
|
|
192
191
|
response = self._make_request(f"{self.BASE_URL}/acls", include_org_context=False)
|
|
193
|
-
|
|
192
|
+
|
|
194
193
|
if response and "data" in response:
|
|
195
194
|
return response["data"]
|
|
196
|
-
|
|
195
|
+
|
|
197
196
|
return []
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def get_adgroups(self, campaign_id: str) -> List[Dict]:
|
|
197
|
+
|
|
198
|
+
def get_adgroups(self, campaign_id: str) -> List[Dict[str, Any]]:
|
|
201
199
|
"""
|
|
202
200
|
Get all ad groups for a specific campaign.
|
|
203
|
-
|
|
201
|
+
|
|
204
202
|
Args:
|
|
205
203
|
campaign_id: The campaign ID to get ad groups for
|
|
206
|
-
|
|
204
|
+
|
|
207
205
|
Returns:
|
|
208
206
|
List of ad group dictionaries
|
|
209
207
|
"""
|
|
210
208
|
# Ensure we have org_id for the context header
|
|
211
209
|
if not self.org_id:
|
|
212
210
|
self._get_org_id()
|
|
213
|
-
|
|
211
|
+
|
|
214
212
|
url = f"{self.BASE_URL}/campaigns/{campaign_id}/adgroups"
|
|
215
|
-
|
|
216
|
-
params = {
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
213
|
+
|
|
214
|
+
params = {"limit": 1000}
|
|
215
|
+
|
|
220
216
|
response = self._make_request(url, params=params)
|
|
221
217
|
return response.get("data", [])
|
|
222
|
-
|
|
223
|
-
def get_campaigns(self, org_id: Optional[str] = None) -> List[Dict]:
|
|
218
|
+
|
|
219
|
+
def get_campaigns(self, org_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
224
220
|
"""
|
|
225
221
|
Get all campaigns for a specific organization or the default one.
|
|
226
|
-
|
|
222
|
+
|
|
227
223
|
Args:
|
|
228
224
|
org_id: Optional organization ID. If not provided, uses the default org.
|
|
229
|
-
|
|
225
|
+
|
|
230
226
|
Returns:
|
|
231
227
|
List of campaign dictionaries.
|
|
232
228
|
"""
|
|
@@ -237,84 +233,82 @@ class AppleSearchAdsClient:
|
|
|
237
233
|
self.org_id = str(org_id)
|
|
238
234
|
elif not self.org_id:
|
|
239
235
|
self._get_org_id()
|
|
240
|
-
|
|
236
|
+
|
|
241
237
|
url = f"{self.BASE_URL}/campaigns"
|
|
242
|
-
|
|
243
|
-
params = {
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
238
|
+
|
|
239
|
+
params = {"limit": 1000}
|
|
240
|
+
|
|
247
241
|
try:
|
|
248
242
|
response = self._make_request(url, params=params)
|
|
249
243
|
campaigns = response.get("data", [])
|
|
250
|
-
|
|
244
|
+
|
|
251
245
|
# Add org_id to each campaign for tracking
|
|
252
246
|
for campaign in campaigns:
|
|
253
247
|
campaign["fetched_org_id"] = self.org_id
|
|
254
|
-
|
|
248
|
+
|
|
255
249
|
return campaigns
|
|
256
250
|
finally:
|
|
257
251
|
# Restore original org_id if we changed it
|
|
258
252
|
if original_org_id is not None:
|
|
259
253
|
self.org_id = original_org_id
|
|
260
|
-
|
|
261
|
-
def get_all_campaigns(self) -> List[Dict]:
|
|
254
|
+
|
|
255
|
+
def get_all_campaigns(self) -> List[Dict[str, Any]]:
|
|
262
256
|
"""
|
|
263
257
|
Get campaigns from all organizations.
|
|
264
|
-
|
|
258
|
+
|
|
265
259
|
Returns:
|
|
266
260
|
List of all campaigns across all organizations.
|
|
267
261
|
"""
|
|
268
262
|
all_campaigns = []
|
|
269
263
|
organizations = self.get_all_organizations()
|
|
270
|
-
|
|
264
|
+
|
|
271
265
|
for org in organizations:
|
|
272
266
|
org_id = str(org["orgId"])
|
|
273
267
|
org_name = org.get("orgName", "Unknown")
|
|
274
|
-
|
|
268
|
+
|
|
275
269
|
try:
|
|
276
270
|
campaigns = self.get_campaigns(org_id=org_id)
|
|
277
|
-
|
|
271
|
+
|
|
278
272
|
# Add organization info to each campaign
|
|
279
273
|
for campaign in campaigns:
|
|
280
274
|
campaign["org_name"] = org_name
|
|
281
275
|
campaign["parent_org_id"] = org.get("parentOrgId")
|
|
282
|
-
|
|
276
|
+
|
|
283
277
|
all_campaigns.extend(campaigns)
|
|
284
278
|
except Exception as e:
|
|
285
279
|
print(f"Error fetching campaigns from {org_name}: {e}")
|
|
286
|
-
|
|
280
|
+
|
|
287
281
|
return all_campaigns
|
|
288
|
-
|
|
282
|
+
|
|
289
283
|
def get_campaign_report(
|
|
290
|
-
self,
|
|
291
|
-
start_date: Union[datetime, str],
|
|
284
|
+
self,
|
|
285
|
+
start_date: Union[datetime, str],
|
|
292
286
|
end_date: Union[datetime, str],
|
|
293
|
-
granularity: str = "DAILY"
|
|
287
|
+
granularity: str = "DAILY",
|
|
294
288
|
) -> pd.DataFrame:
|
|
295
289
|
"""
|
|
296
290
|
Get campaign performance report.
|
|
297
|
-
|
|
291
|
+
|
|
298
292
|
Args:
|
|
299
293
|
start_date: Start date for the report (datetime or YYYY-MM-DD string)
|
|
300
294
|
end_date: End date for the report (datetime or YYYY-MM-DD string)
|
|
301
295
|
granularity: DAILY, WEEKLY, or MONTHLY
|
|
302
|
-
|
|
296
|
+
|
|
303
297
|
Returns:
|
|
304
298
|
DataFrame with campaign performance metrics.
|
|
305
299
|
"""
|
|
306
300
|
# Ensure we have org_id for the context header
|
|
307
301
|
if not self.org_id:
|
|
308
302
|
self._get_org_id()
|
|
309
|
-
|
|
303
|
+
|
|
310
304
|
# Convert string dates to datetime if needed
|
|
311
305
|
if isinstance(start_date, str):
|
|
312
306
|
start_date = datetime.strptime(start_date, "%Y-%m-%d")
|
|
313
307
|
if isinstance(end_date, str):
|
|
314
308
|
end_date = datetime.strptime(end_date, "%Y-%m-%d")
|
|
315
|
-
|
|
309
|
+
|
|
316
310
|
url = f"{self.BASE_URL}/reports/campaigns"
|
|
317
|
-
|
|
311
|
+
|
|
318
312
|
# Apple Search Ads API uses date-only format
|
|
319
313
|
request_data = {
|
|
320
314
|
"startTime": start_date.strftime("%Y-%m-%d"),
|
|
@@ -322,130 +316,133 @@ class AppleSearchAdsClient:
|
|
|
322
316
|
"granularity": granularity,
|
|
323
317
|
"selector": {
|
|
324
318
|
"orderBy": [{"field": "localSpend", "sortOrder": "DESCENDING"}],
|
|
325
|
-
"pagination": {"limit": 1000}
|
|
319
|
+
"pagination": {"limit": 1000},
|
|
326
320
|
},
|
|
327
321
|
"returnRowTotals": True,
|
|
328
|
-
"returnRecordsWithNoMetrics": False
|
|
322
|
+
"returnRecordsWithNoMetrics": False,
|
|
329
323
|
}
|
|
330
|
-
|
|
331
|
-
response = self._make_request(
|
|
332
|
-
|
|
333
|
-
method="POST",
|
|
334
|
-
json_data=request_data
|
|
335
|
-
)
|
|
336
|
-
|
|
324
|
+
|
|
325
|
+
response = self._make_request(url, method="POST", json_data=request_data)
|
|
326
|
+
|
|
337
327
|
# Handle different response structures
|
|
338
328
|
rows = []
|
|
339
329
|
if response and "data" in response:
|
|
340
|
-
if
|
|
330
|
+
if (
|
|
331
|
+
"reportingDataResponse" in response["data"]
|
|
332
|
+
and "row" in response["data"]["reportingDataResponse"]
|
|
333
|
+
):
|
|
341
334
|
# New response structure
|
|
342
335
|
rows = response["data"]["reportingDataResponse"]["row"]
|
|
343
336
|
elif "rows" in response["data"]:
|
|
344
337
|
# Old response structure
|
|
345
338
|
rows = response["data"]["rows"]
|
|
346
|
-
|
|
339
|
+
|
|
347
340
|
if rows:
|
|
348
341
|
# Extract data into a flat structure
|
|
349
342
|
data = []
|
|
350
343
|
for row in rows:
|
|
351
344
|
metadata = row.get("metadata", {})
|
|
352
|
-
|
|
345
|
+
|
|
353
346
|
# For the new structure, we need to process granularity data
|
|
354
347
|
if "granularity" in row:
|
|
355
348
|
# New structure: iterate through each day in granularity
|
|
356
349
|
for day_data in row["granularity"]:
|
|
357
|
-
data.append(
|
|
358
|
-
|
|
350
|
+
data.append(
|
|
351
|
+
{
|
|
352
|
+
"date": day_data.get("date"),
|
|
353
|
+
"campaign_id": metadata.get("campaignId"),
|
|
354
|
+
"campaign_name": metadata.get("campaignName"),
|
|
355
|
+
"campaign_status": metadata.get("campaignStatus"),
|
|
356
|
+
"app_name": (
|
|
357
|
+
metadata.get("app", {}).get("appName")
|
|
358
|
+
if "app" in metadata
|
|
359
|
+
else metadata.get("appName")
|
|
360
|
+
),
|
|
361
|
+
"adam_id": metadata.get("adamId"),
|
|
362
|
+
"impressions": day_data.get("impressions", 0),
|
|
363
|
+
"taps": day_data.get("taps", 0),
|
|
364
|
+
"installs": day_data.get("totalInstalls", 0),
|
|
365
|
+
"spend": float(day_data.get("localSpend", {}).get("amount", 0)),
|
|
366
|
+
"currency": day_data.get("localSpend", {}).get("currency", "USD"),
|
|
367
|
+
"avg_cpa": float(day_data.get("totalAvgCPI", {}).get("amount", 0)),
|
|
368
|
+
"avg_cpt": float(day_data.get("avgCPT", {}).get("amount", 0)),
|
|
369
|
+
"ttr": day_data.get("ttr", 0),
|
|
370
|
+
"conversion_rate": day_data.get("totalInstallRate", 0),
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
# Old structure
|
|
375
|
+
metrics = row.get("metrics", {})
|
|
376
|
+
|
|
377
|
+
data.append(
|
|
378
|
+
{
|
|
379
|
+
"date": metadata.get("date"),
|
|
359
380
|
"campaign_id": metadata.get("campaignId"),
|
|
360
381
|
"campaign_name": metadata.get("campaignName"),
|
|
361
382
|
"campaign_status": metadata.get("campaignStatus"),
|
|
362
|
-
"app_name": metadata.get("
|
|
383
|
+
"app_name": metadata.get("appName"),
|
|
363
384
|
"adam_id": metadata.get("adamId"),
|
|
364
|
-
"impressions":
|
|
365
|
-
"taps":
|
|
366
|
-
"installs":
|
|
367
|
-
"spend": float(
|
|
368
|
-
"currency":
|
|
369
|
-
"avg_cpa": float(
|
|
370
|
-
"avg_cpt": float(
|
|
371
|
-
"ttr":
|
|
372
|
-
"conversion_rate":
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
+
|
|
396
397
|
return pd.DataFrame(data)
|
|
397
|
-
|
|
398
|
+
|
|
398
399
|
return pd.DataFrame()
|
|
399
|
-
|
|
400
|
-
def get_daily_spend(
|
|
401
|
-
self,
|
|
402
|
-
days: int = 30,
|
|
403
|
-
fetch_all_orgs: bool = True
|
|
404
|
-
) -> pd.DataFrame:
|
|
400
|
+
|
|
401
|
+
def get_daily_spend(self, days: int = 30, fetch_all_orgs: bool = True) -> pd.DataFrame:
|
|
405
402
|
"""
|
|
406
403
|
Get daily spend across all campaigns.
|
|
407
|
-
|
|
404
|
+
|
|
408
405
|
Args:
|
|
409
406
|
days: Number of days to fetch
|
|
410
407
|
fetch_all_orgs: If True, fetches from all organizations
|
|
411
|
-
|
|
408
|
+
|
|
412
409
|
Returns:
|
|
413
410
|
DataFrame with daily spend metrics.
|
|
414
411
|
"""
|
|
415
412
|
end_date = datetime.now()
|
|
416
413
|
start_date = end_date - timedelta(days=days)
|
|
417
|
-
|
|
414
|
+
|
|
418
415
|
return self.get_daily_spend_with_dates(start_date, end_date, fetch_all_orgs)
|
|
419
|
-
|
|
416
|
+
|
|
420
417
|
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
|
|
418
|
+
self,
|
|
419
|
+
start_date: Union[datetime, str],
|
|
420
|
+
end_date: Union[datetime, str],
|
|
421
|
+
fetch_all_orgs: bool = True,
|
|
425
422
|
) -> pd.DataFrame:
|
|
426
423
|
"""
|
|
427
424
|
Get daily spend across all campaigns for a specific date range.
|
|
428
|
-
|
|
425
|
+
|
|
429
426
|
Args:
|
|
430
427
|
start_date: Start date for the report
|
|
431
428
|
end_date: End date for the report
|
|
432
429
|
fetch_all_orgs: If True, fetches from all organizations
|
|
433
|
-
|
|
430
|
+
|
|
434
431
|
Returns:
|
|
435
432
|
DataFrame with daily spend metrics.
|
|
436
433
|
"""
|
|
437
434
|
all_campaign_data = []
|
|
438
|
-
|
|
435
|
+
|
|
439
436
|
if fetch_all_orgs:
|
|
440
437
|
organizations = self.get_all_organizations()
|
|
441
|
-
|
|
438
|
+
|
|
442
439
|
for org in organizations:
|
|
443
440
|
org_id = str(org["orgId"])
|
|
444
|
-
|
|
441
|
+
|
|
445
442
|
# Set org context
|
|
446
443
|
current_org_id = self.org_id
|
|
447
444
|
self.org_id = org_id
|
|
448
|
-
|
|
445
|
+
|
|
449
446
|
try:
|
|
450
447
|
# Get campaign report for this org
|
|
451
448
|
org_campaign_df = self.get_campaign_report(start_date, end_date)
|
|
@@ -460,47 +457,42 @@ class AppleSearchAdsClient:
|
|
|
460
457
|
campaign_df = self.get_campaign_report(start_date, end_date)
|
|
461
458
|
if not campaign_df.empty:
|
|
462
459
|
all_campaign_data.append(campaign_df)
|
|
463
|
-
|
|
460
|
+
|
|
464
461
|
if not all_campaign_data:
|
|
465
462
|
return pd.DataFrame()
|
|
466
|
-
|
|
463
|
+
|
|
467
464
|
# Combine all campaign data
|
|
468
465
|
campaign_df = pd.concat(all_campaign_data, ignore_index=True)
|
|
469
|
-
|
|
466
|
+
|
|
470
467
|
# Group by date
|
|
471
|
-
daily_df =
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
468
|
+
daily_df = (
|
|
469
|
+
campaign_df.groupby("date")
|
|
470
|
+
.agg({"spend": "sum", "impressions": "sum", "taps": "sum", "installs": "sum"})
|
|
471
|
+
.reset_index()
|
|
472
|
+
)
|
|
473
|
+
|
|
478
474
|
# Calculate average metrics
|
|
479
|
-
daily_df[
|
|
480
|
-
lambda row: row[
|
|
481
|
-
axis=1
|
|
475
|
+
daily_df["avg_cpi"] = daily_df.apply(
|
|
476
|
+
lambda row: row["spend"] / row["installs"] if row["installs"] > 0 else 0, axis=1
|
|
482
477
|
)
|
|
483
|
-
|
|
484
|
-
daily_df[
|
|
485
|
-
lambda row: row[
|
|
486
|
-
axis=1
|
|
478
|
+
|
|
479
|
+
daily_df["avg_cpt"] = daily_df.apply(
|
|
480
|
+
lambda row: row["spend"] / row["taps"] if row["taps"] > 0 else 0, axis=1
|
|
487
481
|
)
|
|
488
|
-
|
|
489
|
-
daily_df[
|
|
490
|
-
lambda row: row[
|
|
491
|
-
axis=1
|
|
482
|
+
|
|
483
|
+
daily_df["conversion_rate"] = daily_df.apply(
|
|
484
|
+
lambda row: row["installs"] / row["taps"] * 100 if row["taps"] > 0 else 0, axis=1
|
|
492
485
|
)
|
|
493
|
-
|
|
494
|
-
|
|
486
|
+
|
|
495
487
|
return daily_df
|
|
496
|
-
|
|
497
|
-
def get_campaigns_with_details(self, fetch_all_orgs: bool = True) -> List[Dict]:
|
|
488
|
+
|
|
489
|
+
def get_campaigns_with_details(self, fetch_all_orgs: bool = True) -> List[Dict[str, Any]]:
|
|
498
490
|
"""
|
|
499
491
|
Get all campaigns with their app details including adamId.
|
|
500
|
-
|
|
492
|
+
|
|
501
493
|
Args:
|
|
502
494
|
fetch_all_orgs: If True, fetches from all organizations
|
|
503
|
-
|
|
495
|
+
|
|
504
496
|
Returns:
|
|
505
497
|
List of campaign dictionaries with app details.
|
|
506
498
|
"""
|
|
@@ -510,23 +502,23 @@ class AppleSearchAdsClient:
|
|
|
510
502
|
if not self.org_id:
|
|
511
503
|
self._get_org_id()
|
|
512
504
|
campaigns = self.get_campaigns()
|
|
513
|
-
|
|
505
|
+
|
|
514
506
|
return campaigns
|
|
515
|
-
|
|
507
|
+
|
|
516
508
|
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
|
|
509
|
+
self,
|
|
510
|
+
start_date: Union[datetime, str],
|
|
511
|
+
end_date: Union[datetime, str],
|
|
512
|
+
fetch_all_orgs: bool = True,
|
|
521
513
|
) -> pd.DataFrame:
|
|
522
514
|
"""
|
|
523
515
|
Get daily advertising spend grouped by app (adamId).
|
|
524
|
-
|
|
516
|
+
|
|
525
517
|
Args:
|
|
526
518
|
start_date: Start date for the report
|
|
527
519
|
end_date: End date for the report
|
|
528
520
|
fetch_all_orgs: If True, fetches from all organizations
|
|
529
|
-
|
|
521
|
+
|
|
530
522
|
Returns:
|
|
531
523
|
DataFrame with columns:
|
|
532
524
|
- date: The date
|
|
@@ -540,21 +532,21 @@ class AppleSearchAdsClient:
|
|
|
540
532
|
# First, get campaign-to-app mapping
|
|
541
533
|
campaigns = self.get_campaigns_with_details(fetch_all_orgs=fetch_all_orgs)
|
|
542
534
|
campaign_to_app = {str(c["id"]): str(c.get("adamId")) for c in campaigns if c.get("adamId")}
|
|
543
|
-
|
|
535
|
+
|
|
544
536
|
# Get campaign reports from all organizations
|
|
545
537
|
all_campaign_data = []
|
|
546
|
-
|
|
538
|
+
|
|
547
539
|
if fetch_all_orgs:
|
|
548
540
|
organizations = self.get_all_organizations()
|
|
549
|
-
|
|
541
|
+
|
|
550
542
|
for org in organizations:
|
|
551
543
|
org_id = str(org["orgId"])
|
|
552
544
|
org_name = org.get("orgName", "Unknown")
|
|
553
|
-
|
|
545
|
+
|
|
554
546
|
# Set org context
|
|
555
547
|
current_org_id = self.org_id
|
|
556
548
|
self.org_id = org_id
|
|
557
|
-
|
|
549
|
+
|
|
558
550
|
try:
|
|
559
551
|
# Get campaign report for this org
|
|
560
552
|
org_campaign_df = self.get_campaign_report(start_date, end_date)
|
|
@@ -573,74 +565,75 @@ class AppleSearchAdsClient:
|
|
|
573
565
|
campaign_df = self.get_campaign_report(start_date, end_date)
|
|
574
566
|
if not campaign_df.empty:
|
|
575
567
|
all_campaign_data.append(campaign_df)
|
|
576
|
-
|
|
568
|
+
|
|
577
569
|
if not all_campaign_data:
|
|
578
570
|
return pd.DataFrame()
|
|
579
|
-
|
|
571
|
+
|
|
580
572
|
# Combine all campaign data
|
|
581
573
|
campaign_df = pd.concat(all_campaign_data, ignore_index=True)
|
|
582
|
-
|
|
574
|
+
|
|
583
575
|
# Convert campaign_id to string for mapping
|
|
584
576
|
campaign_df["campaign_id"] = campaign_df["campaign_id"].astype(str)
|
|
585
|
-
|
|
577
|
+
|
|
586
578
|
# Map campaigns to apps
|
|
587
579
|
campaign_df["app_id"] = campaign_df["campaign_id"].map(campaign_to_app)
|
|
588
|
-
|
|
580
|
+
|
|
589
581
|
# Filter out campaigns without app mapping
|
|
590
582
|
app_df = campaign_df[campaign_df["app_id"].notna()].copy()
|
|
591
|
-
|
|
583
|
+
|
|
592
584
|
if app_df.empty:
|
|
593
585
|
return pd.DataFrame()
|
|
594
|
-
|
|
586
|
+
|
|
595
587
|
# Aggregate by date and app
|
|
596
|
-
aggregated =
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
588
|
+
aggregated = (
|
|
589
|
+
app_df.groupby(["date", "app_id"])
|
|
590
|
+
.agg(
|
|
591
|
+
{
|
|
592
|
+
"spend": "sum",
|
|
593
|
+
"impressions": "sum",
|
|
594
|
+
"taps": "sum",
|
|
595
|
+
"installs": "sum",
|
|
596
|
+
"campaign_id": "nunique",
|
|
597
|
+
}
|
|
598
|
+
)
|
|
599
|
+
.reset_index()
|
|
600
|
+
)
|
|
601
|
+
|
|
604
602
|
# Rename columns to match standard format
|
|
605
|
-
aggregated.rename(columns={
|
|
606
|
-
|
|
607
|
-
}, inplace=True)
|
|
608
|
-
|
|
603
|
+
aggregated.rename(columns={"campaign_id": "campaigns"}, inplace=True)
|
|
604
|
+
|
|
609
605
|
# Add derived metrics
|
|
610
|
-
aggregated[
|
|
611
|
-
lambda x: x[
|
|
612
|
-
axis=1
|
|
606
|
+
aggregated["cpi"] = aggregated.apply(
|
|
607
|
+
lambda x: x["spend"] / x["installs"] if x["installs"] > 0 else 0, axis=1
|
|
613
608
|
).round(2)
|
|
614
|
-
|
|
615
|
-
aggregated[
|
|
616
|
-
lambda x: (x[
|
|
617
|
-
axis=1
|
|
609
|
+
|
|
610
|
+
aggregated["ctr"] = aggregated.apply(
|
|
611
|
+
lambda x: (x["taps"] / x["impressions"] * 100) if x["impressions"] > 0 else 0, axis=1
|
|
618
612
|
).round(2)
|
|
619
|
-
|
|
620
|
-
aggregated[
|
|
621
|
-
lambda x: (x[
|
|
622
|
-
axis=1
|
|
613
|
+
|
|
614
|
+
aggregated["cvr"] = aggregated.apply(
|
|
615
|
+
lambda x: (x["installs"] / x["taps"] * 100) if x["taps"] > 0 else 0, axis=1
|
|
623
616
|
).round(2)
|
|
624
|
-
|
|
617
|
+
|
|
625
618
|
# Sort by date and app
|
|
626
|
-
aggregated.sort_values([
|
|
627
|
-
|
|
619
|
+
aggregated.sort_values(["date", "app_id"], inplace=True)
|
|
620
|
+
|
|
628
621
|
# Filter to ensure we only return data within the requested date range
|
|
629
622
|
if isinstance(start_date, str):
|
|
630
623
|
start_date = datetime.strptime(start_date, "%Y-%m-%d")
|
|
631
624
|
if isinstance(end_date, str):
|
|
632
625
|
end_date = datetime.strptime(end_date, "%Y-%m-%d")
|
|
633
|
-
|
|
634
|
-
aggregated[
|
|
635
|
-
start_date_only = start_date.date() if hasattr(start_date,
|
|
636
|
-
end_date_only = end_date.date() if hasattr(end_date,
|
|
637
|
-
|
|
626
|
+
|
|
627
|
+
aggregated["date_dt"] = pd.to_datetime(aggregated["date"])
|
|
628
|
+
start_date_only = start_date.date() if hasattr(start_date, "date") else start_date
|
|
629
|
+
end_date_only = end_date.date() if hasattr(end_date, "date") else end_date
|
|
630
|
+
|
|
638
631
|
aggregated = aggregated[
|
|
639
|
-
(aggregated[
|
|
640
|
-
(aggregated[
|
|
632
|
+
(aggregated["date_dt"].dt.date >= start_date_only)
|
|
633
|
+
& (aggregated["date_dt"].dt.date <= end_date_only)
|
|
641
634
|
].copy()
|
|
642
|
-
|
|
635
|
+
|
|
643
636
|
# Drop the temporary datetime column
|
|
644
|
-
aggregated.drop(
|
|
645
|
-
|
|
646
|
-
return aggregated
|
|
637
|
+
aggregated.drop("date_dt", axis=1, inplace=True)
|
|
638
|
+
|
|
639
|
+
return aggregated
|
apple_search_ads/exceptions.py
CHANGED
|
@@ -5,29 +5,35 @@ Custom exceptions for Apple Search Ads Python Client.
|
|
|
5
5
|
|
|
6
6
|
class AppleSearchAdsError(Exception):
|
|
7
7
|
"""Base exception for Apple Search Ads API errors."""
|
|
8
|
+
|
|
8
9
|
pass
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class AuthenticationError(AppleSearchAdsError):
|
|
12
13
|
"""Raised when authentication fails."""
|
|
14
|
+
|
|
13
15
|
pass
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class RateLimitError(AppleSearchAdsError):
|
|
17
19
|
"""Raised when API rate limit is exceeded."""
|
|
20
|
+
|
|
18
21
|
pass
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
class InvalidRequestError(AppleSearchAdsError):
|
|
22
25
|
"""Raised when the API request is invalid."""
|
|
26
|
+
|
|
23
27
|
pass
|
|
24
28
|
|
|
25
29
|
|
|
26
30
|
class OrganizationNotFoundError(AppleSearchAdsError):
|
|
27
31
|
"""Raised when no organization is found for the account."""
|
|
32
|
+
|
|
28
33
|
pass
|
|
29
34
|
|
|
30
35
|
|
|
31
36
|
class ConfigurationError(AppleSearchAdsError):
|
|
32
37
|
"""Raised when the client is not configured properly."""
|
|
33
|
-
|
|
38
|
+
|
|
39
|
+
pass
|
{apple_search_ads_client-1.0.0.dist-info → apple_search_ads_client-1.0.3.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: apple-search-ads-client
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: A Python client for Apple Search Ads API v5
|
|
5
5
|
Home-page: https://github.com/bickster/apple-search-ads-python
|
|
6
6
|
Author: Bickster LLC
|
|
@@ -335,8 +335,7 @@ pytest tests --cov=apple_search_ads --cov-report=html
|
|
|
335
335
|
pytest tests/test_integration.py -v
|
|
336
336
|
```
|
|
337
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).
|
|
338
|
+
For detailed testing documentation, see [TESTING.md](TESTING.md).
|
|
340
339
|
|
|
341
340
|
## Contributing
|
|
342
341
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
apple_search_ads/__init__.py,sha256=gTvCiXK29iuMwRW62N8zQk0xz5-gkKz__EULtusy1As,256
|
|
2
|
+
apple_search_ads/client.py,sha256=85-U8bv1YKxv9dPdzFLpvXD9lucpjFPzp15PKNzFe_M,23224
|
|
3
|
+
apple_search_ads/exceptions.py,sha256=Muyug8BUP3MMz3TJJaQUVquXBCdQ03-Ic7H2OA9oUpA,739
|
|
4
|
+
apple_search_ads_client-1.0.3.dist-info/licenses/LICENSE,sha256=UtWDM6w7nMyyFqRyOZnsYCr408jfBPWGJGVXAx8pIKM,1065
|
|
5
|
+
apple_search_ads_client-1.0.3.dist-info/METADATA,sha256=3VMCxDYTZ5yyhXInaCo3X3QpyQRB1hQzjxFJzm1rwVY,10199
|
|
6
|
+
apple_search_ads_client-1.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
apple_search_ads_client-1.0.3.dist-info/top_level.txt,sha256=VhpVXXfA5PVMfMnKWEerJP-NvvjQIUQdV5h-jU2CJyQ,17
|
|
8
|
+
apple_search_ads_client-1.0.3.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
{apple_search_ads_client-1.0.0.dist-info → apple_search_ads_client-1.0.3.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{apple_search_ads_client-1.0.0.dist-info → apple_search_ads_client-1.0.3.dist-info}/top_level.txt
RENAMED
|
File without changes
|