meta-ads-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meta_ads/__init__.py +3 -0
- meta_ads/api.py +202 -0
- meta_ads/campaign.py +112 -0
- meta_ads/cli.py +192 -0
- meta_ads/config.py +137 -0
- meta_ads_cli-0.1.0.dist-info/METADATA +287 -0
- meta_ads_cli-0.1.0.dist-info/RECORD +10 -0
- meta_ads_cli-0.1.0.dist-info/WHEEL +4 -0
- meta_ads_cli-0.1.0.dist-info/entry_points.txt +2 -0
- meta_ads_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
meta_ads/__init__.py
ADDED
meta_ads/api.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Meta Graph API client for ad management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import click
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MetaAPIError(Exception):
|
|
9
|
+
"""Raised when the Meta API returns an error."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status_code, message, error_code=None):
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self.error_code = error_code
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MetaAdsAPI:
|
|
18
|
+
"""Lightweight wrapper around the Meta Marketing API."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, access_token, ad_account_id, page_id, api_version="v21.0", dry_run=False):
|
|
21
|
+
self.access_token = access_token
|
|
22
|
+
self.ad_account_id = ad_account_id
|
|
23
|
+
self.act_id = f"act_{ad_account_id}"
|
|
24
|
+
self.page_id = page_id
|
|
25
|
+
self.api_version = api_version
|
|
26
|
+
self.base_url = f"https://graph.facebook.com/{api_version}"
|
|
27
|
+
self.dry_run = dry_run
|
|
28
|
+
self._dry_run_counter = 0
|
|
29
|
+
|
|
30
|
+
def _request(self, method, endpoint, **kwargs):
|
|
31
|
+
"""Make an API request to the Meta Graph API."""
|
|
32
|
+
url = f"{self.base_url}/{endpoint}"
|
|
33
|
+
kwargs.setdefault("params", {})
|
|
34
|
+
kwargs["params"]["access_token"] = self.access_token
|
|
35
|
+
|
|
36
|
+
if self.dry_run:
|
|
37
|
+
self._dry_run_counter += 1
|
|
38
|
+
fake_id = f"dry_run_{self._dry_run_counter}"
|
|
39
|
+
click.echo(click.style(f" [DRY RUN] {method} {endpoint}", fg="yellow"))
|
|
40
|
+
params = {k: v for k, v in kwargs.get("params", {}).items() if k != "access_token"}
|
|
41
|
+
if params:
|
|
42
|
+
preview = json.dumps(params, indent=2)
|
|
43
|
+
if len(preview) > 500:
|
|
44
|
+
preview = preview[:500] + "..."
|
|
45
|
+
click.echo(click.style(f" Params: {preview}", fg="yellow"))
|
|
46
|
+
if "files" in kwargs:
|
|
47
|
+
click.echo(click.style(f" Files: {list(kwargs['files'].keys())}", fg="yellow"))
|
|
48
|
+
return {"id": fake_id}
|
|
49
|
+
|
|
50
|
+
resp = getattr(requests, method.lower())(url, **kwargs)
|
|
51
|
+
|
|
52
|
+
if resp.status_code != 200:
|
|
53
|
+
try:
|
|
54
|
+
error_data = resp.json().get("error", {})
|
|
55
|
+
message = error_data.get("message", resp.text)
|
|
56
|
+
error_code = error_data.get("code")
|
|
57
|
+
except Exception:
|
|
58
|
+
message = resp.text
|
|
59
|
+
error_code = None
|
|
60
|
+
raise MetaAPIError(resp.status_code, message, error_code)
|
|
61
|
+
|
|
62
|
+
return resp.json()
|
|
63
|
+
|
|
64
|
+
def upload_image(self, image_path):
|
|
65
|
+
"""Upload an ad image to the ad account. Returns the image hash."""
|
|
66
|
+
with open(image_path, "rb") as f:
|
|
67
|
+
result = self._request(
|
|
68
|
+
"POST",
|
|
69
|
+
f"{self.act_id}/adimages",
|
|
70
|
+
files={"filename": (image_path.name, f, "image/png")},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if self.dry_run:
|
|
74
|
+
return "dry_run_hash"
|
|
75
|
+
|
|
76
|
+
images = result.get("images", {})
|
|
77
|
+
for key, val in images.items():
|
|
78
|
+
return val.get("hash")
|
|
79
|
+
|
|
80
|
+
raise MetaAPIError(0, f"Unexpected image upload response: {result}")
|
|
81
|
+
|
|
82
|
+
def create_campaign(self, name, objective="OUTCOME_TRAFFIC", status="PAUSED", special_ad_categories=None):
|
|
83
|
+
"""Create an ad campaign. Returns the campaign ID."""
|
|
84
|
+
result = self._request(
|
|
85
|
+
"POST",
|
|
86
|
+
f"{self.act_id}/campaigns",
|
|
87
|
+
params={
|
|
88
|
+
"name": name,
|
|
89
|
+
"objective": objective,
|
|
90
|
+
"status": status,
|
|
91
|
+
"special_ad_categories": json.dumps(special_ad_categories or []),
|
|
92
|
+
"is_adset_budget_sharing_enabled": "false",
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
return result.get("id", "dry_run_id")
|
|
96
|
+
|
|
97
|
+
def create_ad_set(self, name, campaign_id, daily_budget, targeting, optimization_goal="LINK_CLICKS",
|
|
98
|
+
billing_event="IMPRESSIONS", bid_strategy="LOWEST_COST_WITHOUT_CAP", status="PAUSED"):
|
|
99
|
+
"""Create an ad set with targeting. Returns the ad set ID."""
|
|
100
|
+
# Build targeting spec from config
|
|
101
|
+
targeting_spec = {
|
|
102
|
+
"age_min": targeting.get("age_min", 18),
|
|
103
|
+
"age_max": targeting.get("age_max", 65),
|
|
104
|
+
"genders": targeting.get("genders", [0]),
|
|
105
|
+
"geo_locations": {
|
|
106
|
+
"countries": targeting.get("countries", ["US"]),
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if targeting.get("interests"):
|
|
111
|
+
targeting_spec["flexible_spec"] = [
|
|
112
|
+
{"interests": targeting["interests"]}
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
platforms = targeting.get("platforms", ["facebook", "instagram"])
|
|
116
|
+
targeting_spec["publisher_platforms"] = platforms
|
|
117
|
+
|
|
118
|
+
if "facebook" in platforms:
|
|
119
|
+
targeting_spec["facebook_positions"] = targeting.get(
|
|
120
|
+
"facebook_positions", ["feed"]
|
|
121
|
+
)
|
|
122
|
+
if "instagram" in platforms:
|
|
123
|
+
targeting_spec["instagram_positions"] = targeting.get(
|
|
124
|
+
"instagram_positions", ["stream", "story", "reels"]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
result = self._request(
|
|
128
|
+
"POST",
|
|
129
|
+
f"{self.act_id}/adsets",
|
|
130
|
+
params={
|
|
131
|
+
"name": name,
|
|
132
|
+
"campaign_id": campaign_id,
|
|
133
|
+
"daily_budget": str(daily_budget),
|
|
134
|
+
"billing_event": billing_event,
|
|
135
|
+
"optimization_goal": optimization_goal,
|
|
136
|
+
"bid_strategy": bid_strategy,
|
|
137
|
+
"status": status,
|
|
138
|
+
"targeting": json.dumps(targeting_spec),
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
return result.get("id", "dry_run_id")
|
|
142
|
+
|
|
143
|
+
def create_ad_creative(self, name, image_hash, primary_text, headline, description, link, cta="LEARN_MORE"):
|
|
144
|
+
"""Create an ad creative. Returns the creative ID."""
|
|
145
|
+
result = self._request(
|
|
146
|
+
"POST",
|
|
147
|
+
f"{self.act_id}/adcreatives",
|
|
148
|
+
params={
|
|
149
|
+
"name": name,
|
|
150
|
+
"object_story_spec": json.dumps({
|
|
151
|
+
"link_data": {
|
|
152
|
+
"image_hash": image_hash,
|
|
153
|
+
"link": link,
|
|
154
|
+
"message": primary_text,
|
|
155
|
+
"name": headline,
|
|
156
|
+
"description": description,
|
|
157
|
+
"call_to_action": {
|
|
158
|
+
"type": cta,
|
|
159
|
+
"value": {"link": link},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
"page_id": self.page_id,
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
return result.get("id", "dry_run_id")
|
|
167
|
+
|
|
168
|
+
def create_ad(self, name, ad_set_id, creative_id, status="PAUSED"):
|
|
169
|
+
"""Create an ad linking a creative to an ad set. Returns the ad ID."""
|
|
170
|
+
result = self._request(
|
|
171
|
+
"POST",
|
|
172
|
+
f"{self.act_id}/ads",
|
|
173
|
+
params={
|
|
174
|
+
"name": name,
|
|
175
|
+
"adset_id": ad_set_id,
|
|
176
|
+
"creative": json.dumps({"creative_id": creative_id}),
|
|
177
|
+
"status": status,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
return result.get("id", "dry_run_id")
|
|
181
|
+
|
|
182
|
+
def get_campaign(self, campaign_id, fields="name,status,objective"):
|
|
183
|
+
"""Get campaign details."""
|
|
184
|
+
return self._request("GET", campaign_id, params={"fields": fields})
|
|
185
|
+
|
|
186
|
+
def get_ad_sets(self, campaign_id, fields="name,status,daily_budget"):
|
|
187
|
+
"""Get ad sets for a campaign."""
|
|
188
|
+
result = self._request("GET", f"{campaign_id}/adsets", params={"fields": fields})
|
|
189
|
+
return result.get("data", [])
|
|
190
|
+
|
|
191
|
+
def get_ads(self, campaign_id, fields="name,status,effective_status"):
|
|
192
|
+
"""Get ads for a campaign."""
|
|
193
|
+
result = self._request("GET", f"{campaign_id}/ads", params={"fields": fields})
|
|
194
|
+
return result.get("data", [])
|
|
195
|
+
|
|
196
|
+
def update_status(self, object_id, status):
|
|
197
|
+
"""Update the status of a campaign, ad set, or ad."""
|
|
198
|
+
return self._request("POST", object_id, params={"status": status})
|
|
199
|
+
|
|
200
|
+
def delete_campaign(self, campaign_id):
|
|
201
|
+
"""Delete a campaign (sets status to DELETED)."""
|
|
202
|
+
return self.update_status(campaign_id, "DELETED")
|
meta_ads/campaign.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Campaign orchestration: create, status, and management."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_full_campaign(api, config):
|
|
9
|
+
"""Create a complete campaign from config: campaign, ad set, creatives, and ads.
|
|
10
|
+
|
|
11
|
+
Returns a dict with all created object IDs.
|
|
12
|
+
"""
|
|
13
|
+
campaign_cfg = config["campaign"]
|
|
14
|
+
ad_set_cfg = config["ad_set"]
|
|
15
|
+
ads_cfg = config["ads"]
|
|
16
|
+
status = campaign_cfg.get("status", "PAUSED")
|
|
17
|
+
|
|
18
|
+
result = {
|
|
19
|
+
"campaign_id": None,
|
|
20
|
+
"ad_set_id": None,
|
|
21
|
+
"creatives": [],
|
|
22
|
+
"ads": [],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Step 1: Upload images
|
|
26
|
+
click.echo(click.style("\n[1/4] Uploading images", fg="blue", bold=True))
|
|
27
|
+
image_hashes = {}
|
|
28
|
+
for ad in ads_cfg:
|
|
29
|
+
image_path = Path(ad["image"])
|
|
30
|
+
click.echo(f" Uploading {image_path.name}...")
|
|
31
|
+
image_hashes[ad["name"]] = api.upload_image(image_path)
|
|
32
|
+
click.echo(click.style(f" Done.", fg="green"))
|
|
33
|
+
|
|
34
|
+
# Step 2: Create campaign
|
|
35
|
+
click.echo(click.style("\n[2/4] Creating campaign", fg="blue", bold=True))
|
|
36
|
+
click.echo(f" Name: {campaign_cfg['name']}")
|
|
37
|
+
campaign_id = api.create_campaign(
|
|
38
|
+
name=campaign_cfg["name"],
|
|
39
|
+
objective=campaign_cfg.get("objective", "OUTCOME_TRAFFIC"),
|
|
40
|
+
status=status,
|
|
41
|
+
special_ad_categories=campaign_cfg.get("special_ad_categories"),
|
|
42
|
+
)
|
|
43
|
+
result["campaign_id"] = campaign_id
|
|
44
|
+
click.echo(click.style(f" Campaign ID: {campaign_id}", fg="green"))
|
|
45
|
+
|
|
46
|
+
# Step 3: Create ad set
|
|
47
|
+
click.echo(click.style("\n[3/4] Creating ad set", fg="blue", bold=True))
|
|
48
|
+
click.echo(f" Name: {ad_set_cfg['name']}")
|
|
49
|
+
budget_dollars = int(ad_set_cfg['daily_budget']) / 100
|
|
50
|
+
click.echo(f" Budget: ${budget_dollars:.2f}/day")
|
|
51
|
+
ad_set_id = api.create_ad_set(
|
|
52
|
+
name=ad_set_cfg["name"],
|
|
53
|
+
campaign_id=campaign_id,
|
|
54
|
+
daily_budget=ad_set_cfg["daily_budget"],
|
|
55
|
+
targeting=ad_set_cfg.get("targeting", {}),
|
|
56
|
+
optimization_goal=ad_set_cfg.get("optimization_goal", "LINK_CLICKS"),
|
|
57
|
+
billing_event=ad_set_cfg.get("billing_event", "IMPRESSIONS"),
|
|
58
|
+
bid_strategy=ad_set_cfg.get("bid_strategy", "LOWEST_COST_WITHOUT_CAP"),
|
|
59
|
+
status=status,
|
|
60
|
+
)
|
|
61
|
+
result["ad_set_id"] = ad_set_id
|
|
62
|
+
click.echo(click.style(f" Ad Set ID: {ad_set_id}", fg="green"))
|
|
63
|
+
|
|
64
|
+
# Step 4: Create creatives and ads
|
|
65
|
+
click.echo(click.style("\n[4/4] Creating ads", fg="blue", bold=True))
|
|
66
|
+
for ad in ads_cfg:
|
|
67
|
+
click.echo(f" Creating: {ad['name']}")
|
|
68
|
+
|
|
69
|
+
creative_id = api.create_ad_creative(
|
|
70
|
+
name=f"{ad['name']} - Creative",
|
|
71
|
+
image_hash=image_hashes[ad["name"]],
|
|
72
|
+
primary_text=ad["primary_text"].strip(),
|
|
73
|
+
headline=ad.get("headline", ""),
|
|
74
|
+
description=ad.get("description", ""),
|
|
75
|
+
link=ad["link"],
|
|
76
|
+
cta=ad.get("cta", "LEARN_MORE"),
|
|
77
|
+
)
|
|
78
|
+
result["creatives"].append(creative_id)
|
|
79
|
+
|
|
80
|
+
ad_id = api.create_ad(
|
|
81
|
+
name=ad["name"],
|
|
82
|
+
ad_set_id=ad_set_id,
|
|
83
|
+
creative_id=creative_id,
|
|
84
|
+
status=status,
|
|
85
|
+
)
|
|
86
|
+
result["ads"].append(ad_id)
|
|
87
|
+
click.echo(click.style(f" Ad ID: {ad_id}", fg="green"))
|
|
88
|
+
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_campaign_status(api, campaign_id):
|
|
93
|
+
"""Fetch and display campaign status with ad sets and ads."""
|
|
94
|
+
campaign = api.get_campaign(campaign_id, fields="name,status,objective,daily_budget")
|
|
95
|
+
click.echo(click.style(f"\nCampaign: {campaign['name']}", bold=True))
|
|
96
|
+
click.echo(f" ID: {campaign['id']}")
|
|
97
|
+
click.echo(f" Status: {campaign['status']}")
|
|
98
|
+
click.echo(f" Objective: {campaign.get('objective', 'N/A')}")
|
|
99
|
+
|
|
100
|
+
ad_sets = api.get_ad_sets(campaign_id, fields="name,status,daily_budget")
|
|
101
|
+
if ad_sets:
|
|
102
|
+
click.echo(click.style("\n Ad Sets:", bold=True))
|
|
103
|
+
for ad_set in ad_sets:
|
|
104
|
+
budget = int(ad_set.get("daily_budget", 0)) / 100
|
|
105
|
+
click.echo(f" {ad_set['name']}: {ad_set['status']} (${budget:.2f}/day)")
|
|
106
|
+
|
|
107
|
+
ads = api.get_ads(campaign_id, fields="name,status,effective_status")
|
|
108
|
+
if ads:
|
|
109
|
+
click.echo(click.style("\n Ads:", bold=True))
|
|
110
|
+
for ad in ads:
|
|
111
|
+
effective = ad.get("effective_status", ad["status"])
|
|
112
|
+
click.echo(f" {ad['name']}: {effective}")
|
meta_ads/cli.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""CLI entry point for meta-ads."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
from meta_ads import __version__
|
|
10
|
+
from meta_ads.api import MetaAdsAPI, MetaAPIError
|
|
11
|
+
from meta_ads.campaign import create_full_campaign, print_campaign_status
|
|
12
|
+
from meta_ads.config import load_config, validate_config, ConfigError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_api(dry_run=False):
|
|
16
|
+
"""Create a MetaAdsAPI instance from environment variables."""
|
|
17
|
+
access_token = os.getenv("META_ACCESS_TOKEN")
|
|
18
|
+
ad_account_id = os.getenv("META_AD_ACCOUNT_ID")
|
|
19
|
+
page_id = os.getenv("META_PAGE_ID")
|
|
20
|
+
api_version = os.getenv("META_API_VERSION", "v21.0")
|
|
21
|
+
|
|
22
|
+
missing = []
|
|
23
|
+
if not access_token:
|
|
24
|
+
missing.append("META_ACCESS_TOKEN")
|
|
25
|
+
if not ad_account_id:
|
|
26
|
+
missing.append("META_AD_ACCOUNT_ID")
|
|
27
|
+
if not page_id:
|
|
28
|
+
missing.append("META_PAGE_ID")
|
|
29
|
+
|
|
30
|
+
if missing:
|
|
31
|
+
click.echo(click.style("Missing required environment variables:", fg="red"))
|
|
32
|
+
for var in missing:
|
|
33
|
+
click.echo(click.style(f" {var}", fg="red"))
|
|
34
|
+
click.echo("\nSet them in .env or export them in your shell.")
|
|
35
|
+
click.echo("See: https://github.com/attainmentlabs/meta-ads-cli#configuration")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
return MetaAdsAPI(
|
|
39
|
+
access_token=access_token,
|
|
40
|
+
ad_account_id=ad_account_id,
|
|
41
|
+
page_id=page_id,
|
|
42
|
+
api_version=api_version,
|
|
43
|
+
dry_run=dry_run,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group()
|
|
48
|
+
@click.version_option(version=__version__, prog_name="meta-ads")
|
|
49
|
+
def cli():
|
|
50
|
+
"""Create and manage Meta ad campaigns from your terminal."""
|
|
51
|
+
load_dotenv()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@cli.command()
|
|
55
|
+
@click.option("--config", "config_path", default="campaign.yaml", help="Path to campaign YAML config.")
|
|
56
|
+
@click.option("--dry-run", is_flag=True, help="Preview what would be created without making API calls.")
|
|
57
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
|
|
58
|
+
def create(config_path, dry_run, yes):
|
|
59
|
+
"""Create a full campaign from a YAML config file."""
|
|
60
|
+
try:
|
|
61
|
+
config = load_config(config_path)
|
|
62
|
+
validate_config(config)
|
|
63
|
+
except ConfigError as e:
|
|
64
|
+
click.echo(click.style(f"Config error:\n{e}", fg="red"))
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
campaign_name = config["campaign"]["name"]
|
|
68
|
+
status = config["campaign"].get("status", "PAUSED")
|
|
69
|
+
budget = int(config["ad_set"]["daily_budget"]) / 100
|
|
70
|
+
num_ads = len(config["ads"])
|
|
71
|
+
|
|
72
|
+
click.echo(click.style("=" * 50, fg="blue"))
|
|
73
|
+
click.echo(click.style("meta-ads create", fg="blue", bold=True))
|
|
74
|
+
click.echo(click.style("=" * 50, fg="blue"))
|
|
75
|
+
click.echo(f"Campaign: {campaign_name}")
|
|
76
|
+
click.echo(f"Budget: ${budget:.2f}/day")
|
|
77
|
+
click.echo(f"Ads: {num_ads}")
|
|
78
|
+
click.echo(f"Status: {status}")
|
|
79
|
+
click.echo(f"Mode: {'DRY RUN' if dry_run else 'LIVE'}")
|
|
80
|
+
|
|
81
|
+
if not dry_run and not yes:
|
|
82
|
+
click.echo()
|
|
83
|
+
if not click.confirm(click.style("This will create real campaigns. Continue?", fg="yellow")):
|
|
84
|
+
click.echo("Aborted.")
|
|
85
|
+
sys.exit(0)
|
|
86
|
+
|
|
87
|
+
api = get_api(dry_run=dry_run)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
result = create_full_campaign(api, config)
|
|
91
|
+
except MetaAPIError as e:
|
|
92
|
+
click.echo(click.style(f"\nAPI Error: {e}", fg="red"))
|
|
93
|
+
if e.error_code:
|
|
94
|
+
click.echo(click.style(f"Error code: {e.error_code}", fg="red"))
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
# Summary
|
|
98
|
+
click.echo(click.style("\n" + "=" * 50, fg="green"))
|
|
99
|
+
click.echo(click.style("Done!", fg="green", bold=True))
|
|
100
|
+
click.echo(click.style("=" * 50, fg="green"))
|
|
101
|
+
click.echo(f"Campaign: {result['campaign_id']} ({status})")
|
|
102
|
+
click.echo(f"Ad Set: {result['ad_set_id']} ({status})")
|
|
103
|
+
click.echo(f"Creatives: {len(result['creatives'])}")
|
|
104
|
+
click.echo(f"Ads: {len(result['ads'])}")
|
|
105
|
+
|
|
106
|
+
if not dry_run:
|
|
107
|
+
click.echo(f"\nView in Ads Manager:")
|
|
108
|
+
click.echo(f" https://adsmanager.facebook.com/adsmanager/manage/campaigns?act={api.ad_account_id}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@cli.command()
|
|
112
|
+
@click.argument("campaign_id")
|
|
113
|
+
def status(campaign_id):
|
|
114
|
+
"""Show the status of a campaign and its ads."""
|
|
115
|
+
api = get_api()
|
|
116
|
+
try:
|
|
117
|
+
print_campaign_status(api, campaign_id)
|
|
118
|
+
except MetaAPIError as e:
|
|
119
|
+
click.echo(click.style(f"API Error: {e}", fg="red"))
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@cli.command()
|
|
124
|
+
@click.argument("campaign_id")
|
|
125
|
+
def pause(campaign_id):
|
|
126
|
+
"""Pause a campaign."""
|
|
127
|
+
api = get_api()
|
|
128
|
+
try:
|
|
129
|
+
api.update_status(campaign_id, "PAUSED")
|
|
130
|
+
click.echo(click.style(f"Campaign {campaign_id} paused.", fg="green"))
|
|
131
|
+
except MetaAPIError as e:
|
|
132
|
+
click.echo(click.style(f"API Error: {e}", fg="red"))
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cli.command()
|
|
137
|
+
@click.argument("campaign_id")
|
|
138
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
|
|
139
|
+
def activate(campaign_id, yes):
|
|
140
|
+
"""Activate a campaign. This will start spending your budget."""
|
|
141
|
+
if not yes:
|
|
142
|
+
if not click.confirm(click.style("This will start spending your ad budget. Continue?", fg="yellow")):
|
|
143
|
+
click.echo("Aborted.")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
api = get_api()
|
|
147
|
+
try:
|
|
148
|
+
api.update_status(campaign_id, "ACTIVE")
|
|
149
|
+
click.echo(click.style(f"Campaign {campaign_id} activated.", fg="green"))
|
|
150
|
+
except MetaAPIError as e:
|
|
151
|
+
click.echo(click.style(f"API Error: {e}", fg="red"))
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@cli.command()
|
|
156
|
+
@click.argument("campaign_id")
|
|
157
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
|
|
158
|
+
def delete(campaign_id, yes):
|
|
159
|
+
"""Delete a campaign. This cannot be undone."""
|
|
160
|
+
if not yes:
|
|
161
|
+
if not click.confirm(click.style("This will permanently delete the campaign. Continue?", fg="red")):
|
|
162
|
+
click.echo("Aborted.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
api = get_api()
|
|
166
|
+
try:
|
|
167
|
+
api.delete_campaign(campaign_id)
|
|
168
|
+
click.echo(click.style(f"Campaign {campaign_id} deleted.", fg="green"))
|
|
169
|
+
except MetaAPIError as e:
|
|
170
|
+
click.echo(click.style(f"API Error: {e}", fg="red"))
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@cli.command()
|
|
175
|
+
@click.option("--config", "config_path", default="campaign.yaml", help="Path to campaign YAML config.")
|
|
176
|
+
def validate(config_path):
|
|
177
|
+
"""Validate a campaign YAML config without making API calls."""
|
|
178
|
+
try:
|
|
179
|
+
config = load_config(config_path)
|
|
180
|
+
validate_config(config)
|
|
181
|
+
except ConfigError as e:
|
|
182
|
+
click.echo(click.style(f"Validation failed:\n{e}", fg="red"))
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
campaign_name = config["campaign"]["name"]
|
|
186
|
+
num_ads = len(config["ads"])
|
|
187
|
+
budget = int(config["ad_set"]["daily_budget"]) / 100
|
|
188
|
+
|
|
189
|
+
click.echo(click.style("Config is valid.", fg="green"))
|
|
190
|
+
click.echo(f" Campaign: {campaign_name}")
|
|
191
|
+
click.echo(f" Budget: ${budget:.2f}/day")
|
|
192
|
+
click.echo(f" Ads: {num_ads}")
|
meta_ads/config.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Campaign config loading and validation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
VALID_OBJECTIVES = [
|
|
9
|
+
"OUTCOME_TRAFFIC",
|
|
10
|
+
"OUTCOME_AWARENESS",
|
|
11
|
+
"OUTCOME_ENGAGEMENT",
|
|
12
|
+
"OUTCOME_LEADS",
|
|
13
|
+
"OUTCOME_SALES",
|
|
14
|
+
"OUTCOME_APP_PROMOTION",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
VALID_OPTIMIZATION_GOALS = [
|
|
18
|
+
"LINK_CLICKS",
|
|
19
|
+
"IMPRESSIONS",
|
|
20
|
+
"REACH",
|
|
21
|
+
"LANDING_PAGE_VIEWS",
|
|
22
|
+
"APP_INSTALLS",
|
|
23
|
+
"OFFSITE_CONVERSIONS",
|
|
24
|
+
"LEAD_GENERATION",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
VALID_CTAS = [
|
|
28
|
+
"LEARN_MORE",
|
|
29
|
+
"SIGN_UP",
|
|
30
|
+
"DOWNLOAD",
|
|
31
|
+
"SHOP_NOW",
|
|
32
|
+
"BOOK_NOW",
|
|
33
|
+
"GET_OFFER",
|
|
34
|
+
"SUBSCRIBE",
|
|
35
|
+
"CONTACT_US",
|
|
36
|
+
"APPLY_NOW",
|
|
37
|
+
"WATCH_MORE",
|
|
38
|
+
"INSTALL_MOBILE_APP",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
VALID_STATUSES = ["PAUSED", "ACTIVE"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigError(Exception):
|
|
45
|
+
"""Raised when campaign config is invalid."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config(path):
|
|
50
|
+
"""Load and validate a campaign YAML config file.
|
|
51
|
+
|
|
52
|
+
Returns the parsed config dict. Image paths are resolved
|
|
53
|
+
relative to the YAML file's directory.
|
|
54
|
+
"""
|
|
55
|
+
config_path = Path(path)
|
|
56
|
+
if not config_path.exists():
|
|
57
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
58
|
+
|
|
59
|
+
with open(config_path) as f:
|
|
60
|
+
config = yaml.safe_load(f)
|
|
61
|
+
|
|
62
|
+
if not config:
|
|
63
|
+
raise ConfigError("Config file is empty")
|
|
64
|
+
|
|
65
|
+
# Resolve image paths relative to the YAML file
|
|
66
|
+
config_dir = config_path.parent
|
|
67
|
+
for ad in config.get("ads", []):
|
|
68
|
+
if "image" in ad:
|
|
69
|
+
image_path = Path(ad["image"])
|
|
70
|
+
if not image_path.is_absolute():
|
|
71
|
+
ad["image"] = str(config_dir / image_path)
|
|
72
|
+
|
|
73
|
+
return config
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_config(config):
|
|
77
|
+
"""Validate a campaign config dict. Raises ConfigError on problems."""
|
|
78
|
+
errors = []
|
|
79
|
+
|
|
80
|
+
# Campaign section
|
|
81
|
+
campaign = config.get("campaign")
|
|
82
|
+
if not campaign:
|
|
83
|
+
errors.append("Missing 'campaign' section")
|
|
84
|
+
else:
|
|
85
|
+
if not campaign.get("name"):
|
|
86
|
+
errors.append("campaign.name is required")
|
|
87
|
+
objective = campaign.get("objective", "OUTCOME_TRAFFIC")
|
|
88
|
+
if objective not in VALID_OBJECTIVES:
|
|
89
|
+
errors.append(f"campaign.objective '{objective}' is not valid. Options: {', '.join(VALID_OBJECTIVES)}")
|
|
90
|
+
status = campaign.get("status", "PAUSED")
|
|
91
|
+
if status not in VALID_STATUSES:
|
|
92
|
+
errors.append(f"campaign.status must be PAUSED or ACTIVE")
|
|
93
|
+
|
|
94
|
+
# Ad set section
|
|
95
|
+
ad_set = config.get("ad_set")
|
|
96
|
+
if not ad_set:
|
|
97
|
+
errors.append("Missing 'ad_set' section")
|
|
98
|
+
else:
|
|
99
|
+
if not ad_set.get("name"):
|
|
100
|
+
errors.append("ad_set.name is required")
|
|
101
|
+
if not ad_set.get("daily_budget"):
|
|
102
|
+
errors.append("ad_set.daily_budget is required (in cents, e.g. 1000 = $10/day)")
|
|
103
|
+
opt_goal = ad_set.get("optimization_goal", "LINK_CLICKS")
|
|
104
|
+
if opt_goal not in VALID_OPTIMIZATION_GOALS:
|
|
105
|
+
errors.append(f"ad_set.optimization_goal '{opt_goal}' is not valid. Options: {', '.join(VALID_OPTIMIZATION_GOALS)}")
|
|
106
|
+
|
|
107
|
+
targeting = ad_set.get("targeting", {})
|
|
108
|
+
if not targeting.get("countries"):
|
|
109
|
+
errors.append("ad_set.targeting.countries is required (e.g. ['US', 'CA'])")
|
|
110
|
+
|
|
111
|
+
# Ads section
|
|
112
|
+
ads = config.get("ads")
|
|
113
|
+
if not ads:
|
|
114
|
+
errors.append("Missing 'ads' section (need at least one ad)")
|
|
115
|
+
else:
|
|
116
|
+
for i, ad in enumerate(ads):
|
|
117
|
+
prefix = f"ads[{i}]"
|
|
118
|
+
if not ad.get("name"):
|
|
119
|
+
errors.append(f"{prefix}.name is required")
|
|
120
|
+
if not ad.get("image"):
|
|
121
|
+
errors.append(f"{prefix}.image is required")
|
|
122
|
+
elif not Path(ad["image"]).exists():
|
|
123
|
+
errors.append(f"{prefix}.image not found: {ad['image']}")
|
|
124
|
+
if not ad.get("primary_text"):
|
|
125
|
+
errors.append(f"{prefix}.primary_text is required")
|
|
126
|
+
if not ad.get("headline"):
|
|
127
|
+
errors.append(f"{prefix}.headline is required")
|
|
128
|
+
if not ad.get("link"):
|
|
129
|
+
errors.append(f"{prefix}.link is required")
|
|
130
|
+
cta = ad.get("cta", "LEARN_MORE")
|
|
131
|
+
if cta not in VALID_CTAS:
|
|
132
|
+
errors.append(f"{prefix}.cta '{cta}' is not valid. Options: {', '.join(VALID_CTAS)}")
|
|
133
|
+
|
|
134
|
+
if errors:
|
|
135
|
+
raise ConfigError("\n".join(f" - {e}" for e in errors))
|
|
136
|
+
|
|
137
|
+
return config
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: meta-ads-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Create and manage Meta (Facebook/Instagram) ad campaigns from your terminal.
|
|
5
|
+
Project-URL: Homepage, https://github.com/attainmentlabs/meta-ads-cli
|
|
6
|
+
Project-URL: Issues, https://github.com/attainmentlabs/meta-ads-cli/issues
|
|
7
|
+
Author-email: Attainment Labs <hello@attainmentlabs.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ads,advertising,cli,facebook,instagram,marketing,meta
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Classifier: Topic :: Office/Business
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: click>=8.0
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0
|
|
26
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
|
+
Requires-Dist: requests>=2.28
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# meta-ads-cli
|
|
31
|
+
|
|
32
|
+
Create and manage Meta (Facebook/Instagram) ad campaigns from your terminal.
|
|
33
|
+
|
|
34
|
+
> Built by [Attainment Labs](https://attainmentlabs.com)
|
|
35
|
+
|
|
36
|
+
## Why
|
|
37
|
+
|
|
38
|
+
Meta Ads Manager is slow. Clicking through 15 screens to launch a campaign is a waste of time when you already know what you want to run.
|
|
39
|
+
|
|
40
|
+
This tool lets you define a campaign in a YAML file and deploy it with one command.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
meta-ads create --config campaign.yaml
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
One campaign. One ad set. Multiple ads. All created in seconds.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install meta-ads-cli
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or install from source:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/attainmentlabs/meta-ads-cli.git
|
|
58
|
+
cd meta-ads-cli
|
|
59
|
+
pip install -e .
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
**1. Set up your credentials**
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cp .env.example .env
|
|
68
|
+
# Edit .env with your Meta access token, ad account ID, and page ID
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**2. Create your campaign config**
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
cp campaign.example.yaml campaign.yaml
|
|
75
|
+
# Edit campaign.yaml with your ad copy, images, targeting, and budget
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**3. Validate your config**
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
meta-ads validate --config campaign.yaml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**4. Preview with dry run**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
meta-ads create --config campaign.yaml --dry-run
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**5. Deploy**
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
meta-ads create --config campaign.yaml
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Your campaign is created as PAUSED by default. Review it in Ads Manager, then activate it when ready.
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
### Environment Variables
|
|
101
|
+
|
|
102
|
+
Create a `.env` file in your project root (or export these in your shell):
|
|
103
|
+
|
|
104
|
+
| Variable | Required | Description |
|
|
105
|
+
|----------|----------|-------------|
|
|
106
|
+
| `META_ACCESS_TOKEN` | Yes | Your Meta API access token |
|
|
107
|
+
| `META_AD_ACCOUNT_ID` | Yes | Your ad account ID (numbers only, no `act_` prefix) |
|
|
108
|
+
| `META_PAGE_ID` | Yes | Your Facebook Page ID |
|
|
109
|
+
| `META_API_VERSION` | No | API version (default: `v21.0`) |
|
|
110
|
+
|
|
111
|
+
### Campaign Config (YAML)
|
|
112
|
+
|
|
113
|
+
Your campaign is defined in a single YAML file. Here is the full schema:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
campaign:
|
|
117
|
+
name: "My Campaign"
|
|
118
|
+
objective: OUTCOME_TRAFFIC # See objectives below
|
|
119
|
+
status: PAUSED # PAUSED or ACTIVE
|
|
120
|
+
special_ad_categories: [] # Leave empty unless required
|
|
121
|
+
|
|
122
|
+
ad_set:
|
|
123
|
+
name: "My Ad Set"
|
|
124
|
+
daily_budget: 1000 # In cents. 1000 = $10/day
|
|
125
|
+
optimization_goal: LINK_CLICKS # See optimization goals below
|
|
126
|
+
targeting:
|
|
127
|
+
age_min: 18
|
|
128
|
+
age_max: 65
|
|
129
|
+
genders: [0] # 0 = all, 1 = male, 2 = female
|
|
130
|
+
countries: ["US"]
|
|
131
|
+
interests: # Optional
|
|
132
|
+
- id: "6003139266461"
|
|
133
|
+
name: "Fitness and wellness"
|
|
134
|
+
platforms: ["facebook", "instagram"]
|
|
135
|
+
facebook_positions: ["feed"]
|
|
136
|
+
instagram_positions: ["stream", "story", "reels"]
|
|
137
|
+
|
|
138
|
+
ads:
|
|
139
|
+
- name: "My Ad"
|
|
140
|
+
image: ./images/ad.png # Path relative to YAML file
|
|
141
|
+
primary_text: "Your ad copy."
|
|
142
|
+
headline: "Your Headline"
|
|
143
|
+
description: "Short description"
|
|
144
|
+
cta: LEARN_MORE # See CTAs below
|
|
145
|
+
link: "https://example.com"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Campaign Objectives:** `OUTCOME_TRAFFIC`, `OUTCOME_AWARENESS`, `OUTCOME_ENGAGEMENT`, `OUTCOME_LEADS`, `OUTCOME_SALES`, `OUTCOME_APP_PROMOTION`
|
|
149
|
+
|
|
150
|
+
**Optimization Goals:** `LINK_CLICKS`, `IMPRESSIONS`, `REACH`, `LANDING_PAGE_VIEWS`, `APP_INSTALLS`, `OFFSITE_CONVERSIONS`, `LEAD_GENERATION`
|
|
151
|
+
|
|
152
|
+
**CTA Options:** `LEARN_MORE`, `SIGN_UP`, `DOWNLOAD`, `SHOP_NOW`, `BOOK_NOW`, `GET_OFFER`, `SUBSCRIBE`, `CONTACT_US`, `APPLY_NOW`, `WATCH_MORE`
|
|
153
|
+
|
|
154
|
+
## Commands
|
|
155
|
+
|
|
156
|
+
### `meta-ads create`
|
|
157
|
+
|
|
158
|
+
Create a full campaign from your YAML config.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
# Preview first
|
|
162
|
+
meta-ads create --dry-run
|
|
163
|
+
|
|
164
|
+
# Deploy (will ask for confirmation)
|
|
165
|
+
meta-ads create
|
|
166
|
+
|
|
167
|
+
# Deploy without confirmation
|
|
168
|
+
meta-ads create --yes
|
|
169
|
+
|
|
170
|
+
# Use a custom config file
|
|
171
|
+
meta-ads create --config path/to/campaign.yaml
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### `meta-ads status <campaign-id>`
|
|
175
|
+
|
|
176
|
+
Check the status of a campaign and all its ads.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
meta-ads status 120243616427570285
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### `meta-ads pause <campaign-id>`
|
|
183
|
+
|
|
184
|
+
Pause a running campaign.
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
meta-ads pause 120243616427570285
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### `meta-ads activate <campaign-id>`
|
|
191
|
+
|
|
192
|
+
Activate a paused campaign. This starts spending your budget.
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
meta-ads activate 120243616427570285
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### `meta-ads delete <campaign-id>`
|
|
199
|
+
|
|
200
|
+
Permanently delete a campaign.
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
meta-ads delete 120243616427570285
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### `meta-ads validate`
|
|
207
|
+
|
|
208
|
+
Validate your YAML config without making any API calls.
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
meta-ads validate --config campaign.yaml
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Getting a Meta Access Token
|
|
215
|
+
|
|
216
|
+
This is the part most people get stuck on. Here is the short version:
|
|
217
|
+
|
|
218
|
+
1. Go to [Meta for Developers](https://developers.facebook.com/) and create an app (type: Business)
|
|
219
|
+
2. Open the [Graph API Explorer](https://developers.facebook.com/tools/explorer/)
|
|
220
|
+
3. Select your app, then request these permissions: `ads_management`, `pages_read_engagement`, `pages_show_list`
|
|
221
|
+
4. Click "Generate Access Token" and authorize
|
|
222
|
+
5. Copy the token to your `.env` file
|
|
223
|
+
|
|
224
|
+
**Important:** Graph API Explorer tokens expire after about 2 hours. For production use, exchange it for a long-lived token:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
curl "https://graph.facebook.com/v21.0/oauth/access_token?\
|
|
228
|
+
grant_type=fb_exchange_token&\
|
|
229
|
+
client_id=YOUR_APP_ID&\
|
|
230
|
+
client_secret=YOUR_APP_SECRET&\
|
|
231
|
+
fb_exchange_token=YOUR_SHORT_LIVED_TOKEN"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Long-lived tokens last about 60 days.
|
|
235
|
+
|
|
236
|
+
## Finding Interest IDs
|
|
237
|
+
|
|
238
|
+
Interest targeting requires Meta's internal IDs. Search for them using the API:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
curl "https://graph.facebook.com/v21.0/search?\
|
|
242
|
+
type=adinterest&\
|
|
243
|
+
q=fitness&\
|
|
244
|
+
access_token=YOUR_TOKEN"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
This returns interest names and IDs you can use in your campaign YAML.
|
|
248
|
+
|
|
249
|
+
## Examples
|
|
250
|
+
|
|
251
|
+
See the [`examples/`](examples/) directory for ready-to-customize campaign configs:
|
|
252
|
+
|
|
253
|
+
- **[ecommerce.yaml](examples/ecommerce.yaml)**: Product launch targeting skincare enthusiasts
|
|
254
|
+
- **[app-install.yaml](examples/app-install.yaml)**: Mobile app install campaign for fitness users
|
|
255
|
+
|
|
256
|
+
## How It Works
|
|
257
|
+
|
|
258
|
+
This tool wraps the [Meta Marketing API](https://developers.facebook.com/docs/marketing-apis/) with `requests`. No heavy SDKs. The full chain:
|
|
259
|
+
|
|
260
|
+
1. Uploads your ad images to your ad account
|
|
261
|
+
2. Creates a campaign with your objective
|
|
262
|
+
3. Creates an ad set with your budget and targeting
|
|
263
|
+
4. Creates ad creatives linking your images and copy
|
|
264
|
+
5. Creates ads linking creatives to the ad set
|
|
265
|
+
|
|
266
|
+
Everything is created as `PAUSED` by default so you can review before spending.
|
|
267
|
+
|
|
268
|
+
## Want the Full Playbook?
|
|
269
|
+
|
|
270
|
+
We wrote a free guide covering Meta Ads automation end to end: OAuth setup walkthrough, audience segmentation strategy, budget allocation frameworks, creative testing, scaling rules, and common API errors with fixes.
|
|
271
|
+
|
|
272
|
+
**[Get the Guide: The Engineer's Playbook for Meta Ads](https://attainmentlabs.com/meta-ads-playbook)**
|
|
273
|
+
|
|
274
|
+
## Contributing
|
|
275
|
+
|
|
276
|
+
PRs welcome. Keep it simple. This tool is intentionally lightweight.
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
git clone https://github.com/attainmentlabs/meta-ads-cli.git
|
|
280
|
+
cd meta-ads-cli
|
|
281
|
+
pip install -e .
|
|
282
|
+
meta-ads --help
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
meta_ads/__init__.py,sha256=7V2HVw6gBETk2UhHzNB3Rb0okjUT8mtYAsapR-QLSiI,99
|
|
2
|
+
meta_ads/api.py,sha256=yW19q4eee_i9t69n_IptpAR36u5h6F7GNXhgpfxJYHc,7833
|
|
3
|
+
meta_ads/campaign.py,sha256=p-wmdNQBIRwzrmYZ2AR6eks3lA9xtfkp2LhM4HkrmWg,4225
|
|
4
|
+
meta_ads/cli.py,sha256=mzXcfq9IkG5_r_Tfc-moAXLGGv8C7q6x9P6NWlYdKv4,6653
|
|
5
|
+
meta_ads/config.py,sha256=loaX5iZoImCPa3zmfLf41wSeWGwzrQuGS9Qg37J4Smw,4205
|
|
6
|
+
meta_ads_cli-0.1.0.dist-info/METADATA,sha256=xL0UEhhRzm_82rP9hyssl7XOTohekRZ316IVYCSF8_U,8021
|
|
7
|
+
meta_ads_cli-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
meta_ads_cli-0.1.0.dist-info/entry_points.txt,sha256=Y9vImL5xWdansNNZbBti-UvAzGX1hwT8xTUnt1xFUMY,46
|
|
9
|
+
meta_ads_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Ye3Xeu5A3dvqnobTC0cIl4j_pYMgwArm613EtzaGqIA,1072
|
|
10
|
+
meta_ads_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Attainment Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|