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,174 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { DEFAULT_AUTH_REDIRECT_URI } from "../auth/constants.js";
|
|
6
|
+
import { AppError, ExitCode } from "../utils/errors.js";
|
|
7
|
+
import { normalizeAdAccountId } from "../utils/ids.js";
|
|
8
|
+
import { normalizeAuthRedirectUri, normalizeHttpsUrl } from "../utils/security.js";
|
|
9
|
+
const fileConfigSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
accessToken: z.string().optional(),
|
|
12
|
+
accessTokenExpiresAt: z.string().optional(),
|
|
13
|
+
apiVersion: z.string().optional(),
|
|
14
|
+
approvalWebhook: z.string().optional(),
|
|
15
|
+
appId: z.string().optional(),
|
|
16
|
+
authConfigId: z.string().optional(),
|
|
17
|
+
authRedirectUri: z.string().optional(),
|
|
18
|
+
defaultAccountId: z.string().optional(),
|
|
19
|
+
outputFormat: z.enum(["text", "json"]).optional(),
|
|
20
|
+
permissionMode: z.enum(["read", "write", "admin"]).optional()
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
function defaultConfigDir() {
|
|
24
|
+
return process.env.XDG_CONFIG_HOME
|
|
25
|
+
? path.join(process.env.XDG_CONFIG_HOME, "cli-meta-ads")
|
|
26
|
+
: path.join(os.homedir(), ".config", "cli-meta-ads");
|
|
27
|
+
}
|
|
28
|
+
export function resolveDefaultConfigPath() {
|
|
29
|
+
return path.join(defaultConfigDir(), "config.json");
|
|
30
|
+
}
|
|
31
|
+
function normalizeApiVersion(version) {
|
|
32
|
+
const normalized = version.trim();
|
|
33
|
+
return normalized.startsWith("v") ? normalized : `v${normalized}`;
|
|
34
|
+
}
|
|
35
|
+
function normalizeOptionalString(value) {
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const normalized = value.trim();
|
|
40
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
41
|
+
}
|
|
42
|
+
function normalizeOptionalIsoDate(value, label) {
|
|
43
|
+
const normalized = normalizeOptionalString(value);
|
|
44
|
+
if (!normalized) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const parsed = new Date(normalized);
|
|
48
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
49
|
+
throw new AppError(`${label} must be a valid ISO-8601 timestamp.`, ExitCode.Config);
|
|
50
|
+
}
|
|
51
|
+
return parsed.toISOString();
|
|
52
|
+
}
|
|
53
|
+
async function readFileConfig(configPath) {
|
|
54
|
+
try {
|
|
55
|
+
const content = await readFile(configPath, "utf8");
|
|
56
|
+
const parsed = JSON.parse(content);
|
|
57
|
+
const config = fileConfigSchema.parse(parsed);
|
|
58
|
+
return {
|
|
59
|
+
...config,
|
|
60
|
+
accessToken: normalizeOptionalString(config.accessToken),
|
|
61
|
+
accessTokenExpiresAt: normalizeOptionalIsoDate(config.accessTokenExpiresAt, "accessTokenExpiresAt"),
|
|
62
|
+
approvalWebhook: config.approvalWebhook
|
|
63
|
+
? normalizeHttpsUrl(config.approvalWebhook, "approvalWebhook")
|
|
64
|
+
: undefined,
|
|
65
|
+
appId: normalizeOptionalString(config.appId),
|
|
66
|
+
authConfigId: normalizeOptionalString(config.authConfigId),
|
|
67
|
+
authRedirectUri: config.authRedirectUri
|
|
68
|
+
? normalizeAuthRedirectUri(config.authRedirectUri, "authRedirectUri")
|
|
69
|
+
: undefined,
|
|
70
|
+
defaultAccountId: config.defaultAccountId
|
|
71
|
+
? normalizeAdAccountId(config.defaultAccountId)
|
|
72
|
+
: undefined
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === "ENOENT") {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
if (error instanceof z.ZodError) {
|
|
80
|
+
throw new AppError("Invalid CLI config file.", ExitCode.Config, {
|
|
81
|
+
issues: error.issues
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (error instanceof SyntaxError) {
|
|
85
|
+
throw new AppError("CLI config file is not valid JSON.", ExitCode.Config);
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function detectConfiguredOutputFormat(configPath) {
|
|
91
|
+
try {
|
|
92
|
+
const config = await readFileConfig(configPath ?? resolveDefaultConfigPath());
|
|
93
|
+
return config.outputFormat;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function resolvePermissionMode(value) {
|
|
100
|
+
if (!value) {
|
|
101
|
+
return "read";
|
|
102
|
+
}
|
|
103
|
+
if (value !== "read" && value !== "write" && value !== "admin") {
|
|
104
|
+
throw new AppError(`Invalid META_CLI_MODE: ${value}`, ExitCode.Config);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
function resolveOutputFormat(value) {
|
|
109
|
+
if (!value) {
|
|
110
|
+
return "text";
|
|
111
|
+
}
|
|
112
|
+
if (value !== "text" && value !== "json") {
|
|
113
|
+
throw new AppError(`Invalid output format: ${value}`, ExitCode.Config);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
export async function loadResolvedConfig(overrides = {}) {
|
|
118
|
+
const configPath = overrides.configPath ?? resolveDefaultConfigPath();
|
|
119
|
+
const fileConfig = await readFileConfig(configPath);
|
|
120
|
+
const envAccessToken = normalizeOptionalString(process.env.META_ACCESS_TOKEN);
|
|
121
|
+
const envAppId = normalizeOptionalString(process.env.META_APP_ID);
|
|
122
|
+
const envAppSecret = normalizeOptionalString(process.env.META_APP_SECRET);
|
|
123
|
+
const envAuthConfigId = normalizeOptionalString(process.env.META_AUTH_CONFIG_ID);
|
|
124
|
+
const envAuthRedirectUri = normalizeOptionalString(process.env.META_AUTH_REDIRECT_URI);
|
|
125
|
+
const permissionMode = overrides.permissionMode
|
|
126
|
+
?? (process.env.META_CLI_MODE ? resolvePermissionMode(process.env.META_CLI_MODE) : undefined)
|
|
127
|
+
?? fileConfig.permissionMode
|
|
128
|
+
?? "read";
|
|
129
|
+
const outputFormat = overrides.outputFormat
|
|
130
|
+
?? (process.env.META_OUTPUT_FORMAT ? resolveOutputFormat(process.env.META_OUTPUT_FORMAT) : undefined)
|
|
131
|
+
?? fileConfig.outputFormat
|
|
132
|
+
?? "text";
|
|
133
|
+
const defaultAccountId = process.env.META_DEFAULT_ACCOUNT_ID ?? fileConfig.defaultAccountId;
|
|
134
|
+
const approvalWebhook = process.env.META_APPROVAL_WEBHOOK
|
|
135
|
+
? normalizeHttpsUrl(process.env.META_APPROVAL_WEBHOOK, "META_APPROVAL_WEBHOOK")
|
|
136
|
+
: fileConfig.approvalWebhook;
|
|
137
|
+
const apiVersion = normalizeApiVersion(overrides.apiVersion
|
|
138
|
+
?? process.env.META_API_VERSION
|
|
139
|
+
?? fileConfig.apiVersion
|
|
140
|
+
?? "v25.0");
|
|
141
|
+
const accessToken = envAccessToken ?? fileConfig.accessToken;
|
|
142
|
+
const authRedirectUri = envAuthRedirectUri
|
|
143
|
+
? normalizeAuthRedirectUri(envAuthRedirectUri, "META_AUTH_REDIRECT_URI")
|
|
144
|
+
: fileConfig.authRedirectUri ?? DEFAULT_AUTH_REDIRECT_URI;
|
|
145
|
+
return {
|
|
146
|
+
...fileConfig,
|
|
147
|
+
accessToken,
|
|
148
|
+
accessTokenExpiresAt: envAccessToken ? undefined : fileConfig.accessTokenExpiresAt,
|
|
149
|
+
appId: envAppId ?? fileConfig.appId,
|
|
150
|
+
appSecret: envAppSecret,
|
|
151
|
+
approvalWebhook,
|
|
152
|
+
apiVersion,
|
|
153
|
+
authConfigId: envAuthConfigId ?? fileConfig.authConfigId,
|
|
154
|
+
authRedirectUri,
|
|
155
|
+
configPath,
|
|
156
|
+
debug: overrides.debug ?? false,
|
|
157
|
+
defaultAccountId: defaultAccountId ? normalizeAdAccountId(defaultAccountId) : undefined,
|
|
158
|
+
outputFormat,
|
|
159
|
+
permissionMode
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export async function writeFileConfig(configPath, partial) {
|
|
163
|
+
const existing = await readFileConfig(configPath);
|
|
164
|
+
const next = fileConfigSchema.parse({
|
|
165
|
+
...existing,
|
|
166
|
+
...partial
|
|
167
|
+
});
|
|
168
|
+
await mkdir(path.dirname(configPath), { mode: 0o700, recursive: true });
|
|
169
|
+
await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, {
|
|
170
|
+
encoding: "utf8",
|
|
171
|
+
mode: 0o600
|
|
172
|
+
});
|
|
173
|
+
await chmod(configPath, 0o600).catch(() => undefined);
|
|
174
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { OutputFormat, PermissionMode } from "../types.js";
|
|
2
|
+
export interface FileConfig {
|
|
3
|
+
accessToken?: string | undefined;
|
|
4
|
+
accessTokenExpiresAt?: string | undefined;
|
|
5
|
+
apiVersion?: string | undefined;
|
|
6
|
+
approvalWebhook?: string | undefined;
|
|
7
|
+
appId?: string | undefined;
|
|
8
|
+
authConfigId?: string | undefined;
|
|
9
|
+
authRedirectUri?: string | undefined;
|
|
10
|
+
defaultAccountId?: string | undefined;
|
|
11
|
+
outputFormat?: OutputFormat | undefined;
|
|
12
|
+
permissionMode?: PermissionMode | undefined;
|
|
13
|
+
}
|
|
14
|
+
export interface ConfigOverrides {
|
|
15
|
+
apiVersion?: string | undefined;
|
|
16
|
+
configPath?: string | undefined;
|
|
17
|
+
debug?: boolean | undefined;
|
|
18
|
+
outputFormat?: OutputFormat | undefined;
|
|
19
|
+
permissionMode?: PermissionMode | undefined;
|
|
20
|
+
}
|
|
21
|
+
export interface ResolvedConfig extends FileConfig {
|
|
22
|
+
accessToken?: string | undefined;
|
|
23
|
+
appId?: string | undefined;
|
|
24
|
+
appSecret?: string | undefined;
|
|
25
|
+
authConfigId?: string | undefined;
|
|
26
|
+
authRedirectUri?: string | undefined;
|
|
27
|
+
configPath: string;
|
|
28
|
+
debug: boolean;
|
|
29
|
+
outputFormat: OutputFormat;
|
|
30
|
+
permissionMode: PermissionMode;
|
|
31
|
+
apiVersion: string;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CommandContext } from "../cli/context.js";
|
|
2
|
+
import { type ManagedAccount } from "../client/meta-discovery.js";
|
|
3
|
+
export interface ResolvedAccountScope {
|
|
4
|
+
accounts: ManagedAccount[];
|
|
5
|
+
selection: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function resolveAccountScope(context: CommandContext, selection?: string | undefined): Promise<ResolvedAccountScope>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { discoverManagedAccounts } from "../client/meta-discovery.js";
|
|
2
|
+
import { AppError, ExitCode } from "../utils/errors.js";
|
|
3
|
+
import { normalizeAdAccountId } from "../utils/ids.js";
|
|
4
|
+
export async function resolveAccountScope(context, selection) {
|
|
5
|
+
const requested = selection ?? context.config.defaultAccountId;
|
|
6
|
+
if (!requested) {
|
|
7
|
+
throw new AppError("No account specified. Use --account or configure a default account with `meta accounts set-default`.", ExitCode.Usage);
|
|
8
|
+
}
|
|
9
|
+
if (requested === "all") {
|
|
10
|
+
const accounts = await discoverManagedAccounts(context.config, context.client);
|
|
11
|
+
if (accounts.length === 0) {
|
|
12
|
+
throw new AppError("No managed accounts could be discovered for this token.", ExitCode.VerificationFailed);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
accounts,
|
|
16
|
+
selection: requested
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
accounts: [
|
|
21
|
+
{
|
|
22
|
+
id: normalizeAdAccountId(requested),
|
|
23
|
+
source: "explicit"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
selection: requested
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DateWindow } from "../utils/date-range.js";
|
|
2
|
+
export interface InsightsMetricRow {
|
|
3
|
+
accountId?: string | undefined;
|
|
4
|
+
accountName?: string | undefined;
|
|
5
|
+
adId?: string | undefined;
|
|
6
|
+
adName?: string | undefined;
|
|
7
|
+
campaignId?: string | undefined;
|
|
8
|
+
campaignName?: string | undefined;
|
|
9
|
+
clicks: number;
|
|
10
|
+
cpc: number;
|
|
11
|
+
cpm: number;
|
|
12
|
+
ctr: number;
|
|
13
|
+
dateStart?: string | undefined;
|
|
14
|
+
dateStop?: string | undefined;
|
|
15
|
+
frequency: number;
|
|
16
|
+
impressions: number;
|
|
17
|
+
reach: number;
|
|
18
|
+
spend: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function summarizePerformance(rows: InsightsMetricRow[]): {
|
|
21
|
+
avgCtr: number;
|
|
22
|
+
rows: number;
|
|
23
|
+
totalClicks: number;
|
|
24
|
+
totalImpressions: number;
|
|
25
|
+
totalReach: number;
|
|
26
|
+
totalSpend: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function buildReport(period: string, window: DateWindow, rows: InsightsMetricRow[]): {
|
|
29
|
+
period: string;
|
|
30
|
+
summary: {
|
|
31
|
+
avgCtr: number;
|
|
32
|
+
rows: number;
|
|
33
|
+
totalClicks: number;
|
|
34
|
+
totalImpressions: number;
|
|
35
|
+
totalReach: number;
|
|
36
|
+
totalSpend: number;
|
|
37
|
+
};
|
|
38
|
+
window: DateWindow;
|
|
39
|
+
};
|
|
40
|
+
export declare function detectAnomalies(rows: InsightsMetricRow[], thresholdPercent: number): Record<string, unknown>[];
|
|
41
|
+
export declare function analyzeCreativeFatigue(rows: InsightsMetricRow[]): {
|
|
42
|
+
adId: string;
|
|
43
|
+
adName: string | undefined;
|
|
44
|
+
campaignId: string | undefined;
|
|
45
|
+
campaignName: string | undefined;
|
|
46
|
+
cpcDelta: number | null;
|
|
47
|
+
ctrDelta: number | null;
|
|
48
|
+
fatigueScore: number;
|
|
49
|
+
frequencyDelta: number | null;
|
|
50
|
+
impressionVolume: number;
|
|
51
|
+
status: string;
|
|
52
|
+
}[];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { percentChange } from "../utils/date-range.js";
|
|
2
|
+
export function summarizePerformance(rows) {
|
|
3
|
+
const totals = rows.reduce((accumulator, row) => ({
|
|
4
|
+
clicks: accumulator.clicks + row.clicks,
|
|
5
|
+
impressions: accumulator.impressions + row.impressions,
|
|
6
|
+
reach: accumulator.reach + row.reach,
|
|
7
|
+
spend: accumulator.spend + row.spend
|
|
8
|
+
}), {
|
|
9
|
+
clicks: 0,
|
|
10
|
+
impressions: 0,
|
|
11
|
+
reach: 0,
|
|
12
|
+
spend: 0
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
avgCtr: totals.impressions === 0 ? 0 : (totals.clicks / totals.impressions) * 100,
|
|
16
|
+
rows: rows.length,
|
|
17
|
+
totalClicks: totals.clicks,
|
|
18
|
+
totalImpressions: totals.impressions,
|
|
19
|
+
totalReach: totals.reach,
|
|
20
|
+
totalSpend: totals.spend
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function buildReport(period, window, rows) {
|
|
24
|
+
return {
|
|
25
|
+
period,
|
|
26
|
+
summary: summarizePerformance(rows),
|
|
27
|
+
window
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function detectAnomalies(rows, thresholdPercent) {
|
|
31
|
+
const byCampaign = new Map();
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
const key = row.campaignId ?? "unknown";
|
|
34
|
+
const bucket = byCampaign.get(key) ?? [];
|
|
35
|
+
bucket.push(row);
|
|
36
|
+
byCampaign.set(key, bucket);
|
|
37
|
+
}
|
|
38
|
+
const anomalies = [];
|
|
39
|
+
for (const [campaignId, campaignRows] of byCampaign.entries()) {
|
|
40
|
+
const sorted = [...campaignRows].sort((left, right) => (left.dateStart ?? "").localeCompare(right.dateStart ?? ""));
|
|
41
|
+
if (sorted.length < 2) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const latest = sorted[sorted.length - 1];
|
|
45
|
+
if (!latest) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const previousRows = sorted.slice(0, -1);
|
|
49
|
+
const baseline = summarizePerformance(previousRows);
|
|
50
|
+
const metrics = [
|
|
51
|
+
{
|
|
52
|
+
current: latest.spend,
|
|
53
|
+
name: "spend",
|
|
54
|
+
previous: baseline.totalSpend / previousRows.length
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
current: latest.ctr,
|
|
58
|
+
name: "ctr",
|
|
59
|
+
previous: previousRows.reduce((sum, row) => sum + row.ctr, 0) / previousRows.length
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
current: latest.clicks,
|
|
63
|
+
name: "clicks",
|
|
64
|
+
previous: baseline.totalClicks / previousRows.length
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
for (const metric of metrics) {
|
|
68
|
+
const delta = percentChange(metric.previous, metric.current);
|
|
69
|
+
if (delta !== null && Math.abs(delta) >= thresholdPercent) {
|
|
70
|
+
anomalies.push({
|
|
71
|
+
campaignId,
|
|
72
|
+
campaignName: latest.campaignName,
|
|
73
|
+
current: metric.current,
|
|
74
|
+
date: latest.dateStart,
|
|
75
|
+
metric: metric.name,
|
|
76
|
+
previous: metric.previous,
|
|
77
|
+
percentChange: delta
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return anomalies.sort((left, right) => Math.abs(Number(right.percentChange)) - Math.abs(Number(left.percentChange)));
|
|
83
|
+
}
|
|
84
|
+
export function analyzeCreativeFatigue(rows) {
|
|
85
|
+
const byAd = new Map();
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
const key = row.adId ?? "unknown";
|
|
88
|
+
const bucket = byAd.get(key) ?? [];
|
|
89
|
+
bucket.push(row);
|
|
90
|
+
byAd.set(key, bucket);
|
|
91
|
+
}
|
|
92
|
+
const fatigue = [];
|
|
93
|
+
for (const [adId, adRows] of byAd.entries()) {
|
|
94
|
+
const sorted = [...adRows].sort((left, right) => (left.dateStart ?? "").localeCompare(right.dateStart ?? ""));
|
|
95
|
+
if (sorted.length < 6) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const splitIndex = Math.floor(sorted.length / 2);
|
|
99
|
+
const early = sorted.slice(0, splitIndex);
|
|
100
|
+
const recent = sorted.slice(splitIndex);
|
|
101
|
+
const avg = (entries, key) => entries.reduce((sum, entry) => sum + Number(entry[key] ?? 0), 0) / entries.length;
|
|
102
|
+
const ctrDelta = percentChange(avg(early, "ctr"), avg(recent, "ctr"));
|
|
103
|
+
const frequencyDelta = percentChange(avg(early, "frequency"), avg(recent, "frequency"));
|
|
104
|
+
const cpcDelta = percentChange(avg(early, "cpc"), avg(recent, "cpc"));
|
|
105
|
+
const signals = [
|
|
106
|
+
ctrDelta !== null && ctrDelta <= -15,
|
|
107
|
+
frequencyDelta !== null && frequencyDelta >= 15,
|
|
108
|
+
cpcDelta !== null && cpcDelta >= 15
|
|
109
|
+
];
|
|
110
|
+
const score = Math.round((signals.filter(Boolean).length / signals.length) * 100);
|
|
111
|
+
fatigue.push({
|
|
112
|
+
adId,
|
|
113
|
+
adName: recent[0]?.adName,
|
|
114
|
+
campaignId: recent[0]?.campaignId,
|
|
115
|
+
campaignName: recent[0]?.campaignName,
|
|
116
|
+
cpcDelta,
|
|
117
|
+
ctrDelta,
|
|
118
|
+
fatigueScore: score,
|
|
119
|
+
frequencyDelta,
|
|
120
|
+
impressionVolume: recent.reduce((sum, entry) => sum + entry.impressions, 0),
|
|
121
|
+
status: score >= 66 ? "fatigued" : score >= 33 ? "watch" : "healthy"
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return fatigue.sort((left, right) => Number(right.fatigueScore) - Number(left.fatigueScore));
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommandContext } from "../cli/context.js";
|
|
2
|
+
import type { ApprovalRequestPayload } from "../types.js";
|
|
3
|
+
export interface ApprovalDecision {
|
|
4
|
+
changePercent?: number | null | undefined;
|
|
5
|
+
reason: string;
|
|
6
|
+
required: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function decideCampaignStatusApproval(action: "pause" | "enable"): ApprovalDecision;
|
|
9
|
+
export declare function decideBudgetApproval(changePercent: number | null): ApprovalDecision;
|
|
10
|
+
export declare function submitApprovalRequest(context: CommandContext, payload: ApprovalRequestPayload): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AppError, ExitCode } from "../utils/errors.js";
|
|
2
|
+
import { normalizeHttpsUrl } from "../utils/security.js";
|
|
3
|
+
export function decideCampaignStatusApproval(action) {
|
|
4
|
+
return {
|
|
5
|
+
reason: `Campaign ${action} always requires approval.`,
|
|
6
|
+
required: true
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function decideBudgetApproval(changePercent) {
|
|
10
|
+
if (changePercent === null) {
|
|
11
|
+
return {
|
|
12
|
+
changePercent,
|
|
13
|
+
reason: "Current budget is zero or missing; approval required for safety.",
|
|
14
|
+
required: true
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (Math.abs(changePercent) > 20) {
|
|
18
|
+
return {
|
|
19
|
+
changePercent,
|
|
20
|
+
reason: "Budget change exceeds the 20% approval threshold.",
|
|
21
|
+
required: true
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
changePercent,
|
|
26
|
+
reason: "Budget change is within the direct-write threshold.",
|
|
27
|
+
required: false
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export async function submitApprovalRequest(context, payload) {
|
|
31
|
+
const approvalWebhook = context.config.approvalWebhook
|
|
32
|
+
? normalizeHttpsUrl(context.config.approvalWebhook, "META_APPROVAL_WEBHOOK")
|
|
33
|
+
: undefined;
|
|
34
|
+
if (!approvalWebhook) {
|
|
35
|
+
throw new AppError("META_APPROVAL_WEBHOOK is required for approval-gated writes.", ExitCode.ApprovalRequired);
|
|
36
|
+
}
|
|
37
|
+
const fetchImpl = context.fetchImpl ?? fetch;
|
|
38
|
+
const response = await fetchImpl(approvalWebhook, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": "application/json"
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify(payload)
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new AppError(`Approval webhook request failed with status ${response.status}.`, ExitCode.Provider);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AssetFeedCreativeCreateSpec } from "./meta-ads-service.js";
|
|
2
|
+
import { type LaunchCreativeFormatKey } from "../utils/meta-placement-assets.js";
|
|
3
|
+
export interface CreativeAssetBinding {
|
|
4
|
+
captionIds?: string[] | undefined;
|
|
5
|
+
id: string;
|
|
6
|
+
imageCrops?: Record<string, number[]> | undefined;
|
|
7
|
+
imageHash?: string | undefined;
|
|
8
|
+
imageUrl?: string | undefined;
|
|
9
|
+
kind: "image" | "video";
|
|
10
|
+
videoId?: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
type AssetFeedBuildBase = {
|
|
13
|
+
fallback: CreativeAssetBinding;
|
|
14
|
+
formatBindings: Partial<Record<LaunchCreativeFormatKey, CreativeAssetBinding | undefined>>;
|
|
15
|
+
includeMatchingFormatRules?: boolean | undefined;
|
|
16
|
+
kind: "link-image" | "video-link";
|
|
17
|
+
name?: string | undefined;
|
|
18
|
+
pageId: string;
|
|
19
|
+
placementTargets: Partial<Record<LaunchCreativeFormatKey, string[] | undefined>>;
|
|
20
|
+
};
|
|
21
|
+
type ImageAssetFeedBuildInput = AssetFeedBuildBase & {
|
|
22
|
+
kind: "link-image";
|
|
23
|
+
linkData: {
|
|
24
|
+
callToAction?: string | undefined;
|
|
25
|
+
description?: string | undefined;
|
|
26
|
+
headline?: string | undefined;
|
|
27
|
+
link: string;
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
type VideoAssetFeedBuildInput = AssetFeedBuildBase & {
|
|
32
|
+
kind: "video-link";
|
|
33
|
+
videoData: {
|
|
34
|
+
callToAction?: string | undefined;
|
|
35
|
+
description?: string | undefined;
|
|
36
|
+
link: string;
|
|
37
|
+
message: string;
|
|
38
|
+
title?: string | undefined;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
export type AssetFeedBuildInput = ImageAssetFeedBuildInput | VideoAssetFeedBuildInput;
|
|
42
|
+
export declare function buildAssetFeedCreativeSpec(input: AssetFeedBuildInput): AssetFeedCreativeCreateSpec | undefined;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { buildPlacementCustomizationSpec, launchCreativeFormatKeys } from "../utils/meta-placement-assets.js";
|
|
2
|
+
function sameCreativeAssetBinding(left, right) {
|
|
3
|
+
if (!left || !right || left.kind !== right.kind || left.id !== right.id) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
return JSON.stringify(left.captionIds ?? []) === JSON.stringify(right.captionIds ?? [])
|
|
7
|
+
&& JSON.stringify(left.imageCrops ?? {}) === JSON.stringify(right.imageCrops ?? {});
|
|
8
|
+
}
|
|
9
|
+
function sanitizeAssetLabel(value) {
|
|
10
|
+
return value.replace(/[^a-zA-Z0-9_]+/g, "_");
|
|
11
|
+
}
|
|
12
|
+
function buildAssetFeedMediaLabel(creativeName, slot) {
|
|
13
|
+
return `placement_asset_${sanitizeAssetLabel(creativeName)}_${sanitizeAssetLabel(slot)}`;
|
|
14
|
+
}
|
|
15
|
+
function buildAssetFeedMediaEntry(labelName, binding) {
|
|
16
|
+
return binding.kind === "image"
|
|
17
|
+
? {
|
|
18
|
+
adlabels: [{ name: labelName }],
|
|
19
|
+
...(binding.imageHash ? { hash: binding.imageHash } : {}),
|
|
20
|
+
...(binding.imageUrl ? { url: binding.imageUrl } : {}),
|
|
21
|
+
image_crops: binding.imageCrops
|
|
22
|
+
}
|
|
23
|
+
: {
|
|
24
|
+
adlabels: [{ name: labelName }],
|
|
25
|
+
caption_ids: binding.captionIds,
|
|
26
|
+
video_id: binding.videoId
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function buildAssetFeedCreativeSpec(input) {
|
|
30
|
+
const explicitRules = launchCreativeFormatKeys.flatMap((formatKey) => {
|
|
31
|
+
const binding = input.formatBindings[formatKey];
|
|
32
|
+
const placementIds = input.placementTargets[formatKey] ?? [];
|
|
33
|
+
if (!binding
|
|
34
|
+
|| placementIds.length === 0
|
|
35
|
+
|| (!input.includeMatchingFormatRules
|
|
36
|
+
&& sameCreativeAssetBinding(binding, input.fallback))) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return [{
|
|
40
|
+
binding,
|
|
41
|
+
placementIds,
|
|
42
|
+
slot: formatKey
|
|
43
|
+
}];
|
|
44
|
+
});
|
|
45
|
+
if (explicitRules.length === 0) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const creativeName = input.name ?? input.kind;
|
|
49
|
+
const fallbackLabelName = buildAssetFeedMediaLabel(creativeName, "default");
|
|
50
|
+
const assetCustomizationRules = [
|
|
51
|
+
...explicitRules.map((rule) => {
|
|
52
|
+
const labelName = buildAssetFeedMediaLabel(creativeName, rule.slot);
|
|
53
|
+
return {
|
|
54
|
+
customization_spec: buildPlacementCustomizationSpec(rule.placementIds),
|
|
55
|
+
...(input.kind === "link-image"
|
|
56
|
+
? { image_label: { name: labelName } }
|
|
57
|
+
: { video_label: { name: labelName } })
|
|
58
|
+
};
|
|
59
|
+
}),
|
|
60
|
+
{
|
|
61
|
+
customization_spec: {},
|
|
62
|
+
...(input.kind === "link-image"
|
|
63
|
+
? { image_label: { name: fallbackLabelName } }
|
|
64
|
+
: { video_label: { name: fallbackLabelName } })
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
const assetFeedSpec = {
|
|
68
|
+
ad_formats: [input.kind === "link-image" ? "SINGLE_IMAGE" : "SINGLE_VIDEO"],
|
|
69
|
+
asset_customization_rules: assetCustomizationRules,
|
|
70
|
+
bodies: [{ text: input.kind === "link-image" ? input.linkData.message : input.videoData.message }],
|
|
71
|
+
...(input.kind === "link-image"
|
|
72
|
+
? {
|
|
73
|
+
call_to_action_types: input.linkData.callToAction ? [input.linkData.callToAction] : undefined,
|
|
74
|
+
descriptions: input.linkData.description ? [{ text: input.linkData.description }] : undefined,
|
|
75
|
+
images: [
|
|
76
|
+
...explicitRules
|
|
77
|
+
.filter((rule) => rule.binding.kind === "image")
|
|
78
|
+
.map((rule) => buildAssetFeedMediaEntry(buildAssetFeedMediaLabel(creativeName, rule.slot), rule.binding)),
|
|
79
|
+
buildAssetFeedMediaEntry(fallbackLabelName, input.fallback)
|
|
80
|
+
],
|
|
81
|
+
link_urls: [{ website_url: input.linkData.link }],
|
|
82
|
+
titles: input.linkData.headline ? [{ text: input.linkData.headline }] : undefined
|
|
83
|
+
}
|
|
84
|
+
: {
|
|
85
|
+
call_to_action_types: input.videoData.callToAction ? [input.videoData.callToAction] : undefined,
|
|
86
|
+
descriptions: input.videoData.description ? [{ text: input.videoData.description }] : undefined,
|
|
87
|
+
link_urls: [{ website_url: input.videoData.link }],
|
|
88
|
+
titles: input.videoData.title ? [{ text: input.videoData.title }] : undefined,
|
|
89
|
+
videos: [
|
|
90
|
+
...explicitRules
|
|
91
|
+
.filter((rule) => rule.binding.kind === "video")
|
|
92
|
+
.map((rule) => buildAssetFeedMediaEntry(buildAssetFeedMediaLabel(creativeName, rule.slot), rule.binding)),
|
|
93
|
+
buildAssetFeedMediaEntry(fallbackLabelName, input.fallback)
|
|
94
|
+
]
|
|
95
|
+
}),
|
|
96
|
+
optimization_type: "PLACEMENT"
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
assetFeedSpec,
|
|
100
|
+
kind: input.kind,
|
|
101
|
+
name: input.name,
|
|
102
|
+
pageId: input.pageId
|
|
103
|
+
};
|
|
104
|
+
}
|