meta-ads-mcp-python 1.0.79__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.
- meta_ads_mcp/__init__.py +79 -0
- meta_ads_mcp/__main__.py +10 -0
- meta_ads_mcp/core/__init__.py +55 -0
- meta_ads_mcp/core/accounts.py +141 -0
- meta_ads_mcp/core/ads.py +2751 -0
- meta_ads_mcp/core/ads_library.py +74 -0
- meta_ads_mcp/core/adsets.py +666 -0
- meta_ads_mcp/core/api.py +431 -0
- meta_ads_mcp/core/auth.py +567 -0
- meta_ads_mcp/core/authentication.py +207 -0
- meta_ads_mcp/core/budget_schedules.py +70 -0
- meta_ads_mcp/core/callback_server.py +256 -0
- meta_ads_mcp/core/campaigns.py +379 -0
- meta_ads_mcp/core/duplication.py +523 -0
- meta_ads_mcp/core/http_auth_integration.py +307 -0
- meta_ads_mcp/core/insights.py +161 -0
- meta_ads_mcp/core/mcc.py +232 -0
- meta_ads_mcp/core/openai_deep_research.py +418 -0
- meta_ads_mcp/core/pipeboard_auth.py +510 -0
- meta_ads_mcp/core/reports.py +135 -0
- meta_ads_mcp/core/resources.py +46 -0
- meta_ads_mcp/core/server.py +391 -0
- meta_ads_mcp/core/targeting.py +542 -0
- meta_ads_mcp/core/utils.py +225 -0
- meta_ads_mcp/settings.py +33 -0
- meta_ads_mcp_python-1.0.79.dist-info/METADATA +187 -0
- meta_ads_mcp_python-1.0.79.dist-info/RECORD +29 -0
- meta_ads_mcp_python-1.0.79.dist-info/WHEEL +4 -0
- meta_ads_mcp_python-1.0.79.dist-info/entry_points.txt +3 -0
meta_ads_mcp/core/ads.py
ADDED
|
@@ -0,0 +1,2751 @@
|
|
|
1
|
+
"""Ad and Creative-related functionality for Meta Ads API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
import io
|
|
8
|
+
|
|
9
|
+
from fastmcp.utilities.types import Image
|
|
10
|
+
from loguru import logger
|
|
11
|
+
from PIL import Image as PILImage
|
|
12
|
+
|
|
13
|
+
from .api import meta_api_tool, make_api_request, ensure_act_prefix
|
|
14
|
+
from .accounts import get_ad_accounts
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Placement asset customization helpers
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
# Maps our user-friendly placement group names to Meta API positions.
|
|
21
|
+
# customization_spec in Meta's API is the placement SELECTOR (WHERE),
|
|
22
|
+
# while image_label/video_label at the rule level is the asset REFERENCE (WHAT).
|
|
23
|
+
_PLACEMENT_GROUP_TO_POSITIONS: Dict[str, Dict[str, List[str]]] = {
|
|
24
|
+
"FEED": {
|
|
25
|
+
"publisher_platforms": ["facebook", "instagram"],
|
|
26
|
+
"facebook_positions": ["feed"],
|
|
27
|
+
"instagram_positions": ["stream", "profile_feed"],
|
|
28
|
+
},
|
|
29
|
+
"STORY": {
|
|
30
|
+
"publisher_platforms": ["facebook", "instagram"],
|
|
31
|
+
"facebook_positions": ["story"],
|
|
32
|
+
"instagram_positions": ["story"],
|
|
33
|
+
},
|
|
34
|
+
"MESSENGER": {
|
|
35
|
+
"publisher_platforms": ["messenger"],
|
|
36
|
+
},
|
|
37
|
+
"INSTREAM_VIDEO": {
|
|
38
|
+
"publisher_platforms": ["facebook"],
|
|
39
|
+
"facebook_positions": ["instream_video"],
|
|
40
|
+
},
|
|
41
|
+
"SEARCH": {
|
|
42
|
+
"publisher_platforms": ["facebook"],
|
|
43
|
+
"facebook_positions": ["search"],
|
|
44
|
+
},
|
|
45
|
+
"SHOP": {
|
|
46
|
+
"publisher_platforms": ["instagram"],
|
|
47
|
+
"instagram_positions": ["shop"],
|
|
48
|
+
},
|
|
49
|
+
"AUDIENCE_NETWORK": {
|
|
50
|
+
"publisher_platforms": ["audience_network"],
|
|
51
|
+
"audience_network_positions": ["classic", "instream_video"],
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _translate_asset_customization_rules(
|
|
57
|
+
rules: List[Dict[str, Any]],
|
|
58
|
+
images_array: List[Dict[str, Any]],
|
|
59
|
+
) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
60
|
+
"""
|
|
61
|
+
Translate user-friendly placement_groups format to Meta API format.
|
|
62
|
+
|
|
63
|
+
Our user-facing format:
|
|
64
|
+
[{"placement_groups": ["FEED"], "customization_spec": {"image_hashes": ["h1"]}},
|
|
65
|
+
{"placement_groups": ["STORY"], "customization_spec": {"image_hashes": ["h2"]}}]
|
|
66
|
+
|
|
67
|
+
Meta API format:
|
|
68
|
+
[{"customization_spec": {"publisher_platforms": [...], "facebook_positions": [...]},
|
|
69
|
+
"image_label": {"name": "PBOARD_IMG_0"}},
|
|
70
|
+
...]
|
|
71
|
+
And images in asset_feed_spec.images get adlabels assigned.
|
|
72
|
+
|
|
73
|
+
Rules that do NOT contain placement_groups are passed through unchanged
|
|
74
|
+
(allows raw Meta API format to be used directly).
|
|
75
|
+
"""
|
|
76
|
+
if not rules or not any("placement_groups" in r for r in rules):
|
|
77
|
+
return rules, images_array
|
|
78
|
+
|
|
79
|
+
# Build hash -> label mapping across all rules
|
|
80
|
+
hash_to_label: Dict[str, str] = {}
|
|
81
|
+
label_counter = 0
|
|
82
|
+
|
|
83
|
+
translated_rules = []
|
|
84
|
+
for rule in rules:
|
|
85
|
+
if "placement_groups" not in rule:
|
|
86
|
+
translated_rules.append(rule)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
placement_groups = rule.get("placement_groups", [])
|
|
90
|
+
cspec_input = rule.get("customization_spec", {})
|
|
91
|
+
|
|
92
|
+
# Build Meta-format customization_spec from placement_groups
|
|
93
|
+
publisher_platforms: set = set()
|
|
94
|
+
facebook_positions: set = set()
|
|
95
|
+
instagram_positions: set = set()
|
|
96
|
+
audience_network_positions: set = set()
|
|
97
|
+
|
|
98
|
+
for pg in placement_groups:
|
|
99
|
+
mapping = _PLACEMENT_GROUP_TO_POSITIONS.get(pg, {})
|
|
100
|
+
publisher_platforms.update(mapping.get("publisher_platforms", []))
|
|
101
|
+
facebook_positions.update(mapping.get("facebook_positions", []))
|
|
102
|
+
instagram_positions.update(mapping.get("instagram_positions", []))
|
|
103
|
+
audience_network_positions.update(mapping.get("audience_network_positions", []))
|
|
104
|
+
|
|
105
|
+
meta_cspec: Dict[str, Any] = {}
|
|
106
|
+
if publisher_platforms:
|
|
107
|
+
meta_cspec["publisher_platforms"] = sorted(publisher_platforms)
|
|
108
|
+
if facebook_positions:
|
|
109
|
+
meta_cspec["facebook_positions"] = sorted(facebook_positions)
|
|
110
|
+
if instagram_positions:
|
|
111
|
+
meta_cspec["instagram_positions"] = sorted(instagram_positions)
|
|
112
|
+
if audience_network_positions:
|
|
113
|
+
meta_cspec["audience_network_positions"] = sorted(audience_network_positions)
|
|
114
|
+
|
|
115
|
+
# Carry over text overrides (bodies, titles, etc.) into customization_spec
|
|
116
|
+
for text_field in ("bodies", "titles", "descriptions", "link_urls", "call_to_action_types"):
|
|
117
|
+
if text_field in cspec_input:
|
|
118
|
+
meta_cspec[text_field] = cspec_input[text_field]
|
|
119
|
+
|
|
120
|
+
translated_rule: Dict[str, Any] = {"customization_spec": meta_cspec}
|
|
121
|
+
|
|
122
|
+
# Assign label for image or video asset
|
|
123
|
+
img_hashes = cspec_input.get("image_hashes", [])
|
|
124
|
+
vid_ids = cspec_input.get("video_ids", [])
|
|
125
|
+
if img_hashes:
|
|
126
|
+
h = img_hashes[0]
|
|
127
|
+
if h not in hash_to_label:
|
|
128
|
+
hash_to_label[h] = f"PBOARD_IMG_{label_counter}"
|
|
129
|
+
label_counter += 1
|
|
130
|
+
translated_rule["image_label"] = {"name": hash_to_label[h]}
|
|
131
|
+
elif vid_ids:
|
|
132
|
+
v = vid_ids[0]
|
|
133
|
+
if v not in hash_to_label:
|
|
134
|
+
hash_to_label[v] = f"PBOARD_VID_{label_counter}"
|
|
135
|
+
label_counter += 1
|
|
136
|
+
translated_rule["video_label"] = {"name": hash_to_label[v]}
|
|
137
|
+
|
|
138
|
+
translated_rules.append(translated_rule)
|
|
139
|
+
|
|
140
|
+
# Add adlabels to images_array for referenced hashes
|
|
141
|
+
updated_images = []
|
|
142
|
+
for img in images_array:
|
|
143
|
+
img_hash = img.get("hash", "")
|
|
144
|
+
if img_hash in hash_to_label:
|
|
145
|
+
updated = dict(img)
|
|
146
|
+
updated["adlabels"] = [{"name": hash_to_label[img_hash]}]
|
|
147
|
+
updated_images.append(updated)
|
|
148
|
+
else:
|
|
149
|
+
updated_images.append(img)
|
|
150
|
+
|
|
151
|
+
return translated_rules, updated_images
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# All writable creative_features_spec keys for Meta Ads API v24+.
|
|
155
|
+
# Mirrors ALL_ENHANCEMENT_KEYS in pipeboard.co/lib/meta-ads-enhancement-keys.ts.
|
|
156
|
+
# Setting each key to {"enroll_status": "OPT_OUT"} disables the enhancement.
|
|
157
|
+
# NOTE: The legacy "standard_enhancements" key is deprecated for POST operations
|
|
158
|
+
# (Meta error subcode 3858504) -- individual keys must be used instead.
|
|
159
|
+
_ALL_ENHANCEMENT_KEYS: tuple[str, ...] = (
|
|
160
|
+
"add_text_overlay",
|
|
161
|
+
"creative_stickers",
|
|
162
|
+
"description_automation",
|
|
163
|
+
"image_animation",
|
|
164
|
+
"image_background_gen",
|
|
165
|
+
"image_templates",
|
|
166
|
+
"image_touchups",
|
|
167
|
+
"image_uncrop",
|
|
168
|
+
"inline_comment",
|
|
169
|
+
"media_type_automation",
|
|
170
|
+
"music_generation",
|
|
171
|
+
"pac_relaxation",
|
|
172
|
+
"product_extensions",
|
|
173
|
+
"profile_card",
|
|
174
|
+
"reveal_details_over_time",
|
|
175
|
+
"show_destination_blurbs",
|
|
176
|
+
"show_summary",
|
|
177
|
+
"site_extensions",
|
|
178
|
+
"text_optimizations",
|
|
179
|
+
"text_translation",
|
|
180
|
+
"translate_voiceover",
|
|
181
|
+
"video_auto_crop",
|
|
182
|
+
"video_highlights",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _translate_video_customization_rules_for_existing_post(
|
|
187
|
+
rules: List[Dict[str, Any]],
|
|
188
|
+
) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
189
|
+
"""
|
|
190
|
+
Translate placement_groups-format customization rules to Meta API format,
|
|
191
|
+
building a videos array for use alongside object_story_id.
|
|
192
|
+
|
|
193
|
+
Used when object_story_id is combined with asset_customization_rules
|
|
194
|
+
to override specific placements (e.g., a 9:16 video for Story/Reels
|
|
195
|
+
while the organic post shows in feed).
|
|
196
|
+
|
|
197
|
+
Our user-facing format:
|
|
198
|
+
[{"placement_groups": ["STORY"], "customization_spec": {"video_ids": ["vid123"]}}]
|
|
199
|
+
|
|
200
|
+
Meta API format in asset_feed_spec:
|
|
201
|
+
videos: [{"video_id": "vid123", "adlabels": [{"name": "PBOARD_VID_0"}]}]
|
|
202
|
+
asset_customization_rules: [
|
|
203
|
+
{"customization_spec": {"publisher_platforms": [...], "instagram_positions": ["story"], ...},
|
|
204
|
+
"video_label": {"name": "PBOARD_VID_0"}}
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
Rules that do NOT contain placement_groups are passed through unchanged
|
|
208
|
+
(allows raw Meta API format to be used directly).
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
(translated_rules, videos_array) where videos_array has adlabels assigned.
|
|
212
|
+
"""
|
|
213
|
+
if not rules or not any("placement_groups" in r for r in rules):
|
|
214
|
+
# Pass through raw rules if already in Meta API format
|
|
215
|
+
return rules, []
|
|
216
|
+
|
|
217
|
+
vid_to_label: Dict[str, str] = {}
|
|
218
|
+
label_counter = 0
|
|
219
|
+
translated_rules = []
|
|
220
|
+
|
|
221
|
+
for rule in rules:
|
|
222
|
+
if "placement_groups" not in rule:
|
|
223
|
+
translated_rules.append(rule)
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
placement_groups = rule.get("placement_groups", [])
|
|
227
|
+
cspec_input = rule.get("customization_spec", {})
|
|
228
|
+
|
|
229
|
+
# Build Meta-format customization_spec from placement_groups
|
|
230
|
+
publisher_platforms: set = set()
|
|
231
|
+
facebook_positions: set = set()
|
|
232
|
+
instagram_positions: set = set()
|
|
233
|
+
audience_network_positions: set = set()
|
|
234
|
+
|
|
235
|
+
for pg in placement_groups:
|
|
236
|
+
mapping = _PLACEMENT_GROUP_TO_POSITIONS.get(pg, {})
|
|
237
|
+
publisher_platforms.update(mapping.get("publisher_platforms", []))
|
|
238
|
+
facebook_positions.update(mapping.get("facebook_positions", []))
|
|
239
|
+
instagram_positions.update(mapping.get("instagram_positions", []))
|
|
240
|
+
audience_network_positions.update(mapping.get("audience_network_positions", []))
|
|
241
|
+
|
|
242
|
+
meta_cspec: Dict[str, Any] = {}
|
|
243
|
+
if publisher_platforms:
|
|
244
|
+
meta_cspec["publisher_platforms"] = sorted(publisher_platforms)
|
|
245
|
+
if facebook_positions:
|
|
246
|
+
meta_cspec["facebook_positions"] = sorted(facebook_positions)
|
|
247
|
+
if instagram_positions:
|
|
248
|
+
meta_cspec["instagram_positions"] = sorted(instagram_positions)
|
|
249
|
+
if audience_network_positions:
|
|
250
|
+
meta_cspec["audience_network_positions"] = sorted(audience_network_positions)
|
|
251
|
+
|
|
252
|
+
# Carry over text overrides into customization_spec
|
|
253
|
+
for text_field in ("bodies", "titles", "descriptions", "link_urls", "call_to_action_types"):
|
|
254
|
+
if text_field in cspec_input:
|
|
255
|
+
meta_cspec[text_field] = cspec_input[text_field]
|
|
256
|
+
|
|
257
|
+
translated_rule: Dict[str, Any] = {"customization_spec": meta_cspec}
|
|
258
|
+
|
|
259
|
+
# Assign label for video asset
|
|
260
|
+
vid_ids = cspec_input.get("video_ids", [])
|
|
261
|
+
if vid_ids:
|
|
262
|
+
v = vid_ids[0]
|
|
263
|
+
if v not in vid_to_label:
|
|
264
|
+
vid_to_label[v] = f"PBOARD_VID_{label_counter}"
|
|
265
|
+
label_counter += 1
|
|
266
|
+
translated_rule["video_label"] = {"name": vid_to_label[v]}
|
|
267
|
+
|
|
268
|
+
translated_rules.append(translated_rule)
|
|
269
|
+
|
|
270
|
+
# Build videos_array with adlabels
|
|
271
|
+
videos_array = [
|
|
272
|
+
{"video_id": vid_id, "adlabels": [{"name": label}]}
|
|
273
|
+
for vid_id, label in vid_to_label.items()
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
return translated_rules, videos_array
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
from .utils import download_image, try_multiple_download_methods, ad_creative_images, extract_creative_image_urls
|
|
280
|
+
from .server import mcp_server
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# Only register the save_ad_image_locally function if explicitly enabled via environment variable
|
|
284
|
+
ENABLE_SAVE_AD_IMAGE_LOCALLY = bool(os.environ.get("META_ADS_ENABLE_SAVE_AD_IMAGE_LOCALLY", ""))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@mcp_server.tool()
|
|
288
|
+
@meta_api_tool
|
|
289
|
+
async def get_ads(account_id: str, access_token: Optional[str] = None, limit: int = 10,
|
|
290
|
+
campaign_id: str = "", adset_id: str = "") -> str:
|
|
291
|
+
"""
|
|
292
|
+
Get ads for a Meta Ads account with optional filtering.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
296
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
297
|
+
limit: Maximum number of ads to return (default: 10)
|
|
298
|
+
campaign_id: Optional campaign ID to filter by
|
|
299
|
+
adset_id: Optional ad set ID to filter by
|
|
300
|
+
"""
|
|
301
|
+
# Require explicit account_id
|
|
302
|
+
if not account_id:
|
|
303
|
+
return json.dumps({"error": "No account ID specified"}, indent=2)
|
|
304
|
+
|
|
305
|
+
# Prioritize adset_id over campaign_id - use adset-specific endpoint
|
|
306
|
+
if adset_id:
|
|
307
|
+
endpoint = f"{adset_id}/ads"
|
|
308
|
+
params = {
|
|
309
|
+
"fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
|
|
310
|
+
"limit": limit
|
|
311
|
+
}
|
|
312
|
+
# Use campaign-specific endpoint if campaign_id is provided
|
|
313
|
+
elif campaign_id:
|
|
314
|
+
endpoint = f"{campaign_id}/ads"
|
|
315
|
+
params = {
|
|
316
|
+
"fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
|
|
317
|
+
"limit": limit
|
|
318
|
+
}
|
|
319
|
+
else:
|
|
320
|
+
# Default to account-level endpoint if no specific filters
|
|
321
|
+
endpoint = f"{account_id}/ads"
|
|
322
|
+
params = {
|
|
323
|
+
"fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
|
|
324
|
+
"limit": limit
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
data = await make_api_request(endpoint, access_token, params)
|
|
328
|
+
|
|
329
|
+
return json.dumps(data, indent=2)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@mcp_server.tool()
|
|
333
|
+
@meta_api_tool
|
|
334
|
+
async def get_ad_details(ad_id: str, access_token: Optional[str] = None) -> str:
|
|
335
|
+
"""
|
|
336
|
+
Get detailed information about a specific ad.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
ad_id: Meta Ads ad ID
|
|
340
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
341
|
+
"""
|
|
342
|
+
if not ad_id:
|
|
343
|
+
return json.dumps({"error": "No ad ID provided"}, indent=2)
|
|
344
|
+
|
|
345
|
+
endpoint = f"{ad_id}"
|
|
346
|
+
params = {
|
|
347
|
+
"fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs,preview_shareable_link"
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
data = await make_api_request(endpoint, access_token, params)
|
|
351
|
+
|
|
352
|
+
return json.dumps(data, indent=2)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@mcp_server.tool()
|
|
356
|
+
@meta_api_tool
|
|
357
|
+
async def get_creative_details(creative_id: str, access_token: Optional[str] = None) -> str:
|
|
358
|
+
"""Get detailed information about a specific ad creative by its ID.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
creative_id: Meta Ads creative ID (required)
|
|
362
|
+
access_token: Meta API access token (optional)
|
|
363
|
+
"""
|
|
364
|
+
if not creative_id:
|
|
365
|
+
return json.dumps({"error": "No creative ID provided"}, indent=2)
|
|
366
|
+
endpoint = f"{creative_id}"
|
|
367
|
+
# Note: dynamic_creative_spec is only valid on dynamic creatives and causes
|
|
368
|
+
# "(#100) Tried accessing nonexisting field" on simple creatives in API v24.
|
|
369
|
+
# We fetch the safe fields first, then try dynamic_creative_spec separately.
|
|
370
|
+
params = {
|
|
371
|
+
"fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,object_type,body,title,effective_object_story_id,asset_feed_spec{images,videos,bodies,titles,descriptions,link_urls,ad_formats,call_to_action_types,optimization_type,asset_customization_rules},url_tags,link_url"
|
|
372
|
+
}
|
|
373
|
+
data = await make_api_request(endpoint, access_token, params)
|
|
374
|
+
|
|
375
|
+
# Try to fetch optional fields separately (may not exist on all creative types)
|
|
376
|
+
if isinstance(data, dict) and "id" in data:
|
|
377
|
+
for opt_field in ["dynamic_creative_spec", "degrees_of_freedom_spec", "product_set_id"]:
|
|
378
|
+
try:
|
|
379
|
+
opt_data = await make_api_request(
|
|
380
|
+
endpoint, access_token, {"fields": opt_field}
|
|
381
|
+
)
|
|
382
|
+
if isinstance(opt_data, dict) and opt_field in opt_data:
|
|
383
|
+
data[opt_field] = opt_data[opt_field]
|
|
384
|
+
except Exception:
|
|
385
|
+
pass # Field doesn't exist on this creative type
|
|
386
|
+
|
|
387
|
+
# Resolve product_set_id -> catalog info for DPA/catalog creatives
|
|
388
|
+
if "product_set_id" in data:
|
|
389
|
+
try:
|
|
390
|
+
catalog_data = await make_api_request(
|
|
391
|
+
data["product_set_id"], access_token,
|
|
392
|
+
{"fields": "product_catalog{id,name}"}
|
|
393
|
+
)
|
|
394
|
+
catalog = catalog_data.get("product_catalog", {})
|
|
395
|
+
if catalog.get("id"):
|
|
396
|
+
data["catalog_id"] = catalog["id"]
|
|
397
|
+
if catalog.get("name"):
|
|
398
|
+
data["catalog_name"] = catalog["name"]
|
|
399
|
+
except Exception:
|
|
400
|
+
pass # Non-critical
|
|
401
|
+
|
|
402
|
+
return json.dumps(data, indent=2)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@meta_api_tool
|
|
406
|
+
async def create_ad(
|
|
407
|
+
account_id: str,
|
|
408
|
+
name: str,
|
|
409
|
+
adset_id: str,
|
|
410
|
+
creative_id: str,
|
|
411
|
+
status: str = "PAUSED",
|
|
412
|
+
bid_amount: Optional[int] = None,
|
|
413
|
+
tracking_specs: Optional[List[Dict[str, Any]]] = None,
|
|
414
|
+
access_token: Optional[str] = None
|
|
415
|
+
) -> str:
|
|
416
|
+
"""
|
|
417
|
+
Create a new ad with an existing creative.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
421
|
+
name: Ad name
|
|
422
|
+
adset_id: Ad set ID where this ad will be placed
|
|
423
|
+
creative_id: ID of an existing creative to use
|
|
424
|
+
status: Initial ad status (default: PAUSED)
|
|
425
|
+
bid_amount: Optional bid amount in account currency (in cents)
|
|
426
|
+
tracking_specs: Optional tracking specifications (e.g., for pixel events).
|
|
427
|
+
Example: [{"action.type":"offsite_conversion","fb_pixel":["YOUR_PIXEL_ID"]}]
|
|
428
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
429
|
+
|
|
430
|
+
Note:
|
|
431
|
+
Dynamic Creative creatives require the parent ad set to have `is_dynamic_creative=true`.
|
|
432
|
+
Otherwise, ad creation will fail with error_subcode 1885998.
|
|
433
|
+
"""
|
|
434
|
+
# Check required parameters
|
|
435
|
+
if not account_id:
|
|
436
|
+
return json.dumps({"error": "No account ID provided"}, indent=2)
|
|
437
|
+
|
|
438
|
+
if not name:
|
|
439
|
+
return json.dumps({"error": "No ad name provided"}, indent=2)
|
|
440
|
+
|
|
441
|
+
if not adset_id:
|
|
442
|
+
return json.dumps({"error": "No ad set ID provided"}, indent=2)
|
|
443
|
+
|
|
444
|
+
if not creative_id:
|
|
445
|
+
return json.dumps({"error": "No creative ID provided"}, indent=2)
|
|
446
|
+
|
|
447
|
+
endpoint = f"{account_id}/ads"
|
|
448
|
+
|
|
449
|
+
params = {
|
|
450
|
+
"name": name,
|
|
451
|
+
"adset_id": adset_id,
|
|
452
|
+
"creative": {"creative_id": creative_id},
|
|
453
|
+
"status": status
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
# Add bid amount if provided
|
|
457
|
+
if bid_amount is not None:
|
|
458
|
+
params["bid_amount"] = str(bid_amount)
|
|
459
|
+
|
|
460
|
+
# Add tracking specs if provided
|
|
461
|
+
if tracking_specs is not None:
|
|
462
|
+
params["tracking_specs"] = json.dumps(tracking_specs) # Needs to be JSON encoded string
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
data = await make_api_request(endpoint, access_token, params, method="POST")
|
|
466
|
+
return json.dumps(data, indent=2)
|
|
467
|
+
except Exception as e:
|
|
468
|
+
error_msg = str(e)
|
|
469
|
+
return json.dumps({
|
|
470
|
+
"error": "Failed to create ad",
|
|
471
|
+
"details": error_msg,
|
|
472
|
+
"params_sent": params
|
|
473
|
+
}, indent=2)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@mcp_server.tool()
|
|
477
|
+
@meta_api_tool
|
|
478
|
+
async def get_ad_creatives(ad_id: str, access_token: Optional[str] = None) -> str:
|
|
479
|
+
"""
|
|
480
|
+
Get creative details for a specific ad. Requires an ad_id (not account_id). Use get_ads first to find ad IDs.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
ad_id: Meta Ads ad ID (required)
|
|
484
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
485
|
+
"""
|
|
486
|
+
if not ad_id:
|
|
487
|
+
return json.dumps({"error": "No ad ID provided"}, indent=2)
|
|
488
|
+
|
|
489
|
+
endpoint = f"{ad_id}/adcreatives"
|
|
490
|
+
params = {
|
|
491
|
+
"fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,object_type,body,title,effective_object_story_id,asset_feed_spec,url_tags,image_urls_for_viewing,product_set_id,degrees_of_freedom_spec"
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
data = await make_api_request(endpoint, access_token, params)
|
|
495
|
+
|
|
496
|
+
if 'data' in data:
|
|
497
|
+
# Resolve asset_feed_spec image hashes to URLs
|
|
498
|
+
image_hashes = set()
|
|
499
|
+
for creative in data['data']:
|
|
500
|
+
if 'asset_feed_spec' in creative and 'images' in creative['asset_feed_spec']:
|
|
501
|
+
for image in creative['asset_feed_spec']['images']:
|
|
502
|
+
if 'hash' in image and 'url' not in image:
|
|
503
|
+
image_hashes.add(image['hash'])
|
|
504
|
+
|
|
505
|
+
if image_hashes:
|
|
506
|
+
# Get account_id from the ad to look up image URLs
|
|
507
|
+
ad_data = await make_api_request(ad_id, access_token, {"fields": "account_id"})
|
|
508
|
+
account_id = ad_data.get("account_id")
|
|
509
|
+
if account_id:
|
|
510
|
+
hashes_str = json.dumps(list(image_hashes))
|
|
511
|
+
image_data = await make_api_request(
|
|
512
|
+
f"act_{account_id}/adimages",
|
|
513
|
+
access_token,
|
|
514
|
+
{"fields": "hash,url,width,height", "hashes": hashes_str},
|
|
515
|
+
)
|
|
516
|
+
hash_to_url = {}
|
|
517
|
+
if 'data' in image_data:
|
|
518
|
+
for img in image_data['data']:
|
|
519
|
+
if 'hash' in img and 'url' in img:
|
|
520
|
+
hash_to_url[img['hash']] = img['url']
|
|
521
|
+
|
|
522
|
+
if hash_to_url:
|
|
523
|
+
for creative in data['data']:
|
|
524
|
+
if 'asset_feed_spec' in creative and 'images' in creative['asset_feed_spec']:
|
|
525
|
+
for image in creative['asset_feed_spec']['images']:
|
|
526
|
+
if 'hash' in image and image['hash'] in hash_to_url:
|
|
527
|
+
image['url'] = hash_to_url[image['hash']]
|
|
528
|
+
|
|
529
|
+
# Add image URLs for direct viewing if available
|
|
530
|
+
for creative in data['data']:
|
|
531
|
+
creative['image_urls_for_viewing'] = extract_creative_image_urls(creative)
|
|
532
|
+
|
|
533
|
+
# Resolve product_set_id -> catalog info for DPA/catalog creatives
|
|
534
|
+
for creative in data['data']:
|
|
535
|
+
ps_id = creative.get('product_set_id')
|
|
536
|
+
if ps_id:
|
|
537
|
+
try:
|
|
538
|
+
catalog_data = await make_api_request(
|
|
539
|
+
ps_id, access_token,
|
|
540
|
+
{"fields": "product_catalog{id,name}"}
|
|
541
|
+
)
|
|
542
|
+
catalog = catalog_data.get("product_catalog", {})
|
|
543
|
+
if catalog.get("id"):
|
|
544
|
+
creative["catalog_id"] = catalog["id"]
|
|
545
|
+
if catalog.get("name"):
|
|
546
|
+
creative["catalog_name"] = catalog["name"]
|
|
547
|
+
except Exception:
|
|
548
|
+
pass # Non-critical
|
|
549
|
+
|
|
550
|
+
return json.dumps(data, indent=2)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@mcp_server.tool()
|
|
554
|
+
@meta_api_tool
|
|
555
|
+
async def get_ad_image(ad_id: str, access_token: Optional[str] = None) -> Image:
|
|
556
|
+
"""
|
|
557
|
+
Get, download, and visualize a Meta ad image in one step. Useful to see the image in the LLM.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
ad_id: Meta Ads ad ID
|
|
561
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
The ad image ready for direct visual analysis
|
|
565
|
+
"""
|
|
566
|
+
if not ad_id:
|
|
567
|
+
return "Error: No ad ID provided"
|
|
568
|
+
|
|
569
|
+
print(f"Attempting to get and analyze creative image for ad {ad_id}")
|
|
570
|
+
|
|
571
|
+
# First, get creative and account IDs
|
|
572
|
+
ad_endpoint = f"{ad_id}"
|
|
573
|
+
ad_params = {
|
|
574
|
+
"fields": "creative{id},account_id"
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
|
|
578
|
+
|
|
579
|
+
if "error" in ad_data:
|
|
580
|
+
return f"Error: Could not get ad data - {json.dumps(ad_data)}"
|
|
581
|
+
|
|
582
|
+
# Extract account_id
|
|
583
|
+
account_id = ad_data.get("account_id", "")
|
|
584
|
+
if not account_id:
|
|
585
|
+
return "Error: No account ID found"
|
|
586
|
+
|
|
587
|
+
# Extract creative ID
|
|
588
|
+
if "creative" not in ad_data:
|
|
589
|
+
return "Error: No creative found for this ad"
|
|
590
|
+
|
|
591
|
+
creative_data = ad_data.get("creative", {})
|
|
592
|
+
creative_id = creative_data.get("id")
|
|
593
|
+
if not creative_id:
|
|
594
|
+
return "Error: No creative ID found"
|
|
595
|
+
|
|
596
|
+
# Get creative details to find image hash
|
|
597
|
+
creative_endpoint = f"{creative_id}"
|
|
598
|
+
creative_params = {
|
|
599
|
+
"fields": "id,name,image_hash,asset_feed_spec"
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
|
|
603
|
+
|
|
604
|
+
# Identify image hashes to use from creative
|
|
605
|
+
image_hashes = []
|
|
606
|
+
|
|
607
|
+
# Check for direct image_hash on creative
|
|
608
|
+
if "image_hash" in creative_details:
|
|
609
|
+
image_hashes.append(creative_details["image_hash"])
|
|
610
|
+
|
|
611
|
+
# Check asset_feed_spec for image hashes - common in Advantage+ ads
|
|
612
|
+
if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
|
|
613
|
+
for image in creative_details["asset_feed_spec"]["images"]:
|
|
614
|
+
if "hash" in image:
|
|
615
|
+
image_hashes.append(image["hash"])
|
|
616
|
+
|
|
617
|
+
if not image_hashes:
|
|
618
|
+
# If no hashes found, try to extract from the first creative we found in the API
|
|
619
|
+
# and also check for direct URLs as fallback
|
|
620
|
+
creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
|
|
621
|
+
creative_data = json.loads(creative_json)
|
|
622
|
+
|
|
623
|
+
# Try to extract hash from data array
|
|
624
|
+
if "data" in creative_data and creative_data["data"]:
|
|
625
|
+
for creative in creative_data["data"]:
|
|
626
|
+
# Check object_story_spec for image hash
|
|
627
|
+
if "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
|
|
628
|
+
link_data = creative["object_story_spec"]["link_data"]
|
|
629
|
+
if "image_hash" in link_data:
|
|
630
|
+
image_hashes.append(link_data["image_hash"])
|
|
631
|
+
# Check direct image_hash on creative
|
|
632
|
+
elif "image_hash" in creative:
|
|
633
|
+
image_hashes.append(creative["image_hash"])
|
|
634
|
+
# Check asset_feed_spec for image hashes
|
|
635
|
+
elif "asset_feed_spec" in creative and "images" in creative["asset_feed_spec"]:
|
|
636
|
+
images = creative["asset_feed_spec"]["images"]
|
|
637
|
+
if images and len(images) > 0 and "hash" in images[0]:
|
|
638
|
+
image_hashes.append(images[0]["hash"])
|
|
639
|
+
|
|
640
|
+
# If still no image hashes found, try direct URL fallback approach
|
|
641
|
+
if not image_hashes:
|
|
642
|
+
print("No image hashes found, trying direct URL fallback...")
|
|
643
|
+
|
|
644
|
+
image_url = None
|
|
645
|
+
if "data" in creative_data and creative_data["data"]:
|
|
646
|
+
creative = creative_data["data"][0]
|
|
647
|
+
|
|
648
|
+
# Prioritize higher quality image URLs in this order:
|
|
649
|
+
# 1. image_urls_for_viewing (usually highest quality)
|
|
650
|
+
# 2. image_url (direct field)
|
|
651
|
+
# 3. object_story_spec.link_data.picture (usually full size)
|
|
652
|
+
# 4. thumbnail_url (last resort - often profile thumbnail)
|
|
653
|
+
|
|
654
|
+
if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
|
|
655
|
+
image_url = creative["image_urls_for_viewing"][0]
|
|
656
|
+
print(f"Using image_urls_for_viewing: {image_url}")
|
|
657
|
+
elif "image_url" in creative and creative["image_url"]:
|
|
658
|
+
image_url = creative["image_url"]
|
|
659
|
+
print(f"Using image_url: {image_url}")
|
|
660
|
+
elif "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
|
|
661
|
+
link_data = creative["object_story_spec"]["link_data"]
|
|
662
|
+
if "picture" in link_data and link_data["picture"]:
|
|
663
|
+
image_url = link_data["picture"]
|
|
664
|
+
print(f"Using object_story_spec.link_data.picture: {image_url}")
|
|
665
|
+
elif "thumbnail_url" in creative and creative["thumbnail_url"]:
|
|
666
|
+
image_url = creative["thumbnail_url"]
|
|
667
|
+
print(f"Using thumbnail_url (fallback): {image_url}")
|
|
668
|
+
|
|
669
|
+
if not image_url:
|
|
670
|
+
return "Error: No image URLs found in creative"
|
|
671
|
+
|
|
672
|
+
# Download the image directly
|
|
673
|
+
print(f"Downloading image from direct URL: {image_url}")
|
|
674
|
+
image_bytes = await download_image(image_url)
|
|
675
|
+
|
|
676
|
+
if not image_bytes:
|
|
677
|
+
return "Error: Failed to download image from direct URL"
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
# Convert bytes to PIL Image
|
|
681
|
+
img = PILImage.open(io.BytesIO(image_bytes))
|
|
682
|
+
|
|
683
|
+
# Convert to RGB if needed
|
|
684
|
+
if img.mode != "RGB":
|
|
685
|
+
img = img.convert("RGB")
|
|
686
|
+
|
|
687
|
+
# Create a byte stream of the image data
|
|
688
|
+
byte_arr = io.BytesIO()
|
|
689
|
+
img.save(byte_arr, format="JPEG")
|
|
690
|
+
img_bytes = byte_arr.getvalue()
|
|
691
|
+
|
|
692
|
+
# Return as an Image object that LLM can directly analyze
|
|
693
|
+
return Image(data=img_bytes, format="jpeg")
|
|
694
|
+
|
|
695
|
+
except Exception as e:
|
|
696
|
+
return f"Error processing image from direct URL: {str(e)}"
|
|
697
|
+
|
|
698
|
+
print(f"Found image hashes: {image_hashes}")
|
|
699
|
+
|
|
700
|
+
# Now fetch image data using adimages endpoint with specific format
|
|
701
|
+
image_endpoint = f"act_{account_id}/adimages"
|
|
702
|
+
|
|
703
|
+
# Format the hashes parameter exactly as in our successful curl test
|
|
704
|
+
hashes_str = f'["{image_hashes[0]}"]' # Format first hash only, as JSON string array
|
|
705
|
+
|
|
706
|
+
image_params = {
|
|
707
|
+
"fields": "hash,url,width,height,name,status",
|
|
708
|
+
"hashes": hashes_str
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
print(f"Requesting image data with params: {image_params}")
|
|
712
|
+
image_data = await make_api_request(image_endpoint, access_token, image_params)
|
|
713
|
+
|
|
714
|
+
if "error" in image_data:
|
|
715
|
+
return f"Error: Failed to get image data - {json.dumps(image_data)}"
|
|
716
|
+
|
|
717
|
+
if "data" not in image_data or not image_data["data"]:
|
|
718
|
+
return "Error: No image data returned from API"
|
|
719
|
+
|
|
720
|
+
# Get the first image URL
|
|
721
|
+
first_image = image_data["data"][0]
|
|
722
|
+
image_url = first_image.get("url")
|
|
723
|
+
|
|
724
|
+
if not image_url:
|
|
725
|
+
return "Error: No valid image URL found"
|
|
726
|
+
|
|
727
|
+
print(f"Downloading image from URL: {image_url}")
|
|
728
|
+
|
|
729
|
+
# Download the image
|
|
730
|
+
image_bytes = await download_image(image_url)
|
|
731
|
+
|
|
732
|
+
if not image_bytes:
|
|
733
|
+
return "Error: Failed to download image"
|
|
734
|
+
|
|
735
|
+
try:
|
|
736
|
+
# Convert bytes to PIL Image
|
|
737
|
+
img = PILImage.open(io.BytesIO(image_bytes))
|
|
738
|
+
|
|
739
|
+
# Convert to RGB if needed
|
|
740
|
+
if img.mode != "RGB":
|
|
741
|
+
img = img.convert("RGB")
|
|
742
|
+
|
|
743
|
+
# Create a byte stream of the image data
|
|
744
|
+
byte_arr = io.BytesIO()
|
|
745
|
+
img.save(byte_arr, format="JPEG")
|
|
746
|
+
img_bytes = byte_arr.getvalue()
|
|
747
|
+
|
|
748
|
+
# Return as an Image object that LLM can directly analyze
|
|
749
|
+
return Image(data=img_bytes, format="jpeg")
|
|
750
|
+
|
|
751
|
+
except Exception as e:
|
|
752
|
+
return f"Error processing image: {str(e)}"
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@mcp_server.tool()
|
|
756
|
+
@meta_api_tool
|
|
757
|
+
async def get_ad_video(ad_id: str = "", video_id: str = "", account_id: str = "", access_token: Optional[str] = None) -> str:
|
|
758
|
+
"""
|
|
759
|
+
Get video details and source URL for a Meta ad video creative. Returns the video source URL
|
|
760
|
+
(direct download link), thumbnail URL, and metadata (title, description, duration).
|
|
761
|
+
|
|
762
|
+
Provide either ad_id (to auto-extract the video from the ad creative) or video_id directly.
|
|
763
|
+
Providing account_id is strongly recommended -- it enables the advideos edge which works
|
|
764
|
+
with Business Manager tokens (avoids error 100/33 and error #10 on account-uploaded videos).
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
ad_id: Meta Ads ad ID (will extract video_id from the ad creative)
|
|
768
|
+
video_id: Meta video ID (use this if you already have it from get_ad_creatives)
|
|
769
|
+
account_id: Ad account ID (e.g. "act_123" or "123"). Enables advideos edge lookup.
|
|
770
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
771
|
+
"""
|
|
772
|
+
if not ad_id and not video_id:
|
|
773
|
+
return json.dumps({"error": "Provide either ad_id or video_id"}, indent=2)
|
|
774
|
+
|
|
775
|
+
# If only ad_id provided, extract video_id from the creative
|
|
776
|
+
if not video_id:
|
|
777
|
+
creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
|
|
778
|
+
creative_data = json.loads(creative_json)
|
|
779
|
+
|
|
780
|
+
if "error" in creative_data:
|
|
781
|
+
return json.dumps({"error": f"Could not get creatives for ad {ad_id}", "details": creative_data}, indent=2)
|
|
782
|
+
|
|
783
|
+
# Extract video_id from creative data
|
|
784
|
+
if "data" in creative_data and creative_data["data"]:
|
|
785
|
+
creative = creative_data["data"][0]
|
|
786
|
+
|
|
787
|
+
# Check object_story_spec.video_data.video_id
|
|
788
|
+
oss = creative.get("object_story_spec", {})
|
|
789
|
+
if "video_data" in oss:
|
|
790
|
+
video_id = str(oss["video_data"].get("video_id", ""))
|
|
791
|
+
|
|
792
|
+
# Check asset_feed_spec.videos
|
|
793
|
+
if not video_id:
|
|
794
|
+
afs = creative.get("asset_feed_spec", {})
|
|
795
|
+
videos = afs.get("videos", [])
|
|
796
|
+
if videos:
|
|
797
|
+
video_id = str(videos[0].get("video_id", ""))
|
|
798
|
+
|
|
799
|
+
if not video_id:
|
|
800
|
+
return json.dumps({
|
|
801
|
+
"error": "No video found in this ad creative",
|
|
802
|
+
"hint": "This ad may be an image ad. Use get_ad_image instead."
|
|
803
|
+
}, indent=2)
|
|
804
|
+
|
|
805
|
+
video_fields = "source,title,description,length,picture,thumbnails,created_time"
|
|
806
|
+
|
|
807
|
+
# Strategy 1: Try fetching via the ad account's advideos edge.
|
|
808
|
+
# Direct GET /{video_id} fails for BM-shared tokens (error 100/33) and
|
|
809
|
+
# page-owned videos (error #10). The ad account edge works for any video
|
|
810
|
+
# that belongs to the account's video library.
|
|
811
|
+
# Normalize: strip act_ prefix if present (we add it back below)
|
|
812
|
+
if account_id and account_id.startswith("act_"):
|
|
813
|
+
account_id = account_id[4:]
|
|
814
|
+
|
|
815
|
+
if not account_id and ad_id:
|
|
816
|
+
ad_data = await make_api_request(ad_id, access_token, {"fields": "account_id"})
|
|
817
|
+
account_id = ad_data.get("account_id", "")
|
|
818
|
+
|
|
819
|
+
video_data = None
|
|
820
|
+
if account_id:
|
|
821
|
+
advideos_data = await make_api_request(
|
|
822
|
+
f"act_{account_id}/advideos",
|
|
823
|
+
access_token,
|
|
824
|
+
{
|
|
825
|
+
"fields": video_fields,
|
|
826
|
+
"filtering": json.dumps([{"field": "id", "operator": "IN", "value": [video_id]}]),
|
|
827
|
+
},
|
|
828
|
+
)
|
|
829
|
+
if "data" in advideos_data and advideos_data["data"]:
|
|
830
|
+
video_data = advideos_data["data"][0]
|
|
831
|
+
logger.debug(f"Video {video_id} resolved via ad account advideos edge")
|
|
832
|
+
|
|
833
|
+
# Strategy 2: Fall back to direct video node access.
|
|
834
|
+
if not video_data:
|
|
835
|
+
video_data = await make_api_request(
|
|
836
|
+
video_id,
|
|
837
|
+
access_token,
|
|
838
|
+
{"fields": video_fields}
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
if "error" in video_data:
|
|
842
|
+
return json.dumps({"error": f"Could not get video {video_id}", "details": video_data}, indent=2)
|
|
843
|
+
|
|
844
|
+
result = {
|
|
845
|
+
"video_id": video_id,
|
|
846
|
+
"source_url": video_data.get("source"),
|
|
847
|
+
"thumbnail_url": video_data.get("picture"),
|
|
848
|
+
"title": video_data.get("title"),
|
|
849
|
+
"description": video_data.get("description"),
|
|
850
|
+
"duration_seconds": video_data.get("length"),
|
|
851
|
+
"created_time": video_data.get("created_time"),
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if ad_id:
|
|
855
|
+
result["ad_id"] = ad_id
|
|
856
|
+
|
|
857
|
+
if not result["source_url"]:
|
|
858
|
+
result["warning"] = "No source URL returned. The video may have been deleted or you may lack permissions."
|
|
859
|
+
|
|
860
|
+
return json.dumps(result, indent=2)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
if ENABLE_SAVE_AD_IMAGE_LOCALLY:
|
|
864
|
+
@mcp_server.tool()
|
|
865
|
+
@meta_api_tool
|
|
866
|
+
async def save_ad_image_locally(ad_id: str, access_token: Optional[str] = None, output_dir: str = "ad_images") -> str:
|
|
867
|
+
"""
|
|
868
|
+
Get, download, and save a Meta ad image locally, returning the file path.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
ad_id: Meta Ads ad ID
|
|
872
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
873
|
+
output_dir: Directory to save the image file (default: 'ad_images')
|
|
874
|
+
|
|
875
|
+
Returns:
|
|
876
|
+
The file path to the saved image, or an error message string.
|
|
877
|
+
"""
|
|
878
|
+
if not ad_id:
|
|
879
|
+
return json.dumps({"error": "No ad ID provided"}, indent=2)
|
|
880
|
+
|
|
881
|
+
print(f"Attempting to get and save creative image for ad {ad_id}")
|
|
882
|
+
|
|
883
|
+
# First, get creative and account IDs
|
|
884
|
+
ad_endpoint = f"{ad_id}"
|
|
885
|
+
ad_params = {
|
|
886
|
+
"fields": "creative{id},account_id"
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
|
|
890
|
+
|
|
891
|
+
if "error" in ad_data:
|
|
892
|
+
return json.dumps({"error": f"Could not get ad data - {json.dumps(ad_data)}"}, indent=2)
|
|
893
|
+
|
|
894
|
+
account_id = ad_data.get("account_id")
|
|
895
|
+
if not account_id:
|
|
896
|
+
return json.dumps({"error": "No account ID found for ad"}, indent=2)
|
|
897
|
+
|
|
898
|
+
if "creative" not in ad_data:
|
|
899
|
+
return json.dumps({"error": "No creative found for this ad"}, indent=2)
|
|
900
|
+
|
|
901
|
+
creative_data = ad_data.get("creative", {})
|
|
902
|
+
creative_id = creative_data.get("id")
|
|
903
|
+
if not creative_id:
|
|
904
|
+
return json.dumps({"error": "No creative ID found"}, indent=2)
|
|
905
|
+
|
|
906
|
+
# Get creative details to find image hash
|
|
907
|
+
creative_endpoint = f"{creative_id}"
|
|
908
|
+
creative_params = {
|
|
909
|
+
"fields": "id,name,image_hash,asset_feed_spec"
|
|
910
|
+
}
|
|
911
|
+
creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
|
|
912
|
+
|
|
913
|
+
image_hashes = []
|
|
914
|
+
if "image_hash" in creative_details:
|
|
915
|
+
image_hashes.append(creative_details["image_hash"])
|
|
916
|
+
if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
|
|
917
|
+
for image in creative_details["asset_feed_spec"]["images"]:
|
|
918
|
+
if "hash" in image:
|
|
919
|
+
image_hashes.append(image["hash"])
|
|
920
|
+
|
|
921
|
+
if not image_hashes:
|
|
922
|
+
# Fallback attempt (as in get_ad_image)
|
|
923
|
+
creative_json = await get_ad_creatives(ad_id=ad_id, access_token=access_token) # Ensure ad_id is passed correctly
|
|
924
|
+
creative_data_list = json.loads(creative_json)
|
|
925
|
+
if 'data' in creative_data_list and creative_data_list['data']:
|
|
926
|
+
first_creative = creative_data_list['data'][0]
|
|
927
|
+
if 'object_story_spec' in first_creative and 'link_data' in first_creative['object_story_spec'] and 'image_hash' in first_creative['object_story_spec']['link_data']:
|
|
928
|
+
image_hashes.append(first_creative['object_story_spec']['link_data']['image_hash'])
|
|
929
|
+
elif 'image_hash' in first_creative: # Check direct hash on creative data
|
|
930
|
+
image_hashes.append(first_creative['image_hash'])
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
if not image_hashes:
|
|
934
|
+
return json.dumps({"error": "No image hashes found in creative or fallback"}, indent=2)
|
|
935
|
+
|
|
936
|
+
print(f"Found image hashes: {image_hashes}")
|
|
937
|
+
|
|
938
|
+
# Fetch image data using the first hash
|
|
939
|
+
image_endpoint = f"act_{account_id}/adimages"
|
|
940
|
+
hashes_str = f'["{image_hashes[0]}"]'
|
|
941
|
+
image_params = {
|
|
942
|
+
"fields": "hash,url,width,height,name,status",
|
|
943
|
+
"hashes": hashes_str
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
print(f"Requesting image data with params: {image_params}")
|
|
947
|
+
image_data = await make_api_request(image_endpoint, access_token, image_params)
|
|
948
|
+
|
|
949
|
+
if "error" in image_data:
|
|
950
|
+
return json.dumps({"error": f"Failed to get image data - {json.dumps(image_data)}"}, indent=2)
|
|
951
|
+
|
|
952
|
+
if "data" not in image_data or not image_data["data"]:
|
|
953
|
+
return json.dumps({"error": "No image data returned from API"}, indent=2)
|
|
954
|
+
|
|
955
|
+
first_image = image_data["data"][0]
|
|
956
|
+
image_url = first_image.get("url")
|
|
957
|
+
|
|
958
|
+
if not image_url:
|
|
959
|
+
return json.dumps({"error": "No valid image URL found in API response"}, indent=2)
|
|
960
|
+
|
|
961
|
+
print(f"Downloading image from URL: {image_url}")
|
|
962
|
+
|
|
963
|
+
# Download and Save Image
|
|
964
|
+
image_bytes = await download_image(image_url)
|
|
965
|
+
|
|
966
|
+
if not image_bytes:
|
|
967
|
+
return json.dumps({"error": "Failed to download image"}, indent=2)
|
|
968
|
+
|
|
969
|
+
try:
|
|
970
|
+
# Ensure output directory exists
|
|
971
|
+
if not os.path.exists(output_dir):
|
|
972
|
+
os.makedirs(output_dir)
|
|
973
|
+
|
|
974
|
+
# Create a filename (e.g., using ad_id and image hash)
|
|
975
|
+
file_extension = ".jpg" # Default extension, could try to infer from headers later
|
|
976
|
+
filename = f"{ad_id}_{image_hashes[0]}{file_extension}"
|
|
977
|
+
filepath = os.path.join(output_dir, filename)
|
|
978
|
+
|
|
979
|
+
# Save the image bytes to the file
|
|
980
|
+
with open(filepath, "wb") as f:
|
|
981
|
+
f.write(image_bytes)
|
|
982
|
+
|
|
983
|
+
print(f"Image saved successfully to: {filepath}")
|
|
984
|
+
return json.dumps({"filepath": filepath}, indent=2) # Return JSON with filepath
|
|
985
|
+
|
|
986
|
+
except Exception as e:
|
|
987
|
+
return json.dumps({"error": f"Failed to save image: {str(e)}"}, indent=2)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
@meta_api_tool
|
|
991
|
+
async def update_ad(
|
|
992
|
+
ad_id: str,
|
|
993
|
+
name: Optional[str] = None,
|
|
994
|
+
status: Optional[str] = None,
|
|
995
|
+
bid_amount: Optional[int] = None,
|
|
996
|
+
tracking_specs: Optional[List[Dict[str, Any]]] = None,
|
|
997
|
+
creative_id: Optional[Union[str, int]] = None,
|
|
998
|
+
access_token: Optional[str] = None
|
|
999
|
+
) -> str:
|
|
1000
|
+
"""
|
|
1001
|
+
Update an ad with new settings.
|
|
1002
|
+
|
|
1003
|
+
Args:
|
|
1004
|
+
ad_id: Meta Ads ad ID
|
|
1005
|
+
name: New ad name
|
|
1006
|
+
status: Update ad status (ACTIVE, PAUSED, etc.)
|
|
1007
|
+
bid_amount: Bid amount in account currency (in cents for USD)
|
|
1008
|
+
tracking_specs: Optional tracking specifications (e.g., for pixel events).
|
|
1009
|
+
creative_id: ID of the creative to associate with this ad (changes the ad's image/content)
|
|
1010
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
1011
|
+
"""
|
|
1012
|
+
if not ad_id:
|
|
1013
|
+
return json.dumps({"error": "Ad ID is required"}, indent=2)
|
|
1014
|
+
|
|
1015
|
+
# Coerce numeric IDs to strings (LLM clients may send integers for numeric-only IDs)
|
|
1016
|
+
if creative_id is not None:
|
|
1017
|
+
creative_id = str(creative_id)
|
|
1018
|
+
|
|
1019
|
+
params = {}
|
|
1020
|
+
if name is not None:
|
|
1021
|
+
params["name"] = name
|
|
1022
|
+
if status:
|
|
1023
|
+
params["status"] = status
|
|
1024
|
+
if bid_amount is not None:
|
|
1025
|
+
# Ensure bid_amount is sent as a string if it's not null
|
|
1026
|
+
params["bid_amount"] = str(bid_amount)
|
|
1027
|
+
if tracking_specs is not None: # Add tracking_specs to params if provided
|
|
1028
|
+
params["tracking_specs"] = json.dumps(tracking_specs) # Needs to be JSON encoded string
|
|
1029
|
+
if creative_id is not None:
|
|
1030
|
+
# Creative parameter needs to be a JSON object containing creative_id
|
|
1031
|
+
params["creative"] = json.dumps({"creative_id": creative_id})
|
|
1032
|
+
|
|
1033
|
+
if not params:
|
|
1034
|
+
return json.dumps({"error": "No update parameters provided (name, status, bid_amount, tracking_specs, or creative_id)"}, indent=2)
|
|
1035
|
+
|
|
1036
|
+
endpoint = f"{ad_id}"
|
|
1037
|
+
try:
|
|
1038
|
+
data = await make_api_request(endpoint, access_token, params, method='POST')
|
|
1039
|
+
|
|
1040
|
+
# Check for FLEX creative image mismatch error (3858355)
|
|
1041
|
+
if creative_id is not None and "error" in data:
|
|
1042
|
+
error_obj = data.get("error", {})
|
|
1043
|
+
if isinstance(error_obj, dict):
|
|
1044
|
+
error_details = error_obj.get("details", {})
|
|
1045
|
+
if isinstance(error_details, dict):
|
|
1046
|
+
inner_error = error_details.get("error", {})
|
|
1047
|
+
error_subcode = inner_error.get("error_subcode") if isinstance(inner_error, dict) else None
|
|
1048
|
+
else:
|
|
1049
|
+
error_subcode = error_obj.get("error_subcode")
|
|
1050
|
+
else:
|
|
1051
|
+
error_subcode = None
|
|
1052
|
+
|
|
1053
|
+
if error_subcode == 3858355:
|
|
1054
|
+
return json.dumps({
|
|
1055
|
+
"error": "Cannot swap creative on this ad due to FLEX image mismatch",
|
|
1056
|
+
"error_subcode": 3858355,
|
|
1057
|
+
"explanation": (
|
|
1058
|
+
"Meta requires the first image in the new creative's asset_feed_spec "
|
|
1059
|
+
"to match the image in its object_story_spec. When swapping a FLEX "
|
|
1060
|
+
"creative on an existing ad, this validation can fail if the new "
|
|
1061
|
+
"creative has different images than the original."
|
|
1062
|
+
),
|
|
1063
|
+
"workaround": (
|
|
1064
|
+
"Create a new ad with the new creative instead of swapping: "
|
|
1065
|
+
"(1) call create_ad with the new creative_id and the same adset_id, "
|
|
1066
|
+
"(2) pause the old ad with update_ad(ad_id, status='PAUSED'). "
|
|
1067
|
+
"Note: this will lose social proof (likes, comments, shares) from the original ad."
|
|
1068
|
+
),
|
|
1069
|
+
"ad_id": ad_id,
|
|
1070
|
+
"creative_id": creative_id
|
|
1071
|
+
}, indent=2)
|
|
1072
|
+
|
|
1073
|
+
return json.dumps(data, indent=2)
|
|
1074
|
+
except Exception as e:
|
|
1075
|
+
return json.dumps({"error": f"Failed to update ad: {str(e)}"}, indent=2)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@meta_api_tool
|
|
1079
|
+
async def upload_ad_image(
|
|
1080
|
+
account_id: str,
|
|
1081
|
+
access_token: Optional[str] = None,
|
|
1082
|
+
file: Optional[str] = None,
|
|
1083
|
+
image_url: Optional[str] = None,
|
|
1084
|
+
name: Optional[str] = None
|
|
1085
|
+
) -> str:
|
|
1086
|
+
"""
|
|
1087
|
+
Upload an image to use in Meta Ads creatives.
|
|
1088
|
+
|
|
1089
|
+
Args:
|
|
1090
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
1091
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
1092
|
+
file: Data URL or raw base64 string of the image (e.g., "data:image/png;base64,iVBORw0KG...")
|
|
1093
|
+
image_url: Direct URL to an image to fetch and upload
|
|
1094
|
+
name: Optional name for the image (default: filename)
|
|
1095
|
+
|
|
1096
|
+
Returns:
|
|
1097
|
+
JSON response with image details including hash for creative creation
|
|
1098
|
+
"""
|
|
1099
|
+
# Check required parameters
|
|
1100
|
+
if not account_id:
|
|
1101
|
+
return json.dumps({"error": "No account ID provided"}, indent=2)
|
|
1102
|
+
|
|
1103
|
+
# Ensure we have image data
|
|
1104
|
+
if not file and not image_url:
|
|
1105
|
+
return json.dumps({"error": "Provide either 'file' (data URL or base64) or 'image_url'"}, indent=2)
|
|
1106
|
+
|
|
1107
|
+
account_id = ensure_act_prefix(account_id)
|
|
1108
|
+
|
|
1109
|
+
try:
|
|
1110
|
+
# Determine encoded_image (base64 string without data URL prefix) and a sensible name
|
|
1111
|
+
encoded_image: str = ""
|
|
1112
|
+
inferred_name: str = name or ""
|
|
1113
|
+
|
|
1114
|
+
if file:
|
|
1115
|
+
# Support data URL (e.g., data:image/png;base64,...) and raw base64
|
|
1116
|
+
data_url_prefix = "data:"
|
|
1117
|
+
base64_marker = "base64,"
|
|
1118
|
+
if file.startswith(data_url_prefix) and base64_marker in file:
|
|
1119
|
+
header, base64_payload = file.split(base64_marker, 1)
|
|
1120
|
+
encoded_image = base64_payload.strip()
|
|
1121
|
+
|
|
1122
|
+
# Infer file extension from MIME type if name not provided
|
|
1123
|
+
if not inferred_name:
|
|
1124
|
+
# Example header: data:image/png;...
|
|
1125
|
+
mime_type = header[len(data_url_prefix):].split(";")[0].strip()
|
|
1126
|
+
extension_map = {
|
|
1127
|
+
"image/png": ".png",
|
|
1128
|
+
"image/jpeg": ".jpg",
|
|
1129
|
+
"image/jpg": ".jpg",
|
|
1130
|
+
"image/webp": ".webp",
|
|
1131
|
+
"image/gif": ".gif",
|
|
1132
|
+
"image/bmp": ".bmp",
|
|
1133
|
+
"image/tiff": ".tiff",
|
|
1134
|
+
}
|
|
1135
|
+
ext = extension_map.get(mime_type, ".png")
|
|
1136
|
+
inferred_name = f"upload{ext}"
|
|
1137
|
+
else:
|
|
1138
|
+
# Assume it's already raw base64
|
|
1139
|
+
encoded_image = file.strip()
|
|
1140
|
+
if not inferred_name:
|
|
1141
|
+
inferred_name = "upload.png"
|
|
1142
|
+
else:
|
|
1143
|
+
# Download image from URL
|
|
1144
|
+
try:
|
|
1145
|
+
image_bytes = await try_multiple_download_methods(image_url)
|
|
1146
|
+
except Exception as download_error:
|
|
1147
|
+
return json.dumps({
|
|
1148
|
+
"error": "We couldn't download the image from the link provided.",
|
|
1149
|
+
"reason": "The server returned an error while trying to fetch the image.",
|
|
1150
|
+
"image_url": image_url,
|
|
1151
|
+
"details": str(download_error),
|
|
1152
|
+
"suggestions": [
|
|
1153
|
+
"Easiest fix: upload your image at https://pipeboard.co/creatives, then copy the image hash and use it directly instead of a URL.",
|
|
1154
|
+
"Make sure the link is publicly reachable (no login, VPN, or IP restrictions). Local file paths (file://...) cannot be accessed by the server.",
|
|
1155
|
+
"If the image is hosted on a private app or server, move it to a public URL or a CDN and try again.",
|
|
1156
|
+
"Verify the URL is correct and serves the actual image file."
|
|
1157
|
+
]
|
|
1158
|
+
}, indent=2)
|
|
1159
|
+
|
|
1160
|
+
if not image_bytes:
|
|
1161
|
+
return json.dumps({
|
|
1162
|
+
"error": "We couldn't access the image at the link you provided.",
|
|
1163
|
+
"reason": "The image link doesn't appear to be publicly accessible or didn't return any data.",
|
|
1164
|
+
"image_url": image_url,
|
|
1165
|
+
"suggestions": [
|
|
1166
|
+
"Easiest fix: upload your image at https://pipeboard.co/creatives, then copy the image hash and use it directly instead of a URL.",
|
|
1167
|
+
"Double-check that the link is public and does not require login, VPN, or IP allow-listing. Local file paths (file://...) cannot be accessed by the server.",
|
|
1168
|
+
"If the image is stored in a private app (for example, a self-hosted gallery), upload it to a public URL or a CDN and try again.",
|
|
1169
|
+
"Confirm the URL is correct and points directly to an image file (e.g., .jpg, .png)."
|
|
1170
|
+
]
|
|
1171
|
+
}, indent=2)
|
|
1172
|
+
|
|
1173
|
+
import base64 # Local import
|
|
1174
|
+
encoded_image = base64.b64encode(image_bytes).decode("utf-8")
|
|
1175
|
+
|
|
1176
|
+
# Infer name from URL if not provided
|
|
1177
|
+
if not inferred_name:
|
|
1178
|
+
try:
|
|
1179
|
+
path_no_query = image_url.split("?")[0]
|
|
1180
|
+
filename_from_url = os.path.basename(path_no_query)
|
|
1181
|
+
inferred_name = filename_from_url if filename_from_url else "upload.jpg"
|
|
1182
|
+
except Exception:
|
|
1183
|
+
inferred_name = "upload.jpg"
|
|
1184
|
+
|
|
1185
|
+
# Final name resolution
|
|
1186
|
+
final_name = name or inferred_name or "upload.png"
|
|
1187
|
+
|
|
1188
|
+
# Prepare the API endpoint for uploading images
|
|
1189
|
+
endpoint = f"{account_id}/adimages"
|
|
1190
|
+
|
|
1191
|
+
# Prepare POST parameters expected by Meta API
|
|
1192
|
+
params = {
|
|
1193
|
+
"bytes": encoded_image,
|
|
1194
|
+
"name": final_name,
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
# Make API request to upload the image
|
|
1198
|
+
print(f"Uploading image to Facebook Ad Account {account_id}")
|
|
1199
|
+
data = await make_api_request(endpoint, access_token, params, method="POST")
|
|
1200
|
+
|
|
1201
|
+
# Normalize/structure the response for callers (e.g., to easily grab image_hash)
|
|
1202
|
+
# Typical Graph API response shape:
|
|
1203
|
+
# { "images": { "<hash>": { "hash": "<hash>", "url": "...", "width": ..., "height": ..., "name": "...", "status": 1 } } }
|
|
1204
|
+
if isinstance(data, dict) and "images" in data and isinstance(data["images"], dict) and data["images"]:
|
|
1205
|
+
images_dict = data["images"]
|
|
1206
|
+
images_list = []
|
|
1207
|
+
for hash_key, info in images_dict.items():
|
|
1208
|
+
# Some responses may omit the nested hash, so ensure it's present
|
|
1209
|
+
normalized = {
|
|
1210
|
+
"hash": (info.get("hash") or hash_key),
|
|
1211
|
+
"url": info.get("url"),
|
|
1212
|
+
"width": info.get("width"),
|
|
1213
|
+
"height": info.get("height"),
|
|
1214
|
+
"name": info.get("name"),
|
|
1215
|
+
}
|
|
1216
|
+
# Drop null/None values
|
|
1217
|
+
normalized = {k: v for k, v in normalized.items() if v is not None}
|
|
1218
|
+
images_list.append(normalized)
|
|
1219
|
+
|
|
1220
|
+
# Sort deterministically by hash
|
|
1221
|
+
images_list.sort(key=lambda i: i.get("hash", ""))
|
|
1222
|
+
primary_hash = images_list[0].get("hash") if images_list else None
|
|
1223
|
+
|
|
1224
|
+
result = {
|
|
1225
|
+
"success": True,
|
|
1226
|
+
"account_id": account_id,
|
|
1227
|
+
"name": final_name,
|
|
1228
|
+
"image_hash": primary_hash,
|
|
1229
|
+
"images_count": len(images_list),
|
|
1230
|
+
"images": images_list
|
|
1231
|
+
}
|
|
1232
|
+
return json.dumps(result, indent=2)
|
|
1233
|
+
|
|
1234
|
+
# If the API returned an error-like structure, surface it consistently
|
|
1235
|
+
if isinstance(data, dict) and "error" in data:
|
|
1236
|
+
return json.dumps({
|
|
1237
|
+
"error": "Failed to upload image",
|
|
1238
|
+
"details": data.get("error"),
|
|
1239
|
+
"account_id": account_id,
|
|
1240
|
+
"name": final_name
|
|
1241
|
+
}, indent=2)
|
|
1242
|
+
|
|
1243
|
+
# Fallback: return a wrapped raw response to avoid breaking callers
|
|
1244
|
+
return json.dumps({
|
|
1245
|
+
"success": True,
|
|
1246
|
+
"account_id": account_id,
|
|
1247
|
+
"name": final_name,
|
|
1248
|
+
"raw_response": data
|
|
1249
|
+
}, indent=2)
|
|
1250
|
+
|
|
1251
|
+
except Exception as e:
|
|
1252
|
+
return json.dumps({
|
|
1253
|
+
"error": "Failed to upload image",
|
|
1254
|
+
"details": str(e)
|
|
1255
|
+
}, indent=2)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
# Valid image_crops keys accepted by Meta's API and their aspect ratios (width/height).
|
|
1259
|
+
_VALID_CROP_KEYS: list[tuple[str, int, int]] = [
|
|
1260
|
+
("100x100", 100, 100), # 1:1 square -- Feed, Marketplace, Search
|
|
1261
|
+
("100x72", 100, 72), # ~1.39:1 horizontal -- Marketplace, some placements
|
|
1262
|
+
("400x500", 400, 500), # 4:5 portrait -- Feed on mobile, Stories fallback
|
|
1263
|
+
("400x150", 400, 150), # ~2.67:1 wide banner -- Audience Network
|
|
1264
|
+
("600x360", 600, 360), # ~1.67:1 horizontal -- Right column, some placements
|
|
1265
|
+
("90x160", 90, 160), # 9:16 tall portrait -- Stories
|
|
1266
|
+
]
|
|
1267
|
+
_VALID_CROP_KEY_NAMES = [k for k, _, _ in _VALID_CROP_KEYS]
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _compute_crop_box(
|
|
1271
|
+
src_w: int, src_h: int, kw: int, kh: int
|
|
1272
|
+
) -> list[list[int]]:
|
|
1273
|
+
"""
|
|
1274
|
+
Compute the largest centered crop box that fits within src_wxsrc_h
|
|
1275
|
+
while matching the aspect ratio kw:kh.
|
|
1276
|
+
|
|
1277
|
+
Returns [[x1, y1], [x2, y2]] in pixel coordinates.
|
|
1278
|
+
"""
|
|
1279
|
+
# Scale to fill the full height; check if it fits within width.
|
|
1280
|
+
crop_w_from_h = src_h * kw / kh
|
|
1281
|
+
if crop_w_from_h <= src_w:
|
|
1282
|
+
# Use full height; crop width centered.
|
|
1283
|
+
crop_w = round(crop_w_from_h)
|
|
1284
|
+
crop_h = src_h
|
|
1285
|
+
else:
|
|
1286
|
+
# Use full width; crop height centered.
|
|
1287
|
+
crop_w = src_w
|
|
1288
|
+
crop_h = round(src_w * kh / kw)
|
|
1289
|
+
|
|
1290
|
+
x1 = (src_w - crop_w) // 2
|
|
1291
|
+
y1 = (src_h - crop_h) // 2
|
|
1292
|
+
return [[x1, y1], [x1 + crop_w, y1 + crop_h]]
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
async def compute_image_crops(
|
|
1296
|
+
image_width: int,
|
|
1297
|
+
image_height: int,
|
|
1298
|
+
crop_keys: Optional[List[str]] = None,
|
|
1299
|
+
) -> str:
|
|
1300
|
+
"""
|
|
1301
|
+
Compute image_crops coordinates for a source image of the given dimensions.
|
|
1302
|
+
|
|
1303
|
+
Returns the image_crops dict ready to pass directly to create_ad_creative
|
|
1304
|
+
or bulk_create_ad_creatives. For each crop key the result is the largest
|
|
1305
|
+
centered region that fits within the source image while matching the key's
|
|
1306
|
+
aspect ratio -- equivalent to "Original" crop (no content is cut off beyond
|
|
1307
|
+
what the ratio requires).
|
|
1308
|
+
|
|
1309
|
+
Args:
|
|
1310
|
+
image_width: Width of the source image in pixels (e.g. 1080).
|
|
1311
|
+
image_height: Height of the source image in pixels (e.g. 1080).
|
|
1312
|
+
crop_keys: Optional list of specific crop keys to compute. Defaults to
|
|
1313
|
+
all 6 keys accepted by Meta's API:
|
|
1314
|
+
"100x100" -- 1:1 square (Feed, Marketplace, Search)
|
|
1315
|
+
"100x72" -- ~1.39:1 horizontal (Marketplace, some placements)
|
|
1316
|
+
"400x500" -- 4:5 portrait (Feed on mobile, Stories fallback)
|
|
1317
|
+
"400x150" -- ~2.67:1 wide banner (Audience Network)
|
|
1318
|
+
"600x360" -- ~1.67:1 horizontal (Right column, some placements)
|
|
1319
|
+
"90x160" -- 9:16 tall portrait (Stories)
|
|
1320
|
+
|
|
1321
|
+
Returns:
|
|
1322
|
+
JSON with the image_crops dict (ready for copy-paste into create_ad_creative),
|
|
1323
|
+
plus validation notes for any invalid keys requested.
|
|
1324
|
+
"""
|
|
1325
|
+
if image_width <= 0 or image_height <= 0:
|
|
1326
|
+
return json.dumps({
|
|
1327
|
+
"error": "image_width and image_height must be positive integers."
|
|
1328
|
+
}, indent=2)
|
|
1329
|
+
|
|
1330
|
+
# Resolve which keys to compute.
|
|
1331
|
+
if crop_keys:
|
|
1332
|
+
requested = crop_keys
|
|
1333
|
+
else:
|
|
1334
|
+
requested = _VALID_CROP_KEY_NAMES
|
|
1335
|
+
|
|
1336
|
+
crops: dict[str, list[list[int]]] = {}
|
|
1337
|
+
warnings: list[str] = []
|
|
1338
|
+
|
|
1339
|
+
key_map = {k: (kw, kh) for k, kw, kh in _VALID_CROP_KEYS}
|
|
1340
|
+
|
|
1341
|
+
for key in requested:
|
|
1342
|
+
if key not in key_map:
|
|
1343
|
+
warnings.append(
|
|
1344
|
+
f"'{key}' is not a valid Meta API crop key and was skipped. "
|
|
1345
|
+
f"Valid keys: {', '.join(_VALID_CROP_KEY_NAMES)}."
|
|
1346
|
+
)
|
|
1347
|
+
continue
|
|
1348
|
+
kw, kh = key_map[key]
|
|
1349
|
+
crops[key] = _compute_crop_box(image_width, image_height, kw, kh)
|
|
1350
|
+
|
|
1351
|
+
result: dict = {
|
|
1352
|
+
"image_crops": crops,
|
|
1353
|
+
"usage": (
|
|
1354
|
+
"Pass image_crops directly to create_ad_creative or as the image_crops "
|
|
1355
|
+
"field inside each element of bulk_create_ad_creatives."
|
|
1356
|
+
),
|
|
1357
|
+
"source_dimensions": {"width": image_width, "height": image_height},
|
|
1358
|
+
}
|
|
1359
|
+
if warnings:
|
|
1360
|
+
result["warnings"] = warnings
|
|
1361
|
+
|
|
1362
|
+
return json.dumps(result, indent=2)
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
@meta_api_tool
|
|
1366
|
+
async def create_ad_creative(
|
|
1367
|
+
account_id: str,
|
|
1368
|
+
image_hash: Optional[str] = None,
|
|
1369
|
+
access_token: Optional[str] = None,
|
|
1370
|
+
name: Optional[str] = None,
|
|
1371
|
+
page_id: Optional[Union[str, int]] = None,
|
|
1372
|
+
link_url: Optional[str] = None,
|
|
1373
|
+
message: Optional[str] = None,
|
|
1374
|
+
messages: Optional[List[str]] = None,
|
|
1375
|
+
headline: Optional[str] = None,
|
|
1376
|
+
headlines: Optional[List[str]] = None,
|
|
1377
|
+
description: Optional[str] = None,
|
|
1378
|
+
descriptions: Optional[List[str]] = None,
|
|
1379
|
+
image_hashes: Optional[List[str]] = None,
|
|
1380
|
+
video_id: Optional[Union[str, int]] = None,
|
|
1381
|
+
thumbnail_url: Optional[str] = None,
|
|
1382
|
+
optimization_type: Optional[str] = None,
|
|
1383
|
+
dynamic_creative_spec: Optional[Dict[str, Any]] = None,
|
|
1384
|
+
call_to_action_type: Optional[str] = None,
|
|
1385
|
+
lead_gen_form_id: Optional[Union[str, int]] = None,
|
|
1386
|
+
instagram_actor_id: Optional[str] = None,
|
|
1387
|
+
ad_formats: Optional[List[str]] = None,
|
|
1388
|
+
asset_customization_rules: Optional[List[Dict[str, Any]]] = None,
|
|
1389
|
+
creative_features_spec: Optional[Dict[str, Any]] = None,
|
|
1390
|
+
phone_number: Optional[str] = None,
|
|
1391
|
+
url_tags: Optional[str] = None,
|
|
1392
|
+
caption: Optional[str] = None,
|
|
1393
|
+
image_crops: Optional[Dict[str, Any]] = None,
|
|
1394
|
+
object_story_id: Optional[str] = None,
|
|
1395
|
+
disable_all_enhancements: Optional[bool] = None,
|
|
1396
|
+
) -> str:
|
|
1397
|
+
"""
|
|
1398
|
+
Create a new ad creative using an uploaded image hash, video ID, or an existing post.
|
|
1399
|
+
|
|
1400
|
+
Supports five creative modes:
|
|
1401
|
+
- **Existing post**: Provide object_story_id (format: {page_id}_{post_id}) to promote an existing
|
|
1402
|
+
organic or published post. No image_hash or video_id required. Optionally combine with
|
|
1403
|
+
asset_customization_rules to attach a 9:16 video for Story/Reels placements.
|
|
1404
|
+
- **Simple image/video**: Single image_hash or video_id with object_story_spec
|
|
1405
|
+
- **Multi-variant copy**: Use plural text params (messages[], headlines[], descriptions[]) to test
|
|
1406
|
+
multiple text variants with a single image/video. No optimization_type or is_dynamic_creative needed.
|
|
1407
|
+
- **Dynamic Creative**: Multiple variants with dynamic_creative_spec (requires is_dynamic_creative on ad set)
|
|
1408
|
+
- **FLEX/DOF (Advantage+)**: Set optimization_type="DEGREES_OF_FREEDOM" for Meta to auto-optimize
|
|
1409
|
+
across all asset combinations without requiring is_dynamic_creative on the ad set
|
|
1410
|
+
|
|
1411
|
+
Args:
|
|
1412
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
1413
|
+
image_hash: Hash of a single uploaded image (cannot be used with image_hashes or video_id)
|
|
1414
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
1415
|
+
name: Creative name
|
|
1416
|
+
page_id: Facebook Page ID (string or int; coerced to string)
|
|
1417
|
+
link_url: Destination URL for the ad (required unless using lead_gen_form_id)
|
|
1418
|
+
message: Single ad copy/text (cannot be used with messages)
|
|
1419
|
+
messages: List of primary text variants for multi-variant copy testing (cannot be used with message)
|
|
1420
|
+
headline: Single headline for simple ads (cannot be used with headlines)
|
|
1421
|
+
headlines: List of headline variants for multi-variant copy testing (cannot be used with headline)
|
|
1422
|
+
description: Single description for simple ads (cannot be used with descriptions)
|
|
1423
|
+
descriptions: List of description variants for multi-variant copy testing (cannot be used with description)
|
|
1424
|
+
image_hashes: List of image hashes for FLEX creatives (up to 10, cannot be used with image_hash or video_id)
|
|
1425
|
+
video_id: Meta video ID for video creatives (cannot be used with image_hash or image_hashes).
|
|
1426
|
+
Upload a video first via the Meta API, then use the returned video ID here.
|
|
1427
|
+
thumbnail_url: Thumbnail image URL for video creatives. Recommended when using video_id.
|
|
1428
|
+
Meta will auto-generate a thumbnail if not provided.
|
|
1429
|
+
optimization_type: Optional. Set to "DEGREES_OF_FREEDOM" for FLEX (Advantage+) creatives that
|
|
1430
|
+
allow Meta to auto-optimize across all asset combinations. Not required for
|
|
1431
|
+
text-only multi-variant creatives (messages[], headlines[], descriptions[]
|
|
1432
|
+
work without it). When using DEGREES_OF_FREEDOM, at least one asset field
|
|
1433
|
+
(image_hashes, messages, headlines, or descriptions) must contain more than
|
|
1434
|
+
one variant.
|
|
1435
|
+
NOTE: If asset_customization_rules is also provided, optimization_type
|
|
1436
|
+
is automatically removed because Meta ignores placement rules for DOF
|
|
1437
|
+
creatives. The creative will use regular dynamic creative mode instead.
|
|
1438
|
+
dynamic_creative_spec: Dynamic creative optimization settings
|
|
1439
|
+
call_to_action_type: Call to action button type (e.g., 'LEARN_MORE', 'SIGN_UP', 'SHOP_NOW',
|
|
1440
|
+
'CALL_NOW'). When using CALL_NOW, also provide phone_number.
|
|
1441
|
+
lead_gen_form_id: Lead generation form ID for lead generation campaigns. Required when using
|
|
1442
|
+
lead generation CTAs like 'SIGN_UP', 'GET_OFFER', 'SUBSCRIBE', etc.
|
|
1443
|
+
instagram_actor_id: Instagram account ID for Instagram placements (must be a string
|
|
1444
|
+
to avoid JavaScript integer precision loss for IDs exceeding
|
|
1445
|
+
Number.MAX_SAFE_INTEGER). Sent as instagram_user_id inside
|
|
1446
|
+
object_story_spec (Meta deprecated instagram_actor_id in Jan 2026).
|
|
1447
|
+
ad_formats: List of ad format strings for asset_feed_spec (e.g., ["AUTOMATIC_FORMAT"] for
|
|
1448
|
+
Flexible ads, ["SINGLE_IMAGE"] for single image, ["SINGLE_VIDEO"] for video).
|
|
1449
|
+
When optimization_type is "DEGREES_OF_FREEDOM" with image_hashes, defaults to
|
|
1450
|
+
["AUTOMATIC_FORMAT"] (Flexible format). For video creatives, defaults to
|
|
1451
|
+
["SINGLE_VIDEO"]. Otherwise defaults to ["SINGLE_IMAGE"].
|
|
1452
|
+
asset_customization_rules: List of placement-specific asset overrides for asset_feed_spec.
|
|
1453
|
+
phone_number: Phone number for CALL_NOW call-to-action ads (click-to-call).
|
|
1454
|
+
Required when call_to_action_type is CALL_NOW. Use E.164 format
|
|
1455
|
+
(e.g., "+18005551234"). The number is passed to Meta in
|
|
1456
|
+
call_to_action.value.phone_number. Common use case: geo-routed
|
|
1457
|
+
call ads with different phone numbers per ad set.
|
|
1458
|
+
creative_features_spec: Advantage+ Creative feature opt-ins/opt-outs. Controls individual
|
|
1459
|
+
creative enhancements like image_touchups, text_optimizations, inline_comment,
|
|
1460
|
+
add_text_overlay, music, 3d_animation, etc. Each feature is a dict with
|
|
1461
|
+
"enroll_status" set to "OPT_IN" or "OPT_OUT".
|
|
1462
|
+
Example: {"image_touchups": {"enroll_status": "OPT_IN"},
|
|
1463
|
+
"inline_comment": {"enroll_status": "OPT_IN"}}
|
|
1464
|
+
Sent to Meta as degrees_of_freedom_spec.creative_features_spec.
|
|
1465
|
+
url_tags: URL tracking parameters appended to the destination URL (e.g.,
|
|
1466
|
+
"utm_source=facebook&utm_medium=cpc&utm_campaign=spring_sale").
|
|
1467
|
+
Sets the url_tags field on the creative.
|
|
1468
|
+
caption: Display URL shown in the ad (e.g., "example.com/shoes"). Sets the
|
|
1469
|
+
caption field in link_data. If not provided, Meta auto-generates it
|
|
1470
|
+
from the destination URL. Only applies to image (link_data) creatives.
|
|
1471
|
+
image_crops: Crop coordinates for different aspect ratios. Applied in link_data for
|
|
1472
|
+
image creatives.
|
|
1473
|
+
|
|
1474
|
+
Use the compute_image_crops tool first to get the correct coordinates
|
|
1475
|
+
for your specific image dimensions -- it computes centered crop boxes
|
|
1476
|
+
for any source size automatically.
|
|
1477
|
+
|
|
1478
|
+
Valid crop keys (only these 6 are accepted by Meta's API):
|
|
1479
|
+
"100x100" -- 1:1 square (Feed, Marketplace, Search)
|
|
1480
|
+
"100x72" -- ~1.39:1 horizontal (Marketplace, some placements)
|
|
1481
|
+
"400x500" -- 4:5 portrait (Feed on mobile, Stories fallback)
|
|
1482
|
+
"400x150" -- ~2.67:1 wide banner (Audience Network)
|
|
1483
|
+
"600x360" -- ~1.67:1 horizontal (Right column, some placements)
|
|
1484
|
+
"90x160" -- 9:16 tall portrait (Stories)
|
|
1485
|
+
|
|
1486
|
+
Format: {"100x100": [[x1,y1],[x2,y2]], "400x500": [[x1,y1],[x2,y2]]}
|
|
1487
|
+
Coordinates are pixel-based (top-left and bottom-right corners).
|
|
1488
|
+
The bounding box aspect ratio must match the key ratio as closely as possible.
|
|
1489
|
+
Image origin (0,0) is the upper-left corner.
|
|
1490
|
+
|
|
1491
|
+
Omit to let Meta auto-crop (default for horizontal is 1.91:1 recommended).
|
|
1492
|
+
object_story_id: ID of an existing organic or published Facebook/Instagram post to promote
|
|
1493
|
+
as an ad. Format: "{page_id}_{post_id}" (e.g., "124965744226834_3888007311337206").
|
|
1494
|
+
When provided, image_hash and video_id are not required. page_id is also not
|
|
1495
|
+
required (it is encoded in the story ID). Combine with asset_customization_rules
|
|
1496
|
+
to attach a 9:16 video for Story/Reels placements while the organic post
|
|
1497
|
+
serves as the feed creative -- a common "Use Existing Post" workflow.
|
|
1498
|
+
Example: object_story_id="124965744226834_3888007311337206",
|
|
1499
|
+
asset_customization_rules=[{"placement_groups": ["STORY"],
|
|
1500
|
+
"customization_spec": {"video_ids": ["890310874031162"]}}]
|
|
1501
|
+
disable_all_enhancements: When True, opts out of all Advantage+ Creative enhancements by
|
|
1502
|
+
setting every known creative_features_spec key (image_touchups,
|
|
1503
|
+
text_optimizations, video_auto_crop, etc.) to OPT_OUT and also
|
|
1504
|
+
disabling contextual_multi_ads. Use when you want full creative
|
|
1505
|
+
control without Meta's auto-modifications.
|
|
1506
|
+
asset_customization_rules: Lets you assign different images or videos to specific placement groups
|
|
1507
|
+
(e.g., feed vs. stories). Only valid with image_hashes or plural asset params.
|
|
1508
|
+
Each rule uses a user-friendly format that is automatically translated to
|
|
1509
|
+
Meta's API format (adlabels + customization_spec positions):
|
|
1510
|
+
- placement_groups: list of placement group names
|
|
1511
|
+
Valid values: FEED, STORY, MESSENGER, INSTREAM_VIDEO, SEARCH, SHOP,
|
|
1512
|
+
AUDIENCE_NETWORK
|
|
1513
|
+
- customization_spec: dict specifying the asset to use for those placements
|
|
1514
|
+
Supported keys: image_hashes (list), video_ids (list),
|
|
1515
|
+
bodies, titles, descriptions (text overrides)
|
|
1516
|
+
All image hashes referenced in rules must also be in image_hashes.
|
|
1517
|
+
Example (feed gets one image, stories gets another):
|
|
1518
|
+
[
|
|
1519
|
+
{"placement_groups": ["FEED"],
|
|
1520
|
+
"customization_spec": {"image_hashes": ["<feed_hash>"]}},
|
|
1521
|
+
{"placement_groups": ["STORY"],
|
|
1522
|
+
"customization_spec": {"image_hashes": ["<story_hash>"]}}
|
|
1523
|
+
]
|
|
1524
|
+
|
|
1525
|
+
Returns:
|
|
1526
|
+
JSON response with created creative details
|
|
1527
|
+
"""
|
|
1528
|
+
# Check required parameters
|
|
1529
|
+
if not account_id:
|
|
1530
|
+
return json.dumps({"error": "No account ID provided"}, indent=2)
|
|
1531
|
+
|
|
1532
|
+
# Coerce numeric IDs to strings (LLM clients may send integers for numeric-only IDs)
|
|
1533
|
+
if video_id is not None:
|
|
1534
|
+
video_id = str(video_id)
|
|
1535
|
+
if instagram_actor_id is not None:
|
|
1536
|
+
instagram_actor_id = str(instagram_actor_id).strip('"').strip("'")
|
|
1537
|
+
if lead_gen_form_id is not None:
|
|
1538
|
+
lead_gen_form_id = str(lead_gen_form_id)
|
|
1539
|
+
|
|
1540
|
+
# Defensive coercion: some MCP transports deliver array/dict params as JSON strings
|
|
1541
|
+
if isinstance(asset_customization_rules, str):
|
|
1542
|
+
try:
|
|
1543
|
+
_parsed = json.loads(asset_customization_rules)
|
|
1544
|
+
if isinstance(_parsed, list):
|
|
1545
|
+
asset_customization_rules = _parsed
|
|
1546
|
+
except (json.JSONDecodeError, TypeError):
|
|
1547
|
+
pass
|
|
1548
|
+
|
|
1549
|
+
if isinstance(creative_features_spec, str):
|
|
1550
|
+
try:
|
|
1551
|
+
_parsed = json.loads(creative_features_spec)
|
|
1552
|
+
if isinstance(_parsed, dict):
|
|
1553
|
+
creative_features_spec = _parsed
|
|
1554
|
+
except (json.JSONDecodeError, TypeError):
|
|
1555
|
+
pass
|
|
1556
|
+
|
|
1557
|
+
if isinstance(image_crops, str):
|
|
1558
|
+
try:
|
|
1559
|
+
_parsed = json.loads(image_crops)
|
|
1560
|
+
if isinstance(_parsed, dict):
|
|
1561
|
+
image_crops = _parsed
|
|
1562
|
+
except (json.JSONDecodeError, TypeError):
|
|
1563
|
+
pass
|
|
1564
|
+
|
|
1565
|
+
for _param_name, _param_val in [
|
|
1566
|
+
('image_hashes', image_hashes),
|
|
1567
|
+
('messages', messages),
|
|
1568
|
+
('headlines', headlines),
|
|
1569
|
+
('descriptions', descriptions),
|
|
1570
|
+
('ad_formats', ad_formats),
|
|
1571
|
+
]:
|
|
1572
|
+
if isinstance(_param_val, str):
|
|
1573
|
+
try:
|
|
1574
|
+
_parsed = json.loads(_param_val)
|
|
1575
|
+
if isinstance(_parsed, list):
|
|
1576
|
+
if _param_name == 'image_hashes':
|
|
1577
|
+
image_hashes = _parsed
|
|
1578
|
+
elif _param_name == 'messages':
|
|
1579
|
+
messages = _parsed
|
|
1580
|
+
elif _param_name == 'headlines':
|
|
1581
|
+
headlines = _parsed
|
|
1582
|
+
elif _param_name == 'descriptions':
|
|
1583
|
+
descriptions = _parsed
|
|
1584
|
+
elif _param_name == 'ad_formats':
|
|
1585
|
+
ad_formats = _parsed
|
|
1586
|
+
except (json.JSONDecodeError, TypeError):
|
|
1587
|
+
pass
|
|
1588
|
+
|
|
1589
|
+
logger.debug(
|
|
1590
|
+
"create_ad_creative called: image_hash=%s, image_hashes=%s(%s), video_id=%s, "
|
|
1591
|
+
"messages=%s, headlines=%s, descriptions=%s, optimization_type=%s",
|
|
1592
|
+
type(image_hash).__name__,
|
|
1593
|
+
type(image_hashes).__name__, image_hashes,
|
|
1594
|
+
video_id,
|
|
1595
|
+
type(messages).__name__,
|
|
1596
|
+
type(headlines).__name__,
|
|
1597
|
+
type(descriptions).__name__,
|
|
1598
|
+
optimization_type,
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
# Validate media mutual exclusivity: exactly one of image_hash, image_hashes, or video_id
|
|
1602
|
+
# (object_story_id is an alternative media source -- it references an existing post)
|
|
1603
|
+
media_params = sum(1 for x in [image_hash, image_hashes, video_id] if x)
|
|
1604
|
+
if media_params > 1:
|
|
1605
|
+
return json.dumps({"error": "Only one media source allowed. Use 'image_hash' for a single image, 'image_hashes' for multiple images, or 'video_id' for video."}, indent=2)
|
|
1606
|
+
|
|
1607
|
+
if media_params == 0 and not object_story_id:
|
|
1608
|
+
return json.dumps({"error": "No media provided. Specify 'image_hash' for a single image, 'image_hashes' for multiple images, 'video_id' for a video, or 'object_story_id' to promote an existing post (format: {page_id}_{post_id})."}, indent=2)
|
|
1609
|
+
|
|
1610
|
+
# Validate image_hashes limits
|
|
1611
|
+
if image_hashes:
|
|
1612
|
+
if len(image_hashes) > 10:
|
|
1613
|
+
return json.dumps({"error": "Maximum 10 image hashes allowed for FLEX creatives"}, indent=2)
|
|
1614
|
+
|
|
1615
|
+
# Validate thumbnail_url only with video_id
|
|
1616
|
+
if thumbnail_url and not video_id:
|
|
1617
|
+
return json.dumps({"error": "thumbnail_url can only be used with video_id"}, indent=2)
|
|
1618
|
+
|
|
1619
|
+
# Validate optimization_type
|
|
1620
|
+
if optimization_type and optimization_type != "DEGREES_OF_FREEDOM":
|
|
1621
|
+
return json.dumps({"error": f"Invalid optimization_type '{optimization_type}'. Only 'DEGREES_OF_FREEDOM' is supported."}, indent=2)
|
|
1622
|
+
|
|
1623
|
+
# Validate message / messages mutual exclusivity
|
|
1624
|
+
if message and messages:
|
|
1625
|
+
return json.dumps({"error": "Cannot specify both 'message' and 'messages'. Use 'message' for single text or 'messages' for multiple variants."}, indent=2)
|
|
1626
|
+
|
|
1627
|
+
if not link_url and not lead_gen_form_id and not object_story_id:
|
|
1628
|
+
return json.dumps({"error": "No link_url provided. A destination URL is required for ad creatives (unless using lead_gen_form_id or object_story_id)."}, indent=2)
|
|
1629
|
+
|
|
1630
|
+
if not name:
|
|
1631
|
+
name = f"Creative {int(time.time())}"
|
|
1632
|
+
|
|
1633
|
+
account_id = ensure_act_prefix(account_id)
|
|
1634
|
+
|
|
1635
|
+
# Enhanced page discovery: If no page ID is provided, use robust discovery methods.
|
|
1636
|
+
# Skip when object_story_id is provided -- the page is embedded in the story ID format.
|
|
1637
|
+
if not page_id and not object_story_id:
|
|
1638
|
+
try:
|
|
1639
|
+
# Use the comprehensive page discovery logic from get_account_pages
|
|
1640
|
+
page_discovery_result = await _discover_pages_for_account(account_id, access_token)
|
|
1641
|
+
|
|
1642
|
+
if page_discovery_result.get("success"):
|
|
1643
|
+
page_id = page_discovery_result["page_id"]
|
|
1644
|
+
page_name = page_discovery_result.get("page_name", "Unknown")
|
|
1645
|
+
print(f"Auto-discovered page ID: {page_id} ({page_name})")
|
|
1646
|
+
else:
|
|
1647
|
+
return json.dumps({
|
|
1648
|
+
"error": "No page ID provided and no suitable pages found for this account",
|
|
1649
|
+
"details": page_discovery_result.get("message", "Page discovery failed"),
|
|
1650
|
+
"suggestions": [
|
|
1651
|
+
"Use get_account_pages to see available pages",
|
|
1652
|
+
"Use search_pages_by_name to find specific pages",
|
|
1653
|
+
"Provide a page_id parameter manually"
|
|
1654
|
+
]
|
|
1655
|
+
}, indent=2)
|
|
1656
|
+
except Exception as e:
|
|
1657
|
+
return json.dumps({
|
|
1658
|
+
"error": "Error during page discovery",
|
|
1659
|
+
"details": str(e),
|
|
1660
|
+
"suggestion": "Please provide a page_id parameter or use get_account_pages to find available pages"
|
|
1661
|
+
}, indent=2)
|
|
1662
|
+
|
|
1663
|
+
# Normalize page_id to string after all assignment paths (input param + discovery).
|
|
1664
|
+
# Skip when object_story_id is used -- page_id may be None in that path.
|
|
1665
|
+
if page_id is not None:
|
|
1666
|
+
page_id = str(page_id)
|
|
1667
|
+
|
|
1668
|
+
# Validate headline/description parameters - cannot mix simple and complex
|
|
1669
|
+
if headline and headlines:
|
|
1670
|
+
return json.dumps({"error": "Cannot specify both 'headline' and 'headlines'. Use 'headline' for single headline or 'headlines' for multiple."}, indent=2)
|
|
1671
|
+
|
|
1672
|
+
if description and descriptions:
|
|
1673
|
+
return json.dumps({"error": "Cannot specify both 'description' and 'descriptions'. Use 'description' for single description or 'descriptions' for multiple."}, indent=2)
|
|
1674
|
+
|
|
1675
|
+
# Validate dynamic creative parameters (plural forms only)
|
|
1676
|
+
if headlines:
|
|
1677
|
+
if len(headlines) > 5:
|
|
1678
|
+
return json.dumps({"error": "Maximum 5 headlines allowed for dynamic creatives"}, indent=2)
|
|
1679
|
+
for i, h in enumerate(headlines):
|
|
1680
|
+
if len(h) > 40:
|
|
1681
|
+
return json.dumps({"error": f"Headline {i+1} exceeds 40 character limit"}, indent=2)
|
|
1682
|
+
|
|
1683
|
+
if descriptions:
|
|
1684
|
+
if len(descriptions) > 5:
|
|
1685
|
+
return json.dumps({"error": "Maximum 5 descriptions allowed for dynamic creatives"}, indent=2)
|
|
1686
|
+
for i, d in enumerate(descriptions):
|
|
1687
|
+
if len(d) > 125:
|
|
1688
|
+
return json.dumps({"error": f"Description {i+1} exceeds 125 character limit"}, indent=2)
|
|
1689
|
+
|
|
1690
|
+
# Prepare the API endpoint for creating a creative
|
|
1691
|
+
endpoint = f"{account_id}/adcreatives"
|
|
1692
|
+
|
|
1693
|
+
try:
|
|
1694
|
+
# Prepare the creative data
|
|
1695
|
+
creative_data = {
|
|
1696
|
+
"name": name
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
# Auto-downgrade DOF when asset_customization_rules is provided.
|
|
1700
|
+
# Meta silently ignores asset_customization_rules for DEGREES_OF_FREEDOM
|
|
1701
|
+
# creatives (confirmed by e2e testing). Dropping optimization_type lets the
|
|
1702
|
+
# rules take effect under regular dynamic creative mode instead.
|
|
1703
|
+
dof_downgraded = False
|
|
1704
|
+
if optimization_type and asset_customization_rules:
|
|
1705
|
+
logger.info(
|
|
1706
|
+
"Dropping optimization_type=%s because asset_customization_rules is set "
|
|
1707
|
+
"(Meta ignores placement rules for DOF creatives)",
|
|
1708
|
+
optimization_type,
|
|
1709
|
+
)
|
|
1710
|
+
optimization_type = None
|
|
1711
|
+
dof_downgraded = True
|
|
1712
|
+
|
|
1713
|
+
# Determine whether to use asset_feed_spec path:
|
|
1714
|
+
# - plural parameters (headlines/descriptions/messages/image_hashes), OR
|
|
1715
|
+
# - optimization_type is set (FLEX creatives always use asset_feed_spec), OR
|
|
1716
|
+
# - asset_customization_rules requires asset_feed_spec, OR
|
|
1717
|
+
# - video_id + description: Meta's video_data rejects "description" directly,
|
|
1718
|
+
# so route through asset_feed_spec which supports descriptions for video ads
|
|
1719
|
+
use_asset_feed = bool(
|
|
1720
|
+
headlines or descriptions or messages or image_hashes or optimization_type
|
|
1721
|
+
or asset_customization_rules or (video_id and description)
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
# Track if this is a video creative
|
|
1725
|
+
is_video = bool(video_id)
|
|
1726
|
+
|
|
1727
|
+
# Meta API v24 REQUIRES a thumbnail (image_hash or image_url) in video_data.
|
|
1728
|
+
# If the caller didn't provide one, auto-fetch from the video object.
|
|
1729
|
+
if is_video and not thumbnail_url:
|
|
1730
|
+
try:
|
|
1731
|
+
video_info = await make_api_request(
|
|
1732
|
+
video_id, access_token, {"fields": "picture"}
|
|
1733
|
+
)
|
|
1734
|
+
if isinstance(video_info, dict) and "picture" in video_info:
|
|
1735
|
+
thumbnail_url = video_info["picture"]
|
|
1736
|
+
logger.info(f"Auto-fetched video thumbnail: {thumbnail_url[:80]}...")
|
|
1737
|
+
else:
|
|
1738
|
+
logger.warning(f"Could not auto-fetch thumbnail for video {video_id}: {video_info}")
|
|
1739
|
+
except Exception as e:
|
|
1740
|
+
logger.warning(f"Failed to auto-fetch thumbnail for video {video_id}: {e}")
|
|
1741
|
+
|
|
1742
|
+
if object_story_id:
|
|
1743
|
+
# ---------------------------------------------------------------------------
|
|
1744
|
+
# Existing-post (object_story_id) path: promote an organic/published post
|
|
1745
|
+
# ---------------------------------------------------------------------------
|
|
1746
|
+
creative_data["object_story_id"] = object_story_id
|
|
1747
|
+
|
|
1748
|
+
if asset_customization_rules:
|
|
1749
|
+
# Build asset_feed_spec with placement-specific video overrides
|
|
1750
|
+
# (e.g., a 9:16 video for Story/Reels while the post shows in feed)
|
|
1751
|
+
translated_rules_osi, videos_array_osi = _translate_video_customization_rules_for_existing_post(
|
|
1752
|
+
asset_customization_rules
|
|
1753
|
+
)
|
|
1754
|
+
asset_feed_spec_osi: Dict[str, Any] = {}
|
|
1755
|
+
if videos_array_osi:
|
|
1756
|
+
asset_feed_spec_osi["videos"] = videos_array_osi
|
|
1757
|
+
if translated_rules_osi:
|
|
1758
|
+
asset_feed_spec_osi["asset_customization_rules"] = translated_rules_osi
|
|
1759
|
+
if link_url:
|
|
1760
|
+
asset_feed_spec_osi["link_urls"] = [{"website_url": link_url}]
|
|
1761
|
+
if call_to_action_type:
|
|
1762
|
+
asset_feed_spec_osi["call_to_action_types"] = [call_to_action_type]
|
|
1763
|
+
if asset_feed_spec_osi:
|
|
1764
|
+
creative_data["asset_feed_spec"] = asset_feed_spec_osi
|
|
1765
|
+
elif call_to_action_type:
|
|
1766
|
+
# No asset_feed_spec: put CTA at top level for simple existing-post creatives
|
|
1767
|
+
cta_osi: Dict[str, Any] = {"type": call_to_action_type}
|
|
1768
|
+
cta_osi_value: Dict[str, Any] = {}
|
|
1769
|
+
if link_url:
|
|
1770
|
+
cta_osi_value["link"] = link_url
|
|
1771
|
+
if lead_gen_form_id:
|
|
1772
|
+
cta_osi_value["lead_gen_form_id"] = lead_gen_form_id
|
|
1773
|
+
if phone_number:
|
|
1774
|
+
cta_osi_value["phone_number"] = phone_number
|
|
1775
|
+
if cta_osi_value:
|
|
1776
|
+
cta_osi["value"] = cta_osi_value
|
|
1777
|
+
creative_data["call_to_action"] = cta_osi
|
|
1778
|
+
|
|
1779
|
+
if instagram_actor_id:
|
|
1780
|
+
creative_data["instagram_actor_id"] = instagram_actor_id
|
|
1781
|
+
|
|
1782
|
+
elif use_asset_feed:
|
|
1783
|
+
# Build the media array from the provided source
|
|
1784
|
+
if is_video:
|
|
1785
|
+
# Video in asset_feed_spec uses "videos" key
|
|
1786
|
+
videos_array = [{"video_id": video_id}]
|
|
1787
|
+
if thumbnail_url:
|
|
1788
|
+
videos_array[0]["thumbnail_url"] = thumbnail_url
|
|
1789
|
+
elif image_hashes:
|
|
1790
|
+
images_array = [{"hash": h} for h in image_hashes]
|
|
1791
|
+
else:
|
|
1792
|
+
images_array = [{"hash": image_hash}]
|
|
1793
|
+
|
|
1794
|
+
# Translate placement_groups-style asset_customization_rules to Meta API format.
|
|
1795
|
+
# Meta API uses customization_spec for placement selection (publisher_platforms,
|
|
1796
|
+
# facebook_positions, instagram_positions) and image_label/video_label at the
|
|
1797
|
+
# rule level for asset selection. Images also need adlabels assigned.
|
|
1798
|
+
if asset_customization_rules and not is_video:
|
|
1799
|
+
asset_customization_rules, images_array = _translate_asset_customization_rules(
|
|
1800
|
+
asset_customization_rules, images_array
|
|
1801
|
+
)
|
|
1802
|
+
|
|
1803
|
+
# ------------------------------------------------------------------
|
|
1804
|
+
# Build asset_feed_spec base: DOF vs non-DOF use different patterns.
|
|
1805
|
+
#
|
|
1806
|
+
# DOF (DEGREES_OF_FREEDOM / FLEX / Advantage+):
|
|
1807
|
+
# asset_feed_spec has ONLY: media, optimization_type, text variants.
|
|
1808
|
+
# URL, ad_formats, and CTA go in object_story_spec.link_data.
|
|
1809
|
+
# This matches the working Next.js duplication pattern -- Meta's
|
|
1810
|
+
# own GET response omits link_urls/ad_formats/call_to_action_types
|
|
1811
|
+
# from asset_feed_spec, and the duplication passes it through AS-IS.
|
|
1812
|
+
# Including those fields causes Meta to silently ignore
|
|
1813
|
+
# asset_feed_spec for multi-image creatives.
|
|
1814
|
+
#
|
|
1815
|
+
# Non-DOF (regular Dynamic Creative):
|
|
1816
|
+
# asset_feed_spec includes link_urls, ad_formats, call_to_action_types
|
|
1817
|
+
# as before (this path is verified working).
|
|
1818
|
+
# ------------------------------------------------------------------
|
|
1819
|
+
if optimization_type:
|
|
1820
|
+
asset_feed_spec = {"optimization_type": optimization_type}
|
|
1821
|
+
# Only include ad_formats if explicitly provided by the caller
|
|
1822
|
+
if ad_formats:
|
|
1823
|
+
asset_feed_spec["ad_formats"] = ad_formats
|
|
1824
|
+
else:
|
|
1825
|
+
resolved_ad_formats = ad_formats or (["SINGLE_VIDEO"] if is_video else ["SINGLE_IMAGE"])
|
|
1826
|
+
asset_feed_spec = {
|
|
1827
|
+
"link_urls": [{"website_url": link_url}],
|
|
1828
|
+
"ad_formats": resolved_ad_formats,
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
# Add media to asset_feed_spec (shared by both paths)
|
|
1832
|
+
if is_video:
|
|
1833
|
+
asset_feed_spec["videos"] = videos_array
|
|
1834
|
+
else:
|
|
1835
|
+
asset_feed_spec["images"] = images_array
|
|
1836
|
+
|
|
1837
|
+
# Handle headlines - Meta API uses "titles" not "headlines" in asset_feed_spec
|
|
1838
|
+
# Auto-promote singular headline to single-element array when in asset_feed_spec path
|
|
1839
|
+
if headlines:
|
|
1840
|
+
asset_feed_spec["titles"] = [{"text": headline_text} for headline_text in headlines]
|
|
1841
|
+
elif headline:
|
|
1842
|
+
asset_feed_spec["titles"] = [{"text": headline}]
|
|
1843
|
+
|
|
1844
|
+
# Handle descriptions
|
|
1845
|
+
# Auto-promote singular description to single-element array when in asset_feed_spec path
|
|
1846
|
+
if descriptions:
|
|
1847
|
+
asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in descriptions]
|
|
1848
|
+
elif description:
|
|
1849
|
+
asset_feed_spec["descriptions"] = [{"text": description}]
|
|
1850
|
+
|
|
1851
|
+
# Handle bodies: messages (plural) or message (singular)
|
|
1852
|
+
if messages:
|
|
1853
|
+
asset_feed_spec["bodies"] = [{"text": m} for m in messages]
|
|
1854
|
+
elif message:
|
|
1855
|
+
asset_feed_spec["bodies"] = [{"text": message}]
|
|
1856
|
+
|
|
1857
|
+
# CTA in asset_feed_spec only for non-DOF (DOF puts CTA in link_data)
|
|
1858
|
+
if call_to_action_type and not optimization_type:
|
|
1859
|
+
asset_feed_spec["call_to_action_types"] = [call_to_action_type]
|
|
1860
|
+
|
|
1861
|
+
# Add placement-specific asset customization rules if provided
|
|
1862
|
+
if asset_customization_rules:
|
|
1863
|
+
asset_feed_spec["asset_customization_rules"] = asset_customization_rules
|
|
1864
|
+
|
|
1865
|
+
creative_data["asset_feed_spec"] = asset_feed_spec
|
|
1866
|
+
|
|
1867
|
+
# ------------------------------------------------------------------
|
|
1868
|
+
# Build object_story_spec for asset_feed_spec creatives.
|
|
1869
|
+
# Meta rejects bare page_id (error 2061015) -- needs a link anchor.
|
|
1870
|
+
# ------------------------------------------------------------------
|
|
1871
|
+
if is_video:
|
|
1872
|
+
# Video FLEX: use video_data with call_to_action carrying
|
|
1873
|
+
# the link URL. This is required for Meta to associate the
|
|
1874
|
+
# video and destination URL with the creative.
|
|
1875
|
+
video_anchor = {"video_id": video_id}
|
|
1876
|
+
if thumbnail_url:
|
|
1877
|
+
video_anchor["image_url"] = thumbnail_url
|
|
1878
|
+
cta_type = call_to_action_type or "LEARN_MORE"
|
|
1879
|
+
cta_value = {}
|
|
1880
|
+
if link_url:
|
|
1881
|
+
cta_value["link"] = link_url
|
|
1882
|
+
if lead_gen_form_id:
|
|
1883
|
+
cta_value["lead_gen_form_id"] = lead_gen_form_id
|
|
1884
|
+
if phone_number:
|
|
1885
|
+
cta_value["phone_number"] = phone_number
|
|
1886
|
+
cta_data = {"type": cta_type}
|
|
1887
|
+
if cta_value:
|
|
1888
|
+
cta_data["value"] = cta_value
|
|
1889
|
+
video_anchor["call_to_action"] = cta_data
|
|
1890
|
+
creative_data["object_story_spec"] = {
|
|
1891
|
+
"page_id": page_id,
|
|
1892
|
+
"video_data": video_anchor
|
|
1893
|
+
}
|
|
1894
|
+
else:
|
|
1895
|
+
if not optimization_type:
|
|
1896
|
+
# Non-DOF asset_feed_spec: Meta requires bare
|
|
1897
|
+
# object_story_spec (no link_data). URLs, images, CTA
|
|
1898
|
+
# live exclusively in asset_feed_spec.
|
|
1899
|
+
# Ref: developers.facebook.com/docs/marketing-api/asset-customization-rules
|
|
1900
|
+
creative_data["object_story_spec"] = {
|
|
1901
|
+
"page_id": page_id,
|
|
1902
|
+
}
|
|
1903
|
+
else:
|
|
1904
|
+
# DOF: link_data serves as the "anchor" creative template.
|
|
1905
|
+
link_data = {"link": link_url}
|
|
1906
|
+
if image_hashes:
|
|
1907
|
+
link_data["image_hash"] = image_hashes[0]
|
|
1908
|
+
elif image_hash:
|
|
1909
|
+
link_data["image_hash"] = image_hash
|
|
1910
|
+
if caption:
|
|
1911
|
+
link_data["caption"] = caption
|
|
1912
|
+
if image_crops:
|
|
1913
|
+
link_data["image_crops"] = image_crops
|
|
1914
|
+
if call_to_action_type:
|
|
1915
|
+
cta = {"type": call_to_action_type}
|
|
1916
|
+
cta_value = {}
|
|
1917
|
+
if link_url:
|
|
1918
|
+
cta_value["link"] = link_url
|
|
1919
|
+
if lead_gen_form_id:
|
|
1920
|
+
cta_value["lead_gen_form_id"] = lead_gen_form_id
|
|
1921
|
+
if phone_number:
|
|
1922
|
+
cta_value["phone_number"] = phone_number
|
|
1923
|
+
if cta_value:
|
|
1924
|
+
cta["value"] = cta_value
|
|
1925
|
+
link_data["call_to_action"] = cta
|
|
1926
|
+
creative_data["object_story_spec"] = {
|
|
1927
|
+
"page_id": page_id,
|
|
1928
|
+
"link_data": link_data,
|
|
1929
|
+
}
|
|
1930
|
+
else:
|
|
1931
|
+
if is_video:
|
|
1932
|
+
# Use object_story_spec with video_data for simple video creatives.
|
|
1933
|
+
# NOTE: video_data does NOT support a "link" field directly.
|
|
1934
|
+
# The destination URL goes in call_to_action.value.link.
|
|
1935
|
+
# Thumbnail auto-fetch is handled earlier (before use_asset_feed branch).
|
|
1936
|
+
video_data = {
|
|
1937
|
+
"video_id": video_id,
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if thumbnail_url:
|
|
1941
|
+
video_data["image_url"] = thumbnail_url
|
|
1942
|
+
|
|
1943
|
+
if message:
|
|
1944
|
+
video_data["message"] = message
|
|
1945
|
+
|
|
1946
|
+
if headline:
|
|
1947
|
+
video_data["title"] = headline
|
|
1948
|
+
|
|
1949
|
+
# NOTE: Meta API v24 rejects "description" in video_data AND
|
|
1950
|
+
# "link_description" in call_to_action.value (deprecated).
|
|
1951
|
+
# Description is not settable for simple video creatives.
|
|
1952
|
+
|
|
1953
|
+
# Build call_to_action with the destination URL.
|
|
1954
|
+
# For video creatives, link_url MUST go in call_to_action.value.link
|
|
1955
|
+
# (not as a top-level field in video_data).
|
|
1956
|
+
cta_value = {}
|
|
1957
|
+
if link_url:
|
|
1958
|
+
cta_value["link"] = link_url
|
|
1959
|
+
if lead_gen_form_id:
|
|
1960
|
+
cta_value["lead_gen_form_id"] = lead_gen_form_id
|
|
1961
|
+
if phone_number:
|
|
1962
|
+
cta_value["phone_number"] = phone_number
|
|
1963
|
+
cta_type = call_to_action_type or ("LEARN_MORE" if link_url else None)
|
|
1964
|
+
if cta_type:
|
|
1965
|
+
cta_data = {"type": cta_type}
|
|
1966
|
+
if cta_value:
|
|
1967
|
+
cta_data["value"] = cta_value
|
|
1968
|
+
video_data["call_to_action"] = cta_data
|
|
1969
|
+
|
|
1970
|
+
creative_data["object_story_spec"] = {
|
|
1971
|
+
"page_id": page_id,
|
|
1972
|
+
"video_data": video_data
|
|
1973
|
+
}
|
|
1974
|
+
else:
|
|
1975
|
+
# Use traditional object_story_spec with link_data for simple image creatives
|
|
1976
|
+
creative_data["object_story_spec"] = {
|
|
1977
|
+
"page_id": page_id,
|
|
1978
|
+
"link_data": {
|
|
1979
|
+
"image_hash": image_hash,
|
|
1980
|
+
"link": link_url
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
# Add optional parameters if provided
|
|
1985
|
+
if message:
|
|
1986
|
+
creative_data["object_story_spec"]["link_data"]["message"] = message
|
|
1987
|
+
|
|
1988
|
+
# Add headline (singular) to link_data
|
|
1989
|
+
if headline:
|
|
1990
|
+
creative_data["object_story_spec"]["link_data"]["name"] = headline
|
|
1991
|
+
|
|
1992
|
+
# Add description (singular) to link_data
|
|
1993
|
+
if description:
|
|
1994
|
+
creative_data["object_story_spec"]["link_data"]["description"] = description
|
|
1995
|
+
|
|
1996
|
+
# Add caption (display URL) to link_data
|
|
1997
|
+
if caption:
|
|
1998
|
+
creative_data["object_story_spec"]["link_data"]["caption"] = caption
|
|
1999
|
+
|
|
2000
|
+
# Add image crops to link_data for placement-specific cropping
|
|
2001
|
+
if image_crops:
|
|
2002
|
+
creative_data["object_story_spec"]["link_data"]["image_crops"] = image_crops
|
|
2003
|
+
|
|
2004
|
+
# Add call_to_action to link_data for simple creatives
|
|
2005
|
+
if call_to_action_type:
|
|
2006
|
+
cta_data = {"type": call_to_action_type}
|
|
2007
|
+
cta_value = {}
|
|
2008
|
+
|
|
2009
|
+
# Add lead form ID to value object if provided (required for lead generation campaigns)
|
|
2010
|
+
if lead_gen_form_id:
|
|
2011
|
+
cta_value["lead_gen_form_id"] = lead_gen_form_id
|
|
2012
|
+
if phone_number:
|
|
2013
|
+
cta_value["phone_number"] = phone_number
|
|
2014
|
+
if cta_value:
|
|
2015
|
+
cta_data["value"] = cta_value
|
|
2016
|
+
|
|
2017
|
+
creative_data["object_story_spec"]["link_data"]["call_to_action"] = cta_data
|
|
2018
|
+
|
|
2019
|
+
# Add dynamic creative spec if provided
|
|
2020
|
+
if dynamic_creative_spec:
|
|
2021
|
+
creative_data["dynamic_creative_spec"] = dynamic_creative_spec
|
|
2022
|
+
|
|
2023
|
+
# Add Advantage+ Creative feature opt-ins if provided.
|
|
2024
|
+
# Only sent when the user explicitly passes creative_features_spec.
|
|
2025
|
+
if creative_features_spec:
|
|
2026
|
+
creative_data["degrees_of_freedom_spec"] = {
|
|
2027
|
+
"creative_features_spec": creative_features_spec
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
# Opt out of all Advantage+ Creative enhancements when requested.
|
|
2031
|
+
# Sets every known individual creative_features_spec key to OPT_OUT and
|
|
2032
|
+
# disables contextual_multi_ads. The legacy "standard_enhancements" key
|
|
2033
|
+
# is deprecated for POST operations (Meta error subcode 3858504), so we
|
|
2034
|
+
# enumerate each key explicitly -- matching the TS expandDisableAllEnhancements().
|
|
2035
|
+
if disable_all_enhancements:
|
|
2036
|
+
dof = creative_data.setdefault("degrees_of_freedom_spec", {})
|
|
2037
|
+
cfs = dof.setdefault("creative_features_spec", {})
|
|
2038
|
+
for key in _ALL_ENHANCEMENT_KEYS:
|
|
2039
|
+
if key not in cfs:
|
|
2040
|
+
cfs[key] = {"enroll_status": "OPT_OUT"}
|
|
2041
|
+
if "contextual_multi_ads" not in creative_data:
|
|
2042
|
+
creative_data["contextual_multi_ads"] = {"enroll_status": "OPT_OUT"}
|
|
2043
|
+
|
|
2044
|
+
# Add URL tracking parameters if provided.
|
|
2045
|
+
if url_tags:
|
|
2046
|
+
creative_data["url_tags"] = url_tags
|
|
2047
|
+
|
|
2048
|
+
# instagram_actor_id -> instagram_user_id migration (Jan 2026).
|
|
2049
|
+
# Meta deprecated instagram_actor_id; the replacement is instagram_user_id
|
|
2050
|
+
# inside object_story_spec (sibling of page_id and video_data/link_data).
|
|
2051
|
+
if instagram_actor_id and "object_story_spec" in creative_data:
|
|
2052
|
+
creative_data["object_story_spec"]["instagram_user_id"] = instagram_actor_id
|
|
2053
|
+
|
|
2054
|
+
# Make API request to create the creative
|
|
2055
|
+
data = await make_api_request(endpoint, access_token, creative_data, method="POST")
|
|
2056
|
+
|
|
2057
|
+
# Check for instagram_actor_id / instagram_user_id permission errors.
|
|
2058
|
+
# This happens when the user's Meta access token lacks the instagram_basic
|
|
2059
|
+
# permission. Re-connecting the Facebook account refreshes the token.
|
|
2060
|
+
if instagram_actor_id and "error" in data:
|
|
2061
|
+
err_details = data.get("error", {}).get("details", {})
|
|
2062
|
+
inner_msg = ""
|
|
2063
|
+
if isinstance(err_details, dict):
|
|
2064
|
+
inner_err = err_details.get("error", {})
|
|
2065
|
+
if isinstance(inner_err, dict):
|
|
2066
|
+
inner_msg = inner_err.get("message", "")
|
|
2067
|
+
if "valid Instagram account id" in inner_msg or "instagram_actor_id" in inner_msg.lower():
|
|
2068
|
+
return json.dumps({
|
|
2069
|
+
"error": "Instagram account not authorized for advertising",
|
|
2070
|
+
"explanation": (
|
|
2071
|
+
"The Meta API rejected the Instagram account ID. This usually means "
|
|
2072
|
+
"your Facebook access token is missing the 'instagram_basic' permission, "
|
|
2073
|
+
"which is required to use Instagram placements in ad creatives."
|
|
2074
|
+
),
|
|
2075
|
+
"fix": (
|
|
2076
|
+
"Reconnect your Facebook account at https://pipeboard.co/connections "
|
|
2077
|
+
"to refresh your access token with the required permissions."
|
|
2078
|
+
),
|
|
2079
|
+
"instagram_actor_id": instagram_actor_id,
|
|
2080
|
+
"meta_error": inner_msg
|
|
2081
|
+
}, indent=2)
|
|
2082
|
+
|
|
2083
|
+
# If successful, get more details about the created creative
|
|
2084
|
+
if "id" in data:
|
|
2085
|
+
creative_id = data["id"]
|
|
2086
|
+
creative_endpoint = f"{creative_id}"
|
|
2087
|
+
creative_params = {
|
|
2088
|
+
"fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,object_type,body,title,effective_object_story_id,asset_feed_spec{images,videos,bodies,titles,descriptions,link_urls,ad_formats,call_to_action_types,optimization_type,asset_customization_rules},url_tags,link_url"
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
|
|
2092
|
+
result = {
|
|
2093
|
+
"success": True,
|
|
2094
|
+
"creative_id": creative_id,
|
|
2095
|
+
"details": creative_details,
|
|
2096
|
+
}
|
|
2097
|
+
if dof_downgraded:
|
|
2098
|
+
result["warning"] = (
|
|
2099
|
+
"optimization_type DEGREES_OF_FREEDOM was automatically removed because "
|
|
2100
|
+
"asset_customization_rules was provided. Meta silently ignores placement "
|
|
2101
|
+
"rules for DOF creatives. The creative was created using regular dynamic "
|
|
2102
|
+
"creative mode so placement-specific images are respected. To use DOF "
|
|
2103
|
+
"instead, remove asset_customization_rules."
|
|
2104
|
+
)
|
|
2105
|
+
return json.dumps(result, indent=2)
|
|
2106
|
+
|
|
2107
|
+
return json.dumps(data, indent=2)
|
|
2108
|
+
|
|
2109
|
+
except Exception as e:
|
|
2110
|
+
logger.exception("create_ad_creative failed")
|
|
2111
|
+
return json.dumps({
|
|
2112
|
+
"error": "Failed to create ad creative",
|
|
2113
|
+
"details": str(e)
|
|
2114
|
+
}, indent=2)
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
@meta_api_tool
|
|
2118
|
+
async def update_ad_creative(
|
|
2119
|
+
creative_id: str,
|
|
2120
|
+
access_token: Optional[str] = None,
|
|
2121
|
+
name: Optional[str] = None,
|
|
2122
|
+
message: Optional[str] = None,
|
|
2123
|
+
messages: Optional[List[str]] = None,
|
|
2124
|
+
headline: Optional[str] = None,
|
|
2125
|
+
headlines: Optional[List[str]] = None,
|
|
2126
|
+
description: Optional[str] = None,
|
|
2127
|
+
descriptions: Optional[List[str]] = None,
|
|
2128
|
+
optimization_type: Optional[str] = None,
|
|
2129
|
+
dynamic_creative_spec: Optional[Dict[str, Any]] = None,
|
|
2130
|
+
call_to_action_type: Optional[str] = None,
|
|
2131
|
+
lead_gen_form_id: Optional[Union[str, int]] = None,
|
|
2132
|
+
ad_formats: Optional[List[str]] = None,
|
|
2133
|
+
creative_features_spec: Optional[Dict[str, Any]] = None
|
|
2134
|
+
) -> str:
|
|
2135
|
+
"""
|
|
2136
|
+
Update an existing ad creative's name or optimization settings.
|
|
2137
|
+
|
|
2138
|
+
IMPORTANT -- Meta API limitation: The Meta API does NOT allow updating content
|
|
2139
|
+
fields (message, headline, description, CTA, image, video, URL) on existing
|
|
2140
|
+
creatives. Only the creative `name` and optimization settings (asset_feed_spec)
|
|
2141
|
+
can be changed. To change ad content, create a new creative with the desired
|
|
2142
|
+
content and update the ad to reference the new creative via `update_ad`.
|
|
2143
|
+
|
|
2144
|
+
Args:
|
|
2145
|
+
creative_id: Meta Ads creative ID to update
|
|
2146
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
2147
|
+
name: New creative name (this is the most reliable update)
|
|
2148
|
+
message: New ad copy/text -- NOTE: Meta API may reject this on existing creatives
|
|
2149
|
+
messages: List of primary text variants -- NOTE: Meta API may reject this on existing creatives
|
|
2150
|
+
headline: Single headline -- NOTE: Meta API may reject this on existing creatives
|
|
2151
|
+
headlines: New list of headlines -- NOTE: Meta API may reject this on existing creatives
|
|
2152
|
+
description: Single description -- NOTE: Meta API may reject this on existing creatives
|
|
2153
|
+
descriptions: New list of descriptions -- NOTE: Meta API may reject this on existing creatives
|
|
2154
|
+
optimization_type: Set to "DEGREES_OF_FREEDOM" for FLEX (Advantage+) creatives
|
|
2155
|
+
dynamic_creative_spec: New dynamic creative optimization settings
|
|
2156
|
+
call_to_action_type: New call to action button type -- NOTE: Meta API may reject this on existing creatives
|
|
2157
|
+
lead_gen_form_id: Lead generation form ID for lead generation campaigns
|
|
2158
|
+
ad_formats: List of ad format strings for asset_feed_spec (e.g., ["AUTOMATIC_FORMAT"] for
|
|
2159
|
+
Flexible ads, ["SINGLE_IMAGE"] for single image)
|
|
2160
|
+
creative_features_spec: Dict of Advantage+ Creative feature opt-ins/opt-outs.
|
|
2161
|
+
Each key is a feature name, value is {"enroll_status": "OPT_IN"|"OPT_OUT"}.
|
|
2162
|
+
Sent as a top-level field (not inside degrees_of_freedom_spec).
|
|
2163
|
+
|
|
2164
|
+
Returns:
|
|
2165
|
+
JSON response with updated creative details
|
|
2166
|
+
"""
|
|
2167
|
+
# Coerce numeric IDs to strings (LLM clients may send integers for numeric-only IDs)
|
|
2168
|
+
if lead_gen_form_id is not None:
|
|
2169
|
+
lead_gen_form_id = str(lead_gen_form_id)
|
|
2170
|
+
# Check required parameters
|
|
2171
|
+
if not creative_id:
|
|
2172
|
+
return json.dumps({"error": "No creative ID provided"}, indent=2)
|
|
2173
|
+
|
|
2174
|
+
# Validate headline/description parameters - cannot mix simple and complex
|
|
2175
|
+
if headline and headlines:
|
|
2176
|
+
return json.dumps({"error": "Cannot specify both 'headline' and 'headlines'. Use 'headline' for single headline or 'headlines' for multiple."}, indent=2)
|
|
2177
|
+
|
|
2178
|
+
if description and descriptions:
|
|
2179
|
+
return json.dumps({"error": "Cannot specify both 'description' and 'descriptions'. Use 'description' for single description or 'descriptions' for multiple."}, indent=2)
|
|
2180
|
+
|
|
2181
|
+
# Validate message / messages mutual exclusivity
|
|
2182
|
+
if message and messages:
|
|
2183
|
+
return json.dumps({"error": "Cannot specify both 'message' and 'messages'. Use 'message' for single text or 'messages' for multiple variants."}, indent=2)
|
|
2184
|
+
|
|
2185
|
+
# Validate optimization_type
|
|
2186
|
+
if optimization_type and optimization_type != "DEGREES_OF_FREEDOM":
|
|
2187
|
+
return json.dumps({"error": f"Invalid optimization_type '{optimization_type}'. Only 'DEGREES_OF_FREEDOM' is supported."}, indent=2)
|
|
2188
|
+
|
|
2189
|
+
# Validate dynamic creative parameters (plural forms only)
|
|
2190
|
+
if headlines:
|
|
2191
|
+
if len(headlines) > 5:
|
|
2192
|
+
return json.dumps({"error": "Maximum 5 headlines allowed for dynamic creatives"}, indent=2)
|
|
2193
|
+
for i, h in enumerate(headlines):
|
|
2194
|
+
if len(h) > 40:
|
|
2195
|
+
return json.dumps({"error": f"Headline {i+1} exceeds 40 character limit"}, indent=2)
|
|
2196
|
+
|
|
2197
|
+
if descriptions:
|
|
2198
|
+
if len(descriptions) > 5:
|
|
2199
|
+
return json.dumps({"error": "Maximum 5 descriptions allowed for dynamic creatives"}, indent=2)
|
|
2200
|
+
for i, d in enumerate(descriptions):
|
|
2201
|
+
if len(d) > 125:
|
|
2202
|
+
return json.dumps({"error": f"Description {i+1} exceeds 125 character limit"}, indent=2)
|
|
2203
|
+
|
|
2204
|
+
# Prepare the update data
|
|
2205
|
+
update_data = {}
|
|
2206
|
+
|
|
2207
|
+
if name:
|
|
2208
|
+
update_data["name"] = name
|
|
2209
|
+
|
|
2210
|
+
# Choose between asset_feed_spec (dynamic/FLEX creative) or object_story_spec (traditional)
|
|
2211
|
+
use_asset_feed = bool(headlines or descriptions or messages or optimization_type or dynamic_creative_spec)
|
|
2212
|
+
|
|
2213
|
+
if use_asset_feed:
|
|
2214
|
+
# Handle dynamic/FLEX creative assets via asset_feed_spec
|
|
2215
|
+
asset_feed_spec = {}
|
|
2216
|
+
|
|
2217
|
+
# Determine ad_formats: use explicit value if provided, otherwise smart default.
|
|
2218
|
+
# NOTE: AUTOMATIC_FORMAT is NOT valid for creation/update -- Meta silently
|
|
2219
|
+
# ignores the entire asset_feed_spec when it encounters it.
|
|
2220
|
+
# Always use SINGLE_IMAGE; Meta handles format selection automatically
|
|
2221
|
+
# via optimization_type=DEGREES_OF_FREEDOM.
|
|
2222
|
+
if ad_formats:
|
|
2223
|
+
asset_feed_spec["ad_formats"] = ad_formats
|
|
2224
|
+
else:
|
|
2225
|
+
asset_feed_spec["ad_formats"] = ["SINGLE_IMAGE"]
|
|
2226
|
+
|
|
2227
|
+
# Add optimization_type for FLEX (Advantage+) creatives
|
|
2228
|
+
if optimization_type:
|
|
2229
|
+
asset_feed_spec["optimization_type"] = optimization_type
|
|
2230
|
+
|
|
2231
|
+
# Handle headlines - Meta API uses "titles" not "headlines" in asset_feed_spec
|
|
2232
|
+
# Auto-promote singular headline to single-element array when in asset_feed_spec path
|
|
2233
|
+
if headlines:
|
|
2234
|
+
asset_feed_spec["titles"] = [{"text": headline_text} for headline_text in headlines]
|
|
2235
|
+
elif headline:
|
|
2236
|
+
asset_feed_spec["titles"] = [{"text": headline}]
|
|
2237
|
+
|
|
2238
|
+
# Handle descriptions
|
|
2239
|
+
# Auto-promote singular description to single-element array when in asset_feed_spec path
|
|
2240
|
+
if descriptions:
|
|
2241
|
+
asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in descriptions]
|
|
2242
|
+
elif description:
|
|
2243
|
+
asset_feed_spec["descriptions"] = [{"text": description}]
|
|
2244
|
+
|
|
2245
|
+
# Handle bodies: messages (plural) or message (singular)
|
|
2246
|
+
if messages:
|
|
2247
|
+
asset_feed_spec["bodies"] = [{"text": m} for m in messages]
|
|
2248
|
+
elif message:
|
|
2249
|
+
asset_feed_spec["bodies"] = [{"text": message}]
|
|
2250
|
+
|
|
2251
|
+
# Add call_to_action_types if provided
|
|
2252
|
+
if call_to_action_type:
|
|
2253
|
+
asset_feed_spec["call_to_action_types"] = [call_to_action_type]
|
|
2254
|
+
|
|
2255
|
+
update_data["asset_feed_spec"] = asset_feed_spec
|
|
2256
|
+
else:
|
|
2257
|
+
# Use traditional object_story_spec with link_data for simple creatives
|
|
2258
|
+
if message or headline or description or call_to_action_type or lead_gen_form_id:
|
|
2259
|
+
update_data["object_story_spec"] = {"link_data": {}}
|
|
2260
|
+
|
|
2261
|
+
if message:
|
|
2262
|
+
update_data["object_story_spec"]["link_data"]["message"] = message
|
|
2263
|
+
|
|
2264
|
+
# Add headline (singular) to link_data
|
|
2265
|
+
if headline:
|
|
2266
|
+
update_data["object_story_spec"]["link_data"]["name"] = headline
|
|
2267
|
+
|
|
2268
|
+
# Add description (singular) to link_data
|
|
2269
|
+
if description:
|
|
2270
|
+
update_data["object_story_spec"]["link_data"]["description"] = description
|
|
2271
|
+
|
|
2272
|
+
# Add call_to_action to link_data for simple creatives
|
|
2273
|
+
if call_to_action_type or lead_gen_form_id:
|
|
2274
|
+
cta_data = {}
|
|
2275
|
+
if call_to_action_type:
|
|
2276
|
+
cta_data["type"] = call_to_action_type
|
|
2277
|
+
|
|
2278
|
+
# Add lead form ID to value object if provided (required for lead generation campaigns)
|
|
2279
|
+
if lead_gen_form_id:
|
|
2280
|
+
cta_data["value"] = {"lead_gen_form_id": lead_gen_form_id}
|
|
2281
|
+
|
|
2282
|
+
if cta_data:
|
|
2283
|
+
update_data["object_story_spec"]["link_data"]["call_to_action"] = cta_data
|
|
2284
|
+
|
|
2285
|
+
# Add dynamic creative spec if provided
|
|
2286
|
+
if dynamic_creative_spec:
|
|
2287
|
+
update_data["dynamic_creative_spec"] = dynamic_creative_spec
|
|
2288
|
+
|
|
2289
|
+
# Add Advantage+ Creative feature opt-ins/opt-outs if provided.
|
|
2290
|
+
# Meta API docs: PUT /{ad_creative_id} accepts creative_features_spec
|
|
2291
|
+
# as a top-level field (NOT inside degrees_of_freedom_spec, which is immutable).
|
|
2292
|
+
if creative_features_spec:
|
|
2293
|
+
update_data["creative_features_spec"] = creative_features_spec
|
|
2294
|
+
|
|
2295
|
+
# Prepare the API endpoint for updating the creative
|
|
2296
|
+
endpoint = f"{creative_id}"
|
|
2297
|
+
|
|
2298
|
+
try:
|
|
2299
|
+
# Meta Graph API uses POST for all mutations (PUT returns "Object Not Found").
|
|
2300
|
+
# creative_features_spec is sent as a top-level POST field, NOT inside
|
|
2301
|
+
# degrees_of_freedom_spec (which is immutable after creation).
|
|
2302
|
+
data = await make_api_request(endpoint, access_token, update_data, method="POST")
|
|
2303
|
+
|
|
2304
|
+
# If successful, get more details about the updated creative
|
|
2305
|
+
if "id" in data:
|
|
2306
|
+
creative_endpoint = f"{creative_id}"
|
|
2307
|
+
creative_params = {
|
|
2308
|
+
"fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,url_tags,link_url,dynamic_creative_spec,degrees_of_freedom_spec"
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
|
|
2312
|
+
return json.dumps({
|
|
2313
|
+
"success": True,
|
|
2314
|
+
"creative_id": creative_id,
|
|
2315
|
+
"details": creative_details
|
|
2316
|
+
}, indent=2)
|
|
2317
|
+
|
|
2318
|
+
# Check for Meta API content update limitation (error_subcode 1815573)
|
|
2319
|
+
error_obj = data.get("error", {})
|
|
2320
|
+
if isinstance(error_obj, dict):
|
|
2321
|
+
error_details = error_obj.get("details", {})
|
|
2322
|
+
if isinstance(error_details, dict):
|
|
2323
|
+
inner_error = error_details.get("error", {})
|
|
2324
|
+
error_subcode = inner_error.get("error_subcode") if isinstance(inner_error, dict) else None
|
|
2325
|
+
else:
|
|
2326
|
+
error_subcode = error_obj.get("error_subcode")
|
|
2327
|
+
else:
|
|
2328
|
+
error_subcode = None
|
|
2329
|
+
|
|
2330
|
+
if error_subcode == 1815573:
|
|
2331
|
+
return json.dumps({
|
|
2332
|
+
"error": "Content updates are not allowed on existing creatives",
|
|
2333
|
+
"explanation": (
|
|
2334
|
+
"The Meta API does not allow updating content fields (message, headline, "
|
|
2335
|
+
"description, CTA, image, video, URL) on existing creatives. "
|
|
2336
|
+
"Only the creative 'name' can be changed."
|
|
2337
|
+
),
|
|
2338
|
+
"workaround": (
|
|
2339
|
+
"To change ad content: (1) create a new creative with the desired content "
|
|
2340
|
+
"using create_ad_creative, then (2) call update_ad with the ad's ID and the "
|
|
2341
|
+
"new creative_id to swap it on the ad."
|
|
2342
|
+
),
|
|
2343
|
+
"creative_id": creative_id,
|
|
2344
|
+
"attempted_updates": update_data
|
|
2345
|
+
}, indent=2)
|
|
2346
|
+
|
|
2347
|
+
return json.dumps(data, indent=2)
|
|
2348
|
+
|
|
2349
|
+
except Exception as e:
|
|
2350
|
+
return json.dumps({
|
|
2351
|
+
"error": "Failed to update ad creative",
|
|
2352
|
+
"details": str(e),
|
|
2353
|
+
"update_data_sent": update_data
|
|
2354
|
+
}, indent=2)
|
|
2355
|
+
|
|
2356
|
+
|
|
2357
|
+
async def _discover_pages_for_account(account_id: str, access_token: str) -> dict:
|
|
2358
|
+
"""
|
|
2359
|
+
Internal function to discover pages for an account using multiple approaches.
|
|
2360
|
+
Returns the best available page ID for ad creation.
|
|
2361
|
+
"""
|
|
2362
|
+
try:
|
|
2363
|
+
# Approach 1: Extract page IDs from tracking_specs in ads (most reliable)
|
|
2364
|
+
endpoint = f"{account_id}/ads"
|
|
2365
|
+
params = {
|
|
2366
|
+
"fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
|
|
2367
|
+
"limit": 100
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
tracking_ads_data = await make_api_request(endpoint, access_token, params)
|
|
2371
|
+
|
|
2372
|
+
tracking_page_ids = set()
|
|
2373
|
+
if "data" in tracking_ads_data:
|
|
2374
|
+
for ad in tracking_ads_data.get("data", []):
|
|
2375
|
+
tracking_specs = ad.get("tracking_specs", [])
|
|
2376
|
+
if isinstance(tracking_specs, list):
|
|
2377
|
+
for spec in tracking_specs:
|
|
2378
|
+
if isinstance(spec, dict) and "page" in spec:
|
|
2379
|
+
page_list = spec["page"]
|
|
2380
|
+
if isinstance(page_list, list):
|
|
2381
|
+
for page_id in page_list:
|
|
2382
|
+
if isinstance(page_id, (str, int)) and str(page_id).isdigit():
|
|
2383
|
+
tracking_page_ids.add(str(page_id))
|
|
2384
|
+
|
|
2385
|
+
if tracking_page_ids:
|
|
2386
|
+
# Get details for the first page found
|
|
2387
|
+
page_id = list(tracking_page_ids)[0]
|
|
2388
|
+
page_endpoint = f"{page_id}"
|
|
2389
|
+
page_params = {
|
|
2390
|
+
"fields": "id,name,username,category,fan_count,link,verification_status,picture"
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
page_data = await make_api_request(page_endpoint, access_token, page_params)
|
|
2394
|
+
if "id" in page_data:
|
|
2395
|
+
return {
|
|
2396
|
+
"success": True,
|
|
2397
|
+
"page_id": page_id,
|
|
2398
|
+
"page_name": page_data.get("name", "Unknown"),
|
|
2399
|
+
"source": "tracking_specs",
|
|
2400
|
+
"note": "Page ID extracted from existing ads - most reliable for ad creation"
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
# Approach 2: Try client_pages endpoint
|
|
2404
|
+
endpoint = f"{account_id}/client_pages"
|
|
2405
|
+
params = {
|
|
2406
|
+
"fields": "id,name,username,category,fan_count,link,verification_status,picture"
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
client_pages_data = await make_api_request(endpoint, access_token, params)
|
|
2410
|
+
|
|
2411
|
+
if "data" in client_pages_data and client_pages_data["data"]:
|
|
2412
|
+
page = client_pages_data["data"][0]
|
|
2413
|
+
return {
|
|
2414
|
+
"success": True,
|
|
2415
|
+
"page_id": str(page["id"]),
|
|
2416
|
+
"page_name": page.get("name", "Unknown"),
|
|
2417
|
+
"source": "client_pages"
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
# Approach 3: Try assigned_pages endpoint
|
|
2421
|
+
pages_endpoint = f"{account_id}/assigned_pages"
|
|
2422
|
+
pages_params = {
|
|
2423
|
+
"fields": "id,name",
|
|
2424
|
+
"limit": 1
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
pages_data = await make_api_request(pages_endpoint, access_token, pages_params)
|
|
2428
|
+
|
|
2429
|
+
if "data" in pages_data and pages_data["data"]:
|
|
2430
|
+
page = pages_data["data"][0]
|
|
2431
|
+
return {
|
|
2432
|
+
"success": True,
|
|
2433
|
+
"page_id": str(page["id"]),
|
|
2434
|
+
"page_name": page.get("name", "Unknown"),
|
|
2435
|
+
"source": "assigned_pages"
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
# If all approaches failed
|
|
2439
|
+
return {
|
|
2440
|
+
"success": False,
|
|
2441
|
+
"message": "No suitable pages found for this account",
|
|
2442
|
+
"note": "Try using get_account_pages to see all available pages or provide page_id manually"
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
except Exception as e:
|
|
2446
|
+
return {
|
|
2447
|
+
"success": False,
|
|
2448
|
+
"message": f"Error during page discovery: {str(e)}"
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
async def _search_pages_by_name_core(access_token: str, account_id: str, search_term: str = None) -> str:
|
|
2453
|
+
"""
|
|
2454
|
+
Core logic for searching pages by name.
|
|
2455
|
+
|
|
2456
|
+
Args:
|
|
2457
|
+
access_token: Meta API access token
|
|
2458
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
2459
|
+
search_term: Search term to find pages by name (optional - returns all pages if not provided)
|
|
2460
|
+
|
|
2461
|
+
Returns:
|
|
2462
|
+
JSON string with search results
|
|
2463
|
+
"""
|
|
2464
|
+
account_id = ensure_act_prefix(account_id)
|
|
2465
|
+
|
|
2466
|
+
try:
|
|
2467
|
+
# Use the internal discovery function directly
|
|
2468
|
+
page_discovery_result = await _discover_pages_for_account(account_id, access_token)
|
|
2469
|
+
|
|
2470
|
+
if not page_discovery_result.get("success"):
|
|
2471
|
+
return json.dumps({
|
|
2472
|
+
"data": [],
|
|
2473
|
+
"message": "No pages found for this account",
|
|
2474
|
+
"details": page_discovery_result.get("message", "Page discovery failed")
|
|
2475
|
+
}, indent=2)
|
|
2476
|
+
|
|
2477
|
+
# Create a single page result
|
|
2478
|
+
page_data = {
|
|
2479
|
+
"id": page_discovery_result["page_id"],
|
|
2480
|
+
"name": page_discovery_result.get("page_name", "Unknown"),
|
|
2481
|
+
"source": page_discovery_result.get("source", "unknown")
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
all_pages_data = {"data": [page_data]}
|
|
2485
|
+
|
|
2486
|
+
# Filter pages by search term if provided
|
|
2487
|
+
if search_term:
|
|
2488
|
+
search_term_lower = search_term.lower()
|
|
2489
|
+
filtered_pages = []
|
|
2490
|
+
|
|
2491
|
+
for page in all_pages_data["data"]:
|
|
2492
|
+
page_name = page.get("name", "").lower()
|
|
2493
|
+
if search_term_lower in page_name:
|
|
2494
|
+
filtered_pages.append(page)
|
|
2495
|
+
|
|
2496
|
+
return json.dumps({
|
|
2497
|
+
"data": filtered_pages,
|
|
2498
|
+
"search_term": search_term,
|
|
2499
|
+
"total_found": len(filtered_pages),
|
|
2500
|
+
"total_available": len(all_pages_data["data"])
|
|
2501
|
+
}, indent=2)
|
|
2502
|
+
else:
|
|
2503
|
+
# Return all pages if no search term provided
|
|
2504
|
+
return json.dumps({
|
|
2505
|
+
"data": all_pages_data["data"],
|
|
2506
|
+
"total_available": len(all_pages_data["data"]),
|
|
2507
|
+
"note": "Use search_term parameter to filter pages by name"
|
|
2508
|
+
}, indent=2)
|
|
2509
|
+
|
|
2510
|
+
except Exception as e:
|
|
2511
|
+
return json.dumps({
|
|
2512
|
+
"error": "Failed to search pages by name",
|
|
2513
|
+
"details": str(e)
|
|
2514
|
+
}, indent=2)
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
@mcp_server.tool()
|
|
2518
|
+
@meta_api_tool
|
|
2519
|
+
async def search_pages_by_name(account_id: str, access_token: Optional[str] = None, search_term: Optional[str] = None) -> str:
|
|
2520
|
+
"""
|
|
2521
|
+
Search for pages by name within an account.
|
|
2522
|
+
|
|
2523
|
+
Args:
|
|
2524
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
2525
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
2526
|
+
search_term: Search term to find pages by name (optional - returns all pages if not provided)
|
|
2527
|
+
|
|
2528
|
+
Returns:
|
|
2529
|
+
JSON response with matching pages
|
|
2530
|
+
"""
|
|
2531
|
+
# Check required parameters
|
|
2532
|
+
if not account_id:
|
|
2533
|
+
return json.dumps({"error": "No account ID provided"}, indent=2)
|
|
2534
|
+
|
|
2535
|
+
# Call the core function
|
|
2536
|
+
result = await _search_pages_by_name_core(access_token, account_id, search_term)
|
|
2537
|
+
return result
|
|
2538
|
+
|
|
2539
|
+
|
|
2540
|
+
@mcp_server.tool()
|
|
2541
|
+
@meta_api_tool
|
|
2542
|
+
async def get_account_pages(account_id: str, access_token: Optional[str] = None) -> str:
|
|
2543
|
+
"""
|
|
2544
|
+
Get pages associated with a Meta Ads account.
|
|
2545
|
+
|
|
2546
|
+
Args:
|
|
2547
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
2548
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
2549
|
+
|
|
2550
|
+
Returns:
|
|
2551
|
+
JSON response with pages associated with the account
|
|
2552
|
+
"""
|
|
2553
|
+
# Check required parameters
|
|
2554
|
+
if not account_id:
|
|
2555
|
+
return json.dumps({"error": "No account ID provided"}, indent=2)
|
|
2556
|
+
|
|
2557
|
+
# Handle special case for 'me'
|
|
2558
|
+
if account_id == "me":
|
|
2559
|
+
try:
|
|
2560
|
+
endpoint = "me/accounts"
|
|
2561
|
+
params = {
|
|
2562
|
+
"fields": "id,name,username,category,fan_count,link,verification_status,picture"
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
user_pages_data = await make_api_request(endpoint, access_token, params)
|
|
2566
|
+
return json.dumps(user_pages_data, indent=2)
|
|
2567
|
+
except Exception as e:
|
|
2568
|
+
return json.dumps({
|
|
2569
|
+
"error": "Failed to get user pages",
|
|
2570
|
+
"details": str(e)
|
|
2571
|
+
}, indent=2)
|
|
2572
|
+
|
|
2573
|
+
account_id = ensure_act_prefix(account_id)
|
|
2574
|
+
|
|
2575
|
+
try:
|
|
2576
|
+
# Collect all page IDs from multiple approaches
|
|
2577
|
+
all_page_ids = set()
|
|
2578
|
+
|
|
2579
|
+
# Approach 1: Get user's personal pages (broad scope)
|
|
2580
|
+
try:
|
|
2581
|
+
endpoint = "me/accounts"
|
|
2582
|
+
params = {
|
|
2583
|
+
"fields": "id,name,username,category,fan_count,link,verification_status,picture"
|
|
2584
|
+
}
|
|
2585
|
+
user_pages_data = await make_api_request(endpoint, access_token, params)
|
|
2586
|
+
if "data" in user_pages_data:
|
|
2587
|
+
for page in user_pages_data["data"]:
|
|
2588
|
+
if "id" in page:
|
|
2589
|
+
all_page_ids.add(page["id"])
|
|
2590
|
+
except Exception:
|
|
2591
|
+
pass
|
|
2592
|
+
|
|
2593
|
+
# Approach 2: Try business manager pages
|
|
2594
|
+
try:
|
|
2595
|
+
# Strip 'act_' prefix to get raw account ID for business endpoints
|
|
2596
|
+
raw_account_id = account_id.replace("act_", "")
|
|
2597
|
+
endpoint = f"{raw_account_id}/owned_pages"
|
|
2598
|
+
params = {
|
|
2599
|
+
"fields": "id,name,username,category,fan_count,link,verification_status,picture"
|
|
2600
|
+
}
|
|
2601
|
+
business_pages_data = await make_api_request(endpoint, access_token, params)
|
|
2602
|
+
if "data" in business_pages_data:
|
|
2603
|
+
for page in business_pages_data["data"]:
|
|
2604
|
+
if "id" in page:
|
|
2605
|
+
all_page_ids.add(page["id"])
|
|
2606
|
+
except Exception:
|
|
2607
|
+
pass
|
|
2608
|
+
|
|
2609
|
+
# Approach 3: Try ad account client pages
|
|
2610
|
+
try:
|
|
2611
|
+
endpoint = f"{account_id}/client_pages"
|
|
2612
|
+
params = {
|
|
2613
|
+
"fields": "id,name,username,category,fan_count,link,verification_status,picture"
|
|
2614
|
+
}
|
|
2615
|
+
client_pages_data = await make_api_request(endpoint, access_token, params)
|
|
2616
|
+
if "data" in client_pages_data:
|
|
2617
|
+
for page in client_pages_data["data"]:
|
|
2618
|
+
if "id" in page:
|
|
2619
|
+
all_page_ids.add(page["id"])
|
|
2620
|
+
except Exception:
|
|
2621
|
+
pass
|
|
2622
|
+
|
|
2623
|
+
# Approach 4: Extract page IDs from all ad creatives (broader creative search)
|
|
2624
|
+
try:
|
|
2625
|
+
endpoint = f"{account_id}/adcreatives"
|
|
2626
|
+
params = {
|
|
2627
|
+
"fields": "id,name,object_story_spec,link_url,call_to_action,image_hash",
|
|
2628
|
+
"limit": 100
|
|
2629
|
+
}
|
|
2630
|
+
creatives_data = await make_api_request(endpoint, access_token, params)
|
|
2631
|
+
if "data" in creatives_data:
|
|
2632
|
+
for creative in creatives_data["data"]:
|
|
2633
|
+
if "object_story_spec" in creative and "page_id" in creative["object_story_spec"]:
|
|
2634
|
+
all_page_ids.add(creative["object_story_spec"]["page_id"])
|
|
2635
|
+
except Exception:
|
|
2636
|
+
pass
|
|
2637
|
+
|
|
2638
|
+
# Approach 5: Get active ads and extract page IDs from creatives
|
|
2639
|
+
try:
|
|
2640
|
+
endpoint = f"{account_id}/ads"
|
|
2641
|
+
params = {
|
|
2642
|
+
"fields": "creative{object_story_spec{page_id},link_url,call_to_action}",
|
|
2643
|
+
"limit": 100
|
|
2644
|
+
}
|
|
2645
|
+
ads_data = await make_api_request(endpoint, access_token, params)
|
|
2646
|
+
if "data" in ads_data:
|
|
2647
|
+
for ad in ads_data.get("data", []):
|
|
2648
|
+
if "creative" in ad and "object_story_spec" in ad["creative"] and "page_id" in ad["creative"]["object_story_spec"]:
|
|
2649
|
+
all_page_ids.add(ad["creative"]["object_story_spec"]["page_id"])
|
|
2650
|
+
except Exception:
|
|
2651
|
+
pass
|
|
2652
|
+
|
|
2653
|
+
# Approach 6: Try promoted_objects endpoint
|
|
2654
|
+
try:
|
|
2655
|
+
endpoint = f"{account_id}/promoted_objects"
|
|
2656
|
+
params = {
|
|
2657
|
+
"fields": "page_id,object_store_url,product_set_id,application_id"
|
|
2658
|
+
}
|
|
2659
|
+
promoted_objects_data = await make_api_request(endpoint, access_token, params)
|
|
2660
|
+
if "data" in promoted_objects_data:
|
|
2661
|
+
for obj in promoted_objects_data["data"]:
|
|
2662
|
+
if "page_id" in obj:
|
|
2663
|
+
all_page_ids.add(obj["page_id"])
|
|
2664
|
+
except Exception:
|
|
2665
|
+
pass
|
|
2666
|
+
|
|
2667
|
+
# Approach 7: Extract page IDs from tracking_specs in ads (most reliable)
|
|
2668
|
+
try:
|
|
2669
|
+
endpoint = f"{account_id}/ads"
|
|
2670
|
+
params = {
|
|
2671
|
+
"fields": "id,name,status,creative,tracking_specs",
|
|
2672
|
+
"limit": 100
|
|
2673
|
+
}
|
|
2674
|
+
tracking_ads_data = await make_api_request(endpoint, access_token, params)
|
|
2675
|
+
if "data" in tracking_ads_data:
|
|
2676
|
+
for ad in tracking_ads_data.get("data", []):
|
|
2677
|
+
tracking_specs = ad.get("tracking_specs", [])
|
|
2678
|
+
if isinstance(tracking_specs, list):
|
|
2679
|
+
for spec in tracking_specs:
|
|
2680
|
+
if isinstance(spec, dict) and "page" in spec:
|
|
2681
|
+
page_list = spec["page"]
|
|
2682
|
+
if isinstance(page_list, list):
|
|
2683
|
+
for page_id in page_list:
|
|
2684
|
+
if isinstance(page_id, (str, int)) and str(page_id).isdigit():
|
|
2685
|
+
all_page_ids.add(str(page_id))
|
|
2686
|
+
except Exception:
|
|
2687
|
+
pass
|
|
2688
|
+
|
|
2689
|
+
# Approach 8: Try campaigns and extract page info
|
|
2690
|
+
try:
|
|
2691
|
+
endpoint = f"{account_id}/campaigns"
|
|
2692
|
+
params = {
|
|
2693
|
+
"fields": "id,name,promoted_object,objective",
|
|
2694
|
+
"limit": 50
|
|
2695
|
+
}
|
|
2696
|
+
campaigns_data = await make_api_request(endpoint, access_token, params)
|
|
2697
|
+
if "data" in campaigns_data:
|
|
2698
|
+
for campaign in campaigns_data["data"]:
|
|
2699
|
+
if "promoted_object" in campaign and "page_id" in campaign["promoted_object"]:
|
|
2700
|
+
all_page_ids.add(campaign["promoted_object"]["page_id"])
|
|
2701
|
+
except Exception:
|
|
2702
|
+
pass
|
|
2703
|
+
|
|
2704
|
+
# If we found any page IDs, get details for each
|
|
2705
|
+
if all_page_ids:
|
|
2706
|
+
page_details = {
|
|
2707
|
+
"data": [],
|
|
2708
|
+
"total_pages_found": len(all_page_ids)
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
for page_id in all_page_ids:
|
|
2712
|
+
try:
|
|
2713
|
+
page_endpoint = f"{page_id}"
|
|
2714
|
+
page_params = {
|
|
2715
|
+
"fields": "id,name,username,category,fan_count,link,verification_status,picture"
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
page_data = await make_api_request(page_endpoint, access_token, page_params)
|
|
2719
|
+
if "id" in page_data:
|
|
2720
|
+
page_details["data"].append(page_data)
|
|
2721
|
+
else:
|
|
2722
|
+
page_details["data"].append({
|
|
2723
|
+
"id": page_id,
|
|
2724
|
+
"error": "Page details not accessible"
|
|
2725
|
+
})
|
|
2726
|
+
except Exception as e:
|
|
2727
|
+
page_details["data"].append({
|
|
2728
|
+
"id": page_id,
|
|
2729
|
+
"error": f"Failed to get page details: {str(e)}"
|
|
2730
|
+
})
|
|
2731
|
+
|
|
2732
|
+
if page_details["data"]:
|
|
2733
|
+
return json.dumps(page_details, indent=2)
|
|
2734
|
+
|
|
2735
|
+
# If all approaches failed, return empty data with a message
|
|
2736
|
+
return json.dumps({
|
|
2737
|
+
"data": [],
|
|
2738
|
+
"message": "No pages found associated with this account",
|
|
2739
|
+
"suggestion": "Create a Facebook page and connect it to this ad account, or ensure existing pages are properly connected through Business Manager"
|
|
2740
|
+
}, indent=2)
|
|
2741
|
+
|
|
2742
|
+
except Exception as e:
|
|
2743
|
+
return json.dumps({
|
|
2744
|
+
"error": "Failed to get account pages",
|
|
2745
|
+
"details": str(e)
|
|
2746
|
+
}, indent=2)
|
|
2747
|
+
|
|
2748
|
+
|
|
2749
|
+
|
|
2750
|
+
|
|
2751
|
+
|