cli-meta-ads 0.1.0
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.
- package/AGENTS.md +188 -0
- package/AI_CONTEXT.md +144 -0
- package/CLAUDE.md +183 -0
- package/README.md +590 -0
- package/REQUIREMENTS.md +148 -0
- package/dist/auth/constants.d.ts +1 -0
- package/dist/auth/constants.js +1 -0
- package/dist/auth/guards.d.ts +5 -0
- package/dist/auth/guards.js +16 -0
- package/dist/auth/login.d.ts +28 -0
- package/dist/auth/login.js +222 -0
- package/dist/cli/action.d.ts +11 -0
- package/dist/cli/action.js +77 -0
- package/dist/cli/build-cli.d.ts +2 -0
- package/dist/cli/build-cli.js +110 -0
- package/dist/cli/context.d.ts +24 -0
- package/dist/cli/context.js +19 -0
- package/dist/client/meta-api-client.d.ts +50 -0
- package/dist/client/meta-api-client.js +258 -0
- package/dist/client/meta-discovery.d.ts +13 -0
- package/dist/client/meta-discovery.js +88 -0
- package/dist/commands/accounts.d.ts +4 -0
- package/dist/commands/accounts.js +42 -0
- package/dist/commands/ads.d.ts +4 -0
- package/dist/commands/ads.js +148 -0
- package/dist/commands/adsets.d.ts +4 -0
- package/dist/commands/adsets.js +49 -0
- package/dist/commands/anomalies.d.ts +4 -0
- package/dist/commands/anomalies.js +44 -0
- package/dist/commands/assets.d.ts +4 -0
- package/dist/commands/assets.js +116 -0
- package/dist/commands/audiences.d.ts +4 -0
- package/dist/commands/audiences.js +40 -0
- package/dist/commands/auth.d.ts +4 -0
- package/dist/commands/auth.js +139 -0
- package/dist/commands/campaigns.d.ts +4 -0
- package/dist/commands/campaigns.js +273 -0
- package/dist/commands/capi.d.ts +4 -0
- package/dist/commands/capi.js +64 -0
- package/dist/commands/creatives.d.ts +4 -0
- package/dist/commands/creatives.js +49 -0
- package/dist/commands/diagnostics.d.ts +4 -0
- package/dist/commands/diagnostics.js +88 -0
- package/dist/commands/helpers.d.ts +13 -0
- package/dist/commands/helpers.js +50 -0
- package/dist/commands/launch.d.ts +4 -0
- package/dist/commands/launch.js +109 -0
- package/dist/commands/performance.d.ts +4 -0
- package/dist/commands/performance.js +55 -0
- package/dist/commands/pixel.d.ts +4 -0
- package/dist/commands/pixel.js +68 -0
- package/dist/commands/report.d.ts +4 -0
- package/dist/commands/report.js +30 -0
- package/dist/config/file-config.d.ts +6 -0
- package/dist/config/file-config.js +174 -0
- package/dist/config/types.d.ts +32 -0
- package/dist/config/types.js +1 -0
- package/dist/domain/account-scope.d.ts +7 -0
- package/dist/domain/account-scope.js +28 -0
- package/dist/domain/analytics.d.ts +52 -0
- package/dist/domain/analytics.js +125 -0
- package/dist/domain/approval-service.d.ts +10 -0
- package/dist/domain/approval-service.js +48 -0
- package/dist/domain/asset-feed-compiler.d.ts +43 -0
- package/dist/domain/asset-feed-compiler.js +104 -0
- package/dist/domain/launch-service.d.ts +200 -0
- package/dist/domain/launch-service.js +558 -0
- package/dist/domain/meta-ads-service.d.ts +620 -0
- package/dist/domain/meta-ads-service.js +841 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -0
- package/dist/output/render.d.ts +3 -0
- package/dist/output/render.js +103 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +1 -0
- package/dist/utils/currency.d.ts +4 -0
- package/dist/utils/currency.js +40 -0
- package/dist/utils/date-range.d.ts +20 -0
- package/dist/utils/date-range.js +115 -0
- package/dist/utils/errors.d.ts +35 -0
- package/dist/utils/errors.js +68 -0
- package/dist/utils/ids.d.ts +4 -0
- package/dist/utils/ids.js +23 -0
- package/dist/utils/meta-placement-assets.d.ts +44 -0
- package/dist/utils/meta-placement-assets.js +315 -0
- package/dist/utils/security.d.ts +5 -0
- package/dist/utils/security.js +104 -0
- package/dist/validators/common.d.ts +10 -0
- package/dist/validators/common.js +56 -0
- package/dist/validators/create-spec.d.ts +373 -0
- package/dist/validators/create-spec.js +394 -0
- package/dist/validators/launch-spec.d.ts +229 -0
- package/dist/validators/launch-spec.js +371 -0
- package/docs/TECHNICAL.md +480 -0
- package/examples/README.md +29 -0
- package/examples/launch/assets/feed4x5.png +0 -0
- package/examples/launch/assets/story9x16.png +0 -0
- package/examples/launch/multi-format-launch.json +90 -0
- package/examples/single-object/ad.json +6 -0
- package/examples/single-object/adset.json +30 -0
- package/examples/single-object/campaign.json +6 -0
- package/examples/single-object/creative.json +19 -0
- package/package.json +62 -0
- package/skills/meta-cli-operator/SKILL.md +105 -0
- package/skills/meta-cli-operator/agents/openai.yaml +4 -0
- package/skills/meta-cli-operator/references/update-matrix.md +117 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config/types.js";
|
|
2
|
+
export interface MetaRateLimitUsage {
|
|
3
|
+
app?: Record<string, unknown> | undefined;
|
|
4
|
+
adAccount?: Record<string, unknown> | undefined;
|
|
5
|
+
businessUseCase?: Record<string, unknown> | undefined;
|
|
6
|
+
}
|
|
7
|
+
export interface MetaPagedResponse<T> {
|
|
8
|
+
data: T[];
|
|
9
|
+
paging?: {
|
|
10
|
+
cursors?: {
|
|
11
|
+
after?: string | undefined;
|
|
12
|
+
before?: string | undefined;
|
|
13
|
+
} | undefined;
|
|
14
|
+
next?: string | undefined;
|
|
15
|
+
} | undefined;
|
|
16
|
+
summary?: Record<string, unknown> | undefined;
|
|
17
|
+
}
|
|
18
|
+
export interface MetaRequestOptions {
|
|
19
|
+
body?: Record<string, string | number | boolean | undefined> | undefined;
|
|
20
|
+
multipart?: Record<string, MetaMultipartValue | undefined> | undefined;
|
|
21
|
+
method?: "GET" | "POST";
|
|
22
|
+
path: string;
|
|
23
|
+
query?: Record<string, string | number | boolean | undefined> | undefined;
|
|
24
|
+
retryable?: boolean | undefined;
|
|
25
|
+
}
|
|
26
|
+
export interface MetaRequestResult<T> {
|
|
27
|
+
data: T;
|
|
28
|
+
usage: MetaRateLimitUsage;
|
|
29
|
+
headers: Headers;
|
|
30
|
+
}
|
|
31
|
+
export interface FetchLike {
|
|
32
|
+
(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
33
|
+
}
|
|
34
|
+
export type MetaMultipartValue = string | number | boolean | Blob | {
|
|
35
|
+
blob: Blob;
|
|
36
|
+
filename: string;
|
|
37
|
+
};
|
|
38
|
+
export declare class MetaApiClient {
|
|
39
|
+
private readonly fetchImpl;
|
|
40
|
+
private readonly config;
|
|
41
|
+
constructor(config: ResolvedConfig, fetchImpl?: FetchLike);
|
|
42
|
+
get apiVersion(): string;
|
|
43
|
+
request<T>(options: MetaRequestOptions): Promise<MetaRequestResult<T>>;
|
|
44
|
+
get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<MetaRequestResult<T>>;
|
|
45
|
+
post<T>(path: string, body?: Record<string, string | number | boolean | undefined>): Promise<MetaRequestResult<T>>;
|
|
46
|
+
postMultipart<T>(path: string, multipart: Record<string, MetaMultipartValue | undefined>): Promise<MetaRequestResult<T>>;
|
|
47
|
+
paginate<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<MetaRequestResult<MetaPagedResponse<T>>>;
|
|
48
|
+
private executeRequest;
|
|
49
|
+
private extractError;
|
|
50
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { requireAccessToken } from "../auth/guards.js";
|
|
5
|
+
import { AppError, ExitCode, MetaApiError } from "../utils/errors.js";
|
|
6
|
+
import { sanitizeText, sanitizeUrl } from "../utils/security.js";
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const packageJson = require("../../package.json");
|
|
9
|
+
const USER_AGENT = `cli-meta-ads/${packageJson.version ?? "0.0.0"}`;
|
|
10
|
+
const RETRIABLE_META_CODES = new Set([4, 17, 32, 341, 613, 80001, 80002, 80004]);
|
|
11
|
+
const MAX_PAGINATION_PAGES = 100;
|
|
12
|
+
function buildFormData(params, accessToken, appSecret) {
|
|
13
|
+
const formData = new FormData();
|
|
14
|
+
for (const [key, value] of Object.entries(params)) {
|
|
15
|
+
if (value === undefined) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (value instanceof Blob) {
|
|
19
|
+
formData.append(key, value);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === "object" && "blob" in value && "filename" in value) {
|
|
23
|
+
formData.append(key, value.blob, value.filename);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
formData.append(key, String(value));
|
|
27
|
+
}
|
|
28
|
+
formData.append("access_token", accessToken);
|
|
29
|
+
if (appSecret) {
|
|
30
|
+
const proof = createHmac("sha256", appSecret).update(accessToken).digest("hex");
|
|
31
|
+
formData.append("appsecret_proof", proof);
|
|
32
|
+
}
|
|
33
|
+
return formData;
|
|
34
|
+
}
|
|
35
|
+
function parseUsageHeader(value) {
|
|
36
|
+
if (!value) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(value);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function isRetriableError(status, metaCode) {
|
|
47
|
+
return status === 429 || status >= 500 || (metaCode !== undefined && RETRIABLE_META_CODES.has(metaCode));
|
|
48
|
+
}
|
|
49
|
+
function classifyExitCode(status, metaCode) {
|
|
50
|
+
if (metaCode === 190) {
|
|
51
|
+
return ExitCode.Auth;
|
|
52
|
+
}
|
|
53
|
+
if (metaCode === 200 || metaCode === 10 || metaCode === 270) {
|
|
54
|
+
return ExitCode.Permission;
|
|
55
|
+
}
|
|
56
|
+
if (isRetriableError(status, metaCode)) {
|
|
57
|
+
return ExitCode.RateLimit;
|
|
58
|
+
}
|
|
59
|
+
if (metaCode === 2635) {
|
|
60
|
+
return ExitCode.VerificationFailed;
|
|
61
|
+
}
|
|
62
|
+
return ExitCode.Provider;
|
|
63
|
+
}
|
|
64
|
+
function buildSearchParams(params, accessToken, appSecret) {
|
|
65
|
+
const searchParams = new URLSearchParams();
|
|
66
|
+
for (const [key, value] of Object.entries(params)) {
|
|
67
|
+
if (value !== undefined) {
|
|
68
|
+
searchParams.set(key, String(value));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
searchParams.set("access_token", accessToken);
|
|
72
|
+
if (appSecret) {
|
|
73
|
+
const proof = createHmac("sha256", appSecret).update(accessToken).digest("hex");
|
|
74
|
+
searchParams.set("appsecret_proof", proof);
|
|
75
|
+
}
|
|
76
|
+
return searchParams;
|
|
77
|
+
}
|
|
78
|
+
export class MetaApiClient {
|
|
79
|
+
fetchImpl;
|
|
80
|
+
config;
|
|
81
|
+
constructor(config, fetchImpl = fetch) {
|
|
82
|
+
this.config = config;
|
|
83
|
+
this.fetchImpl = fetchImpl;
|
|
84
|
+
}
|
|
85
|
+
get apiVersion() {
|
|
86
|
+
return this.config.apiVersion;
|
|
87
|
+
}
|
|
88
|
+
async request(options) {
|
|
89
|
+
const maxAttempts = options.retryable === false ? 1 : 4;
|
|
90
|
+
const accessToken = requireAccessToken(this.config);
|
|
91
|
+
let lastError;
|
|
92
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
93
|
+
try {
|
|
94
|
+
return await this.executeRequest(options, accessToken);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!(error instanceof MetaApiError)) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
lastError = error;
|
|
101
|
+
const retriable = options.retryable !== false && isRetriableError(error.status, error.metaCode);
|
|
102
|
+
if (!retriable || attempt === maxAttempts) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const backoffMs = 400 * (2 ** (attempt - 1)) + Math.floor(Math.random() * 250);
|
|
106
|
+
await sleep(backoffMs);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw lastError ?? new MetaApiError("Meta request failed.", ExitCode.Provider, { status: 500 });
|
|
110
|
+
}
|
|
111
|
+
async get(path, query) {
|
|
112
|
+
return this.request({
|
|
113
|
+
method: "GET",
|
|
114
|
+
path,
|
|
115
|
+
query,
|
|
116
|
+
retryable: true
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async post(path, body) {
|
|
120
|
+
return this.request({
|
|
121
|
+
method: "POST",
|
|
122
|
+
path,
|
|
123
|
+
body,
|
|
124
|
+
retryable: false
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async postMultipart(path, multipart) {
|
|
128
|
+
return this.request({
|
|
129
|
+
method: "POST",
|
|
130
|
+
multipart,
|
|
131
|
+
path,
|
|
132
|
+
retryable: false
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async paginate(path, query) {
|
|
136
|
+
const collected = [];
|
|
137
|
+
let after;
|
|
138
|
+
let lastUsage = {};
|
|
139
|
+
let summary;
|
|
140
|
+
let pageCount = 0;
|
|
141
|
+
do {
|
|
142
|
+
const pageQuery = {
|
|
143
|
+
...(query ?? {}),
|
|
144
|
+
...(after ? { after } : {})
|
|
145
|
+
};
|
|
146
|
+
const page = await this.get(path, pageQuery);
|
|
147
|
+
pageCount += 1;
|
|
148
|
+
collected.push(...page.data.data);
|
|
149
|
+
lastUsage = page.usage;
|
|
150
|
+
summary = page.data.summary ?? summary;
|
|
151
|
+
after = page.data.paging?.cursors?.after;
|
|
152
|
+
if (!page.data.paging?.next) {
|
|
153
|
+
after = undefined;
|
|
154
|
+
}
|
|
155
|
+
if (after && pageCount >= MAX_PAGINATION_PAGES) {
|
|
156
|
+
throw new AppError(`Pagination exceeded the safety limit of ${MAX_PAGINATION_PAGES} pages. Refine the query before retrying.`, ExitCode.Provider, {
|
|
157
|
+
pageLimit: MAX_PAGINATION_PAGES,
|
|
158
|
+
path
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
} while (after);
|
|
162
|
+
return {
|
|
163
|
+
data: {
|
|
164
|
+
data: collected,
|
|
165
|
+
summary
|
|
166
|
+
},
|
|
167
|
+
usage: lastUsage,
|
|
168
|
+
headers: new Headers()
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async executeRequest(options, accessToken) {
|
|
172
|
+
const baseUrl = `https://graph.facebook.com/${this.config.apiVersion}${options.path}`;
|
|
173
|
+
const method = options.method ?? "GET";
|
|
174
|
+
const init = {
|
|
175
|
+
method,
|
|
176
|
+
headers: {
|
|
177
|
+
Accept: "application/json",
|
|
178
|
+
"User-Agent": USER_AGENT
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
let url = baseUrl;
|
|
182
|
+
if (method === "GET") {
|
|
183
|
+
const searchParams = buildSearchParams(options.query ?? {}, accessToken, this.config.appSecret);
|
|
184
|
+
url = `${baseUrl}?${searchParams.toString()}`;
|
|
185
|
+
}
|
|
186
|
+
else if (options.multipart) {
|
|
187
|
+
init.body = buildFormData(options.multipart, accessToken, this.config.appSecret);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const bodyParams = buildSearchParams(options.body ?? {}, accessToken, this.config.appSecret);
|
|
191
|
+
init.headers = {
|
|
192
|
+
...init.headers,
|
|
193
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
194
|
+
};
|
|
195
|
+
init.body = bodyParams.toString();
|
|
196
|
+
}
|
|
197
|
+
const sanitizedRequestUrl = sanitizeUrl(url);
|
|
198
|
+
const response = await this.fetchImpl(url, init);
|
|
199
|
+
const usage = {
|
|
200
|
+
app: parseUsageHeader(response.headers.get("x-app-usage")),
|
|
201
|
+
adAccount: parseUsageHeader(response.headers.get("x-ad-account-usage")),
|
|
202
|
+
businessUseCase: parseUsageHeader(response.headers.get("x-business-use-case-usage"))
|
|
203
|
+
};
|
|
204
|
+
const text = await response.text();
|
|
205
|
+
let payload = {};
|
|
206
|
+
if (text) {
|
|
207
|
+
try {
|
|
208
|
+
payload = JSON.parse(text);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
throw new MetaApiError(`Meta returned a non-JSON response with status ${response.status}.`, classifyExitCode(response.status), {
|
|
212
|
+
status: response.status,
|
|
213
|
+
details: {
|
|
214
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
215
|
+
path: options.path,
|
|
216
|
+
requestUrl: sanitizedRequestUrl,
|
|
217
|
+
responsePreview: sanitizeText(text.slice(0, 200))
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const errorPayload = this.extractError(payload);
|
|
223
|
+
if (!response.ok || errorPayload) {
|
|
224
|
+
const status = response.status;
|
|
225
|
+
const message = errorPayload?.message ?? `Meta request failed with status ${status}.`;
|
|
226
|
+
const metaCode = errorPayload?.code;
|
|
227
|
+
const metaSubcode = errorPayload?.error_subcode;
|
|
228
|
+
const fbtraceId = errorPayload?.fbtrace_id;
|
|
229
|
+
throw new MetaApiError(message, classifyExitCode(status, metaCode), {
|
|
230
|
+
status,
|
|
231
|
+
metaCode,
|
|
232
|
+
metaSubcode,
|
|
233
|
+
fbtraceId,
|
|
234
|
+
details: {
|
|
235
|
+
path: options.path,
|
|
236
|
+
requestUrl: sanitizedRequestUrl,
|
|
237
|
+
usage,
|
|
238
|
+
response: payload
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
data: payload,
|
|
244
|
+
headers: response.headers,
|
|
245
|
+
usage
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
extractError(payload) {
|
|
249
|
+
if (typeof payload !== "object" || payload === null || !("error" in payload)) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const errorValue = payload.error;
|
|
253
|
+
if (typeof errorValue !== "object" || errorValue === null) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
return errorValue;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config/types.js";
|
|
2
|
+
import { MetaApiClient } from "./meta-api-client.js";
|
|
3
|
+
export interface ManagedAccount {
|
|
4
|
+
id: string;
|
|
5
|
+
name?: string | undefined;
|
|
6
|
+
accountStatus?: number | string | undefined;
|
|
7
|
+
currency?: string | undefined;
|
|
8
|
+
timezoneName?: string | undefined;
|
|
9
|
+
source: string;
|
|
10
|
+
businessId?: string | undefined;
|
|
11
|
+
businessName?: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
export declare function discoverManagedAccounts(config: ResolvedConfig, client?: MetaApiClient): Promise<ManagedAccount[]>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { AppError, ExitCode, MetaApiError } from "../utils/errors.js";
|
|
2
|
+
import { normalizeAdAccountId } from "../utils/ids.js";
|
|
3
|
+
import { MetaApiClient } from "./meta-api-client.js";
|
|
4
|
+
function normalizeAccountNode(node, source, business) {
|
|
5
|
+
const rawId = node.account_id ?? node.id;
|
|
6
|
+
if (!rawId) {
|
|
7
|
+
throw new AppError("Meta account discovery returned an account without an id.", ExitCode.Provider);
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
id: normalizeAdAccountId(rawId),
|
|
11
|
+
name: node.name,
|
|
12
|
+
accountStatus: node.account_status,
|
|
13
|
+
businessId: business?.id,
|
|
14
|
+
businessName: business?.name,
|
|
15
|
+
currency: node.currency,
|
|
16
|
+
source,
|
|
17
|
+
timezoneName: node.timezone_name
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function dedupeAccounts(accounts) {
|
|
21
|
+
const byId = new Map();
|
|
22
|
+
for (const account of accounts) {
|
|
23
|
+
if (!byId.has(account.id)) {
|
|
24
|
+
byId.set(account.id, account);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
28
|
+
}
|
|
29
|
+
async function swallowDiscoveryError(promise) {
|
|
30
|
+
try {
|
|
31
|
+
return await promise;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof MetaApiError) {
|
|
35
|
+
if (error.exitCode === ExitCode.Auth || error.exitCode === ExitCode.RateLimit) {
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function discoverManagedAccounts(config, client = new MetaApiClient(config)) {
|
|
44
|
+
const discovered = [];
|
|
45
|
+
const [meAccounts, meAssigned, businesses] = await Promise.all([
|
|
46
|
+
swallowDiscoveryError(client.paginate("/me/adaccounts", {
|
|
47
|
+
fields: "id,account_id,name,account_status,currency,timezone_name",
|
|
48
|
+
limit: 500
|
|
49
|
+
})),
|
|
50
|
+
swallowDiscoveryError(client.get("/me", {
|
|
51
|
+
fields: "assigned_ad_accounts{id,account_id,name,account_status,currency,timezone_name}"
|
|
52
|
+
})),
|
|
53
|
+
swallowDiscoveryError(client.paginate("/me/businesses", {
|
|
54
|
+
fields: "id,name",
|
|
55
|
+
limit: 200
|
|
56
|
+
}))
|
|
57
|
+
]);
|
|
58
|
+
if (meAccounts) {
|
|
59
|
+
discovered.push(...meAccounts.data.data.map((account) => normalizeAccountNode(account, "/me/adaccounts")));
|
|
60
|
+
}
|
|
61
|
+
if (meAssigned?.data.assigned_ad_accounts?.data) {
|
|
62
|
+
discovered.push(...meAssigned.data.assigned_ad_accounts.data.map((account) => normalizeAccountNode(account, "/me?fields=assigned_ad_accounts")));
|
|
63
|
+
}
|
|
64
|
+
if (businesses) {
|
|
65
|
+
const expandedBusinesses = [];
|
|
66
|
+
for (const business of businesses.data.data) {
|
|
67
|
+
const expanded = await swallowDiscoveryError(client.get(`/${business.id}`, {
|
|
68
|
+
fields: [
|
|
69
|
+
"id",
|
|
70
|
+
"name",
|
|
71
|
+
"owned_ad_accounts.limit(500){id,account_id,name,account_status,currency,timezone_name}",
|
|
72
|
+
"client_ad_accounts.limit(500){id,account_id,name,account_status,currency,timezone_name}"
|
|
73
|
+
].join(",")
|
|
74
|
+
}));
|
|
75
|
+
expandedBusinesses.push(expanded);
|
|
76
|
+
}
|
|
77
|
+
for (const expanded of expandedBusinesses) {
|
|
78
|
+
if (!expanded) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const businessNode = expanded.data;
|
|
82
|
+
const owned = businessNode.owned_ad_accounts?.data ?? [];
|
|
83
|
+
const clientAccounts = businessNode.client_ad_accounts?.data ?? [];
|
|
84
|
+
discovered.push(...owned.map((account) => normalizeAccountNode(account, "business.owned_ad_accounts", businessNode)), ...clientAccounts.map((account) => normalizeAccountNode(account, "business.client_ad_accounts", businessNode)));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return dedupeAccounts(discovered);
|
|
88
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createAction } from "../cli/action.js";
|
|
2
|
+
import { writeFileConfig } from "../config/file-config.js";
|
|
3
|
+
import { MetaAdsService } from "../domain/meta-ads-service.js";
|
|
4
|
+
import { normalizeAdAccountId } from "../utils/ids.js";
|
|
5
|
+
export function registerAccountsCommands(program, deps, state) {
|
|
6
|
+
const accounts = program.command("accounts").description("Managed account discovery and local defaults.");
|
|
7
|
+
accounts
|
|
8
|
+
.command("list")
|
|
9
|
+
.description("List all managed ad accounts accessible to the current token.")
|
|
10
|
+
.action(createAction("accounts list", deps, state, async (context) => {
|
|
11
|
+
const service = new MetaAdsService(context.client);
|
|
12
|
+
const accountsList = await service.listManagedAccounts(context.config);
|
|
13
|
+
return {
|
|
14
|
+
ok: true,
|
|
15
|
+
command: "accounts list",
|
|
16
|
+
data: {
|
|
17
|
+
accounts: accountsList
|
|
18
|
+
},
|
|
19
|
+
meta: {
|
|
20
|
+
count: accountsList.length
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}));
|
|
24
|
+
accounts
|
|
25
|
+
.command("set-default")
|
|
26
|
+
.argument("<ad-account-id>", "Default ad account id.")
|
|
27
|
+
.description("Persist the default ad account id in the local CLI config.")
|
|
28
|
+
.action(createAction("accounts set-default", deps, state, async (context, accountId) => {
|
|
29
|
+
const normalized = normalizeAdAccountId(accountId);
|
|
30
|
+
await writeFileConfig(context.config.configPath, {
|
|
31
|
+
defaultAccountId: normalized
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
command: "accounts set-default",
|
|
36
|
+
data: {
|
|
37
|
+
defaultAccountId: normalized,
|
|
38
|
+
configPath: context.config.configPath
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { requirePermission } from "../auth/guards.js";
|
|
2
|
+
import { createAction } from "../cli/action.js";
|
|
3
|
+
import { analyzeCreativeFatigue } from "../domain/analytics.js";
|
|
4
|
+
import { MetaAdsService } from "../domain/meta-ads-service.js";
|
|
5
|
+
import { parseDateWindow } from "../utils/date-range.js";
|
|
6
|
+
import { adCreateSpecSchema, readSpecFile } from "../validators/create-spec.js";
|
|
7
|
+
import { parseAdId, parseCampaignId } from "../validators/common.js";
|
|
8
|
+
import { resolveSingleAccount, toMetaSort } from "./helpers.js";
|
|
9
|
+
export function registerAdsCommands(program, deps, state) {
|
|
10
|
+
const ads = program.command("ads").description("Ad reads, creation, performance and fatigue analysis.");
|
|
11
|
+
ads
|
|
12
|
+
.command("create")
|
|
13
|
+
.requiredOption("--account <id>", "Ad account id.")
|
|
14
|
+
.requiredOption("--spec <path>", "Path to a JSON ad create spec.")
|
|
15
|
+
.description("Draft or create an ad from a JSON spec.")
|
|
16
|
+
.action(createAction("ads create", deps, state, async (context, options) => {
|
|
17
|
+
const account = await resolveSingleAccount(context, options.account, "ads create");
|
|
18
|
+
const { spec, specPath } = await readSpecFile(options.spec, adCreateSpecSchema, "Ad create");
|
|
19
|
+
const service = new MetaAdsService(context.client);
|
|
20
|
+
const plan = {
|
|
21
|
+
accountId: account.id,
|
|
22
|
+
action: "create-ad",
|
|
23
|
+
spec,
|
|
24
|
+
specPath
|
|
25
|
+
};
|
|
26
|
+
if (!context.apply) {
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
command: "ads create",
|
|
30
|
+
data: plan,
|
|
31
|
+
meta: {
|
|
32
|
+
mode: "draft"
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
requirePermission(context.config, "write", "Ad create");
|
|
37
|
+
const ad = await service.createAd(account.id, spec);
|
|
38
|
+
return {
|
|
39
|
+
ok: true,
|
|
40
|
+
command: "ads create",
|
|
41
|
+
data: {
|
|
42
|
+
accountId: account.id,
|
|
43
|
+
ad,
|
|
44
|
+
applied: true,
|
|
45
|
+
specPath
|
|
46
|
+
},
|
|
47
|
+
meta: {
|
|
48
|
+
mode: "write"
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}));
|
|
52
|
+
ads
|
|
53
|
+
.command("list")
|
|
54
|
+
.requiredOption("--campaign <id>", "Campaign id.")
|
|
55
|
+
.description("List ads under a campaign.")
|
|
56
|
+
.action(createAction("ads list", deps, state, async (context, options) => {
|
|
57
|
+
const service = new MetaAdsService(context.client);
|
|
58
|
+
const campaignId = parseCampaignId(options.campaign);
|
|
59
|
+
const rows = await service.listAdsByCampaign(campaignId);
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
command: "ads list",
|
|
63
|
+
data: {
|
|
64
|
+
ads: rows,
|
|
65
|
+
campaignId
|
|
66
|
+
},
|
|
67
|
+
meta: {
|
|
68
|
+
count: rows.length
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}));
|
|
72
|
+
ads
|
|
73
|
+
.command("performance")
|
|
74
|
+
.requiredOption("--campaign <id>", "Campaign id.")
|
|
75
|
+
.option("--last <window>", "Relative time window like 7d or 30d, unless --from/--to is used.")
|
|
76
|
+
.option("--from <date>", "Start date in YYYY-MM-DD.")
|
|
77
|
+
.option("--to <date>", "End date in YYYY-MM-DD.")
|
|
78
|
+
.option("--sort <metric>", "Sort metric such as ctr or spend.")
|
|
79
|
+
.description("Ad-level performance for a campaign.")
|
|
80
|
+
.action(createAction("ads performance", deps, state, async (context, options) => {
|
|
81
|
+
const service = new MetaAdsService(context.client);
|
|
82
|
+
const campaignId = parseCampaignId(options.campaign);
|
|
83
|
+
const window = parseDateWindow({
|
|
84
|
+
from: options.from,
|
|
85
|
+
last: options.last,
|
|
86
|
+
to: options.to
|
|
87
|
+
});
|
|
88
|
+
const rows = await service.getAdPerformanceByCampaign(campaignId, window, toMetaSort(options.sort));
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
command: "ads performance",
|
|
92
|
+
data: {
|
|
93
|
+
campaignId,
|
|
94
|
+
rows,
|
|
95
|
+
window
|
|
96
|
+
},
|
|
97
|
+
meta: {
|
|
98
|
+
count: rows.length
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}));
|
|
102
|
+
ads
|
|
103
|
+
.command("fatigue")
|
|
104
|
+
.requiredOption("--campaign <id>", "Campaign id.")
|
|
105
|
+
.option("--last <window>", "Relative time window like 30d.", "30d")
|
|
106
|
+
.description("Local creative fatigue analysis on ad-level trends.")
|
|
107
|
+
.action(createAction("ads fatigue", deps, state, async (context, options) => {
|
|
108
|
+
const service = new MetaAdsService(context.client);
|
|
109
|
+
const campaignId = parseCampaignId(options.campaign);
|
|
110
|
+
const window = parseDateWindow({
|
|
111
|
+
last: options.last
|
|
112
|
+
});
|
|
113
|
+
const rows = await service.getDailyAdInsights(campaignId, window);
|
|
114
|
+
const fatigue = analyzeCreativeFatigue(rows);
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
command: "ads fatigue",
|
|
118
|
+
data: {
|
|
119
|
+
campaignId,
|
|
120
|
+
fatigue,
|
|
121
|
+
window
|
|
122
|
+
},
|
|
123
|
+
meta: {
|
|
124
|
+
count: fatigue.length
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}));
|
|
128
|
+
ads
|
|
129
|
+
.command("preview")
|
|
130
|
+
.argument("<ad-id>", "Ad id.")
|
|
131
|
+
.option("--ad-format <format>", "Optional ad_format override for previews.")
|
|
132
|
+
.description("Fetch preview payloads for an ad.")
|
|
133
|
+
.action(createAction("ads preview", deps, state, async (context, adId, options) => {
|
|
134
|
+
const service = new MetaAdsService(context.client);
|
|
135
|
+
const previews = await service.getAdPreview(parseAdId(adId), options.adFormat);
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
command: "ads preview",
|
|
139
|
+
data: {
|
|
140
|
+
adId,
|
|
141
|
+
previews
|
|
142
|
+
},
|
|
143
|
+
meta: {
|
|
144
|
+
count: previews.length
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createAction } from "../cli/action.js";
|
|
2
|
+
import { requirePermission } from "../auth/guards.js";
|
|
3
|
+
import { MetaAdsService } from "../domain/meta-ads-service.js";
|
|
4
|
+
import { adSetCreateSpecSchema, readSpecFile } from "../validators/create-spec.js";
|
|
5
|
+
import { resolveSingleAccount } from "./helpers.js";
|
|
6
|
+
export function registerAdSetCommands(program, deps, state) {
|
|
7
|
+
const adSets = program.command("adsets").description("Ad set creation workflows.");
|
|
8
|
+
adSets
|
|
9
|
+
.command("create")
|
|
10
|
+
.requiredOption("--account <id>", "Ad account id.")
|
|
11
|
+
.requiredOption("--spec <path>", "Path to a JSON ad set create spec.")
|
|
12
|
+
.description("Draft or create an ad set from a JSON spec.")
|
|
13
|
+
.action(createAction("adsets create", deps, state, async (context, options) => {
|
|
14
|
+
const account = await resolveSingleAccount(context, options.account, "adsets create");
|
|
15
|
+
const { spec, specPath } = await readSpecFile(options.spec, adSetCreateSpecSchema, "Ad set create");
|
|
16
|
+
const service = new MetaAdsService(context.client);
|
|
17
|
+
const plan = {
|
|
18
|
+
accountId: account.id,
|
|
19
|
+
action: "create-adset",
|
|
20
|
+
spec,
|
|
21
|
+
specPath
|
|
22
|
+
};
|
|
23
|
+
if (!context.apply) {
|
|
24
|
+
return {
|
|
25
|
+
ok: true,
|
|
26
|
+
command: "adsets create",
|
|
27
|
+
data: plan,
|
|
28
|
+
meta: {
|
|
29
|
+
mode: "draft"
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
requirePermission(context.config, "write", "Ad set create");
|
|
34
|
+
const adSet = await service.createAdSet(account.id, spec);
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
command: "adsets create",
|
|
38
|
+
data: {
|
|
39
|
+
accountId: account.id,
|
|
40
|
+
adSet,
|
|
41
|
+
applied: true,
|
|
42
|
+
specPath
|
|
43
|
+
},
|
|
44
|
+
meta: {
|
|
45
|
+
mode: "write"
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}));
|
|
49
|
+
}
|