@aicopycoders/exodus 2026.9.200 → 2026.9.1100-beta.1
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/README.md +11 -0
- package/assets/docs/CLAUDE.md +3 -1
- package/assets/docs/PIPELINES.md +22 -0
- package/assets/skills/exodus/SKILL.md +3 -1
- package/assets/skills/exodus-ads/SKILL.md +165 -0
- package/assets/skills/exodus-video/SKILL.md +233 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +469 -0
- package/assets/skills/exodus-winners/SKILL.md +65 -17
- package/dist/bin/exodus.js +5 -0
- package/dist/commands/ads.d.ts +50 -0
- package/dist/commands/ads.js +414 -0
- package/dist/commands/comments.d.ts +28 -0
- package/dist/commands/comments.js +198 -0
- package/dist/commands/video.d.ts +213 -0
- package/dist/commands/video.js +1038 -0
- package/dist/commands/winners.d.ts +50 -0
- package/dist/commands/winners.js +175 -2
- package/dist/lib/meta-ads.d.ts +108 -0
- package/dist/lib/meta-ads.js +156 -0
- package/package.json +1 -1
package/dist/bin/exodus.js
CHANGED
|
@@ -23,12 +23,15 @@ const OTHER_COMMANDS = new Set([
|
|
|
23
23
|
"session",
|
|
24
24
|
"bank",
|
|
25
25
|
"hooks",
|
|
26
|
+
"ads",
|
|
27
|
+
"comments",
|
|
26
28
|
]);
|
|
27
29
|
const HIDDEN_COMMANDS = new Set([
|
|
28
30
|
"creative",
|
|
29
31
|
"template",
|
|
30
32
|
"idea",
|
|
31
33
|
"swipe",
|
|
34
|
+
"video",
|
|
32
35
|
]);
|
|
33
36
|
const EXAMPLES = [
|
|
34
37
|
'exodus genesis run --brief "joint pain relief in 30 days" --variants 6',
|
|
@@ -41,6 +44,8 @@ const EXAMPLES = [
|
|
|
41
44
|
'exodus meme run --brief "grounding sheets reduce inflammation" --formats \'[...]\'',
|
|
42
45
|
'exodus browse',
|
|
43
46
|
'exodus hooks list --min-score 10',
|
|
47
|
+
'exodus ads list --sort cost-per-result',
|
|
48
|
+
'exodus comments list --ad <adId>',
|
|
44
49
|
'exodus status --id <runId> --type genesis',
|
|
45
50
|
];
|
|
46
51
|
function installedCommands() {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type AdAccountRef, type AdShowResponse, type AdSummary, type AdWindowStats, type AdsListResponse, type FlowResult, type MetaAdsDeps } from "../lib/meta-ads.js";
|
|
2
|
+
export declare const helpText: string;
|
|
3
|
+
export declare const SORTS: readonly ["cost-per-result", "spend", "ctr"];
|
|
4
|
+
export type AdSort = (typeof SORTS)[number];
|
|
5
|
+
export type AdWindow = "lifetime" | "last90d";
|
|
6
|
+
export declare const VALUE_FLAGS: Set<string>;
|
|
7
|
+
export declare const MAX_LIMIT = 200;
|
|
8
|
+
export interface AdsListOptions {
|
|
9
|
+
sort?: AdSort;
|
|
10
|
+
window?: AdWindow;
|
|
11
|
+
account?: string;
|
|
12
|
+
limit?: number;
|
|
13
|
+
}
|
|
14
|
+
export type ParseResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
options: AdsListOptions;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function parseSince(raw: unknown): {
|
|
22
|
+
ok: true;
|
|
23
|
+
window?: AdWindow;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function parseAdsListFlags(flags: Record<string, string | boolean>): ParseResult;
|
|
29
|
+
export declare function adsListQuery(options: AdsListOptions): string;
|
|
30
|
+
export declare function winnerBadge(status: unknown): string;
|
|
31
|
+
export declare function costPerResultCell(ad: AdWindowStats): string;
|
|
32
|
+
export declare function spendCell(ad: AdWindowStats): string;
|
|
33
|
+
export declare function ctrCell(ad: AdWindowStats): string;
|
|
34
|
+
export declare function cpcCell(ad: AdWindowStats): string;
|
|
35
|
+
export declare function resultsCell(ad: AdWindowStats): string;
|
|
36
|
+
export declare const AD_TABLE_HEADERS: readonly ["cost/result", "spend", "ctr", "cpc", "results", "status", "winner", "name"];
|
|
37
|
+
export declare function adRow(ad: AdSummary, accountCell?: string): string[];
|
|
38
|
+
export declare function accountCellFor(accounts: AdAccountRef[], accountId: string | null | undefined): string;
|
|
39
|
+
export declare function adTableHeaders(withAccount?: boolean): string[];
|
|
40
|
+
export declare function formatAdsTable(ads: AdSummary[], opts?: {
|
|
41
|
+
accounts?: AdAccountRef[];
|
|
42
|
+
showAccount?: boolean;
|
|
43
|
+
}): string;
|
|
44
|
+
export declare function accountLabel(accounts: AdAccountRef[], accountId: string | null | undefined): string;
|
|
45
|
+
export declare function formatAdsList(data: AdsListResponse, options: AdsListOptions): string[];
|
|
46
|
+
export declare function formatAdShow(ad: AdShowResponse, apiBase: string): string[];
|
|
47
|
+
export declare function listFlow(options: AdsListOptions, json: boolean, deps: MetaAdsDeps): Promise<FlowResult>;
|
|
48
|
+
export declare function showFlow(adId: string, json: boolean, deps: MetaAdsDeps): Promise<FlowResult>;
|
|
49
|
+
export declare function parsePositional(args?: string[]): string[];
|
|
50
|
+
export declare function run(flags: Record<string, string | boolean>): Promise<void>;
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { accountLine, count, dateOrDash, defaultMetaAdsDeps, displayOr, errorFor, mediaUrl, money, percent, safeText, safeTextOr, table, truncate, } from "../lib/meta-ads.js";
|
|
2
|
+
export const helpText = `
|
|
3
|
+
exodus ads — read your own connected Meta ads (read-only)
|
|
4
|
+
|
|
5
|
+
Once a brand's Meta ad account is connected on the dashboard, Exodus syncs its
|
|
6
|
+
ads — the copy, the creative, the numbers and the comments — once a day. This
|
|
7
|
+
family READS that stored copy. Nothing here calls Meta, changes an ad, or
|
|
8
|
+
spends a cent, and no Meta Ads MCP is needed to use it.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
exodus ads list [--sort x] [--since 90d] [--account act_…] [--limit n] [--json]
|
|
12
|
+
exodus ads show <adId> [--json]
|
|
13
|
+
|
|
14
|
+
List flags:
|
|
15
|
+
--sort <x> cost-per-result (default, cheapest first) | spend | ctr
|
|
16
|
+
--since 90d The last 90 days instead of the ad's lifetime. 90d is the
|
|
17
|
+
only value — lifetime (the default) and the last 90 days are
|
|
18
|
+
the two windows the daily sync keeps.
|
|
19
|
+
--account <id> Narrow to one connected ad account, e.g. act_1234567890.
|
|
20
|
+
Leave it off and you get every connected account's ads in
|
|
21
|
+
one list, with an "account" column saying which is which.
|
|
22
|
+
--limit <n> How many ads to show, 1–200 (default 50)
|
|
23
|
+
--json Machine-readable JSON instead of the table
|
|
24
|
+
|
|
25
|
+
Reading the table:
|
|
26
|
+
cost/result is the ranking column — the account's own result type (purchases,
|
|
27
|
+
leads, appointments) priced per unit. Ads with no result yet sort last.
|
|
28
|
+
"winner" marks an ad the brand has designated: confirmed (a human said so) or
|
|
29
|
+
proposed (the rule put it forward, nobody has agreed yet).
|
|
30
|
+
The "account" column only appears when the list spans several ad accounts —
|
|
31
|
+
their currencies and result types differ, so don't compare across it blind.
|
|
32
|
+
|
|
33
|
+
Notes:
|
|
34
|
+
• Scopes to your active brand (exodus brand current).
|
|
35
|
+
• Numbers are as of the last daily sync — \`show\` prints the sync stamp.
|
|
36
|
+
• Media links (image / video / poster) are fetchable URLs that redirect to a
|
|
37
|
+
fresh file each time; they are not permanent links to save.
|
|
38
|
+
• What this brand MEANS by a winner: exodus winners definition
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
exodus ads list
|
|
42
|
+
exodus ads list --sort spend --limit 20
|
|
43
|
+
exodus ads list --since 90d --sort ctr
|
|
44
|
+
exodus ads list --account act_1234567890 --json
|
|
45
|
+
exodus ads show 120210000000000
|
|
46
|
+
`.trim();
|
|
47
|
+
export const SORTS = ["cost-per-result", "spend", "ctr"];
|
|
48
|
+
export const VALUE_FLAGS = new Set(["sort", "since", "account", "limit"]);
|
|
49
|
+
export const MAX_LIMIT = 200;
|
|
50
|
+
export function parseSince(raw) {
|
|
51
|
+
if (raw === undefined)
|
|
52
|
+
return { ok: true };
|
|
53
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
message: "--since needs a value, and 90d is the only one: the daily sync keeps the ad's lifetime (the default) and the last 90 days, nothing else.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const value = raw.trim().toLowerCase();
|
|
60
|
+
if (value === "90d")
|
|
61
|
+
return { ok: true, window: "last90d" };
|
|
62
|
+
if (value === "lifetime" || value === "all")
|
|
63
|
+
return { ok: true, window: "lifetime" };
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
message: `--since only accepts 90d (got "${raw.trim()}") — the daily sync keeps two windows per ad: the ad's lifetime, which is the default, and the last 90 days. There is no other range to ask for.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function parseAdsListFlags(flags) {
|
|
70
|
+
const options = {};
|
|
71
|
+
const sortRaw = flags["sort"];
|
|
72
|
+
if (sortRaw !== undefined) {
|
|
73
|
+
if (typeof sortRaw !== "string" || !SORTS.includes(sortRaw.trim())) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
message: `--sort must be one of: ${SORTS.join(", ")} (got "${String(sortRaw)}")`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
options.sort = sortRaw.trim();
|
|
80
|
+
}
|
|
81
|
+
const since = parseSince(flags["since"]);
|
|
82
|
+
if (!since.ok)
|
|
83
|
+
return { ok: false, message: since.message };
|
|
84
|
+
if (since.window)
|
|
85
|
+
options.window = since.window;
|
|
86
|
+
const account = flags["account"];
|
|
87
|
+
if (account !== undefined) {
|
|
88
|
+
if (typeof account !== "string" || !account.trim()) {
|
|
89
|
+
return { ok: false, message: "--account needs an ad account id, e.g. act_1234567890" };
|
|
90
|
+
}
|
|
91
|
+
options.account = account.trim();
|
|
92
|
+
}
|
|
93
|
+
const limitRaw = flags["limit"];
|
|
94
|
+
if (limitRaw !== undefined) {
|
|
95
|
+
const n = typeof limitRaw === "string" ? Number(limitRaw) : NaN;
|
|
96
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_LIMIT) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
message: `--limit must be a whole number 1–${MAX_LIMIT} (got "${String(limitRaw)}")`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
options.limit = n;
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, options };
|
|
105
|
+
}
|
|
106
|
+
export function adsListQuery(options) {
|
|
107
|
+
const params = new URLSearchParams();
|
|
108
|
+
if (options.sort)
|
|
109
|
+
params.set("sort", options.sort);
|
|
110
|
+
if (options.window)
|
|
111
|
+
params.set("window", options.window);
|
|
112
|
+
if (options.account)
|
|
113
|
+
params.set("account", options.account);
|
|
114
|
+
if (options.limit !== undefined)
|
|
115
|
+
params.set("limit", String(options.limit));
|
|
116
|
+
const qs = params.toString();
|
|
117
|
+
return qs ? `?${qs}` : "";
|
|
118
|
+
}
|
|
119
|
+
const SORT_LABELS = {
|
|
120
|
+
"cost-per-result": "cost per result (cheapest first)",
|
|
121
|
+
spend: "spend (highest first)",
|
|
122
|
+
ctr: "click-through rate (highest first)",
|
|
123
|
+
};
|
|
124
|
+
const WINDOW_LABELS = {
|
|
125
|
+
lifetime: "lifetime",
|
|
126
|
+
last90d: "last 90 days",
|
|
127
|
+
};
|
|
128
|
+
export function winnerBadge(status) {
|
|
129
|
+
const clean = safeText(status);
|
|
130
|
+
if (!clean)
|
|
131
|
+
return "—";
|
|
132
|
+
const value = clean.toLowerCase();
|
|
133
|
+
if (value === "confirmed")
|
|
134
|
+
return "✓ winner";
|
|
135
|
+
if (value === "proposed")
|
|
136
|
+
return "· proposed";
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
export function costPerResultCell(ad) {
|
|
140
|
+
return displayOr(ad.costPerResultDisplay, ad.costPerResult, (n) => money(n, ad.currency));
|
|
141
|
+
}
|
|
142
|
+
export function spendCell(ad) {
|
|
143
|
+
return displayOr(ad.spendDisplay, ad.spend, (n) => money(n, ad.currency));
|
|
144
|
+
}
|
|
145
|
+
export function ctrCell(ad) {
|
|
146
|
+
return displayOr(ad.ctrDisplay, ad.ctr, percent);
|
|
147
|
+
}
|
|
148
|
+
export function cpcCell(ad) {
|
|
149
|
+
return displayOr(ad.cpcDisplay, ad.cpc, (n) => money(n, ad.currency));
|
|
150
|
+
}
|
|
151
|
+
export function resultsCell(ad) {
|
|
152
|
+
const n = count(ad.results);
|
|
153
|
+
const label = safeText(ad.resultType);
|
|
154
|
+
if (n === "—")
|
|
155
|
+
return label ? `— ${label}` : "—";
|
|
156
|
+
return label ? `${n} ${label}` : n;
|
|
157
|
+
}
|
|
158
|
+
const NAME_WIDTH = 38;
|
|
159
|
+
export const AD_TABLE_HEADERS = [
|
|
160
|
+
"cost/result",
|
|
161
|
+
"spend",
|
|
162
|
+
"ctr",
|
|
163
|
+
"cpc",
|
|
164
|
+
"results",
|
|
165
|
+
"status",
|
|
166
|
+
"winner",
|
|
167
|
+
"name",
|
|
168
|
+
];
|
|
169
|
+
const ACCOUNT_WIDTH = 24;
|
|
170
|
+
export function adRow(ad, accountCell) {
|
|
171
|
+
const row = [
|
|
172
|
+
costPerResultCell(ad),
|
|
173
|
+
spendCell(ad),
|
|
174
|
+
ctrCell(ad),
|
|
175
|
+
cpcCell(ad),
|
|
176
|
+
resultsCell(ad),
|
|
177
|
+
safeTextOr(ad.status, "—"),
|
|
178
|
+
winnerBadge(ad.winnerStatus),
|
|
179
|
+
truncate(ad.name, NAME_WIDTH) || safeTextOr(ad.adId, "—"),
|
|
180
|
+
];
|
|
181
|
+
if (accountCell !== undefined)
|
|
182
|
+
row.push(accountCell);
|
|
183
|
+
return row;
|
|
184
|
+
}
|
|
185
|
+
export function accountCellFor(accounts, accountId) {
|
|
186
|
+
const match = accounts.find((a) => a.accountId === accountId);
|
|
187
|
+
const name = truncate(match?.name, ACCOUNT_WIDTH);
|
|
188
|
+
return name || safeTextOr(accountId, "—");
|
|
189
|
+
}
|
|
190
|
+
export function adTableHeaders(withAccount = false) {
|
|
191
|
+
return withAccount ? [...AD_TABLE_HEADERS, "account"] : [...AD_TABLE_HEADERS];
|
|
192
|
+
}
|
|
193
|
+
export function formatAdsTable(ads, opts = {}) {
|
|
194
|
+
const accounts = opts.accounts ?? [];
|
|
195
|
+
const showAccount = opts.showAccount === true;
|
|
196
|
+
return table(adTableHeaders(showAccount), ads.map((ad) => adRow(ad, showAccount ? accountCellFor(accounts, ad.accountId) : undefined)));
|
|
197
|
+
}
|
|
198
|
+
export function accountLabel(accounts, accountId) {
|
|
199
|
+
if (!accountId)
|
|
200
|
+
return accounts.length === 1 ? accountLabel(accounts, accounts[0]?.accountId) : "";
|
|
201
|
+
const match = accounts.find((a) => a.accountId === accountId);
|
|
202
|
+
const name = safeText(match?.name);
|
|
203
|
+
const id = safeText(accountId);
|
|
204
|
+
return name ? `${name} (${id})` : id;
|
|
205
|
+
}
|
|
206
|
+
export function formatAdsList(data, options) {
|
|
207
|
+
const ads = Array.isArray(data.ads) ? data.ads : [];
|
|
208
|
+
const accounts = Array.isArray(data.accounts) ? data.accounts : [];
|
|
209
|
+
const windowLabel = WINDOW_LABELS[data.window ?? options.window ?? "lifetime"] ?? "lifetime";
|
|
210
|
+
const sortLabel = SORT_LABELS[data.sort ?? options.sort ?? "cost-per-result"] ?? "cost per result";
|
|
211
|
+
const mixed = accounts.length > 1 && !options.account;
|
|
212
|
+
const account = mixed
|
|
213
|
+
? ""
|
|
214
|
+
: accountLabel(accounts, options.account ?? ads[0]?.accountId ?? null);
|
|
215
|
+
if (ads.length === 0) {
|
|
216
|
+
const lines = [
|
|
217
|
+
account
|
|
218
|
+
? `No synced ads for ${account} in the ${windowLabel} window.`
|
|
219
|
+
: `No synced ads for this brand yet.`,
|
|
220
|
+
"The daily sync fills this in once the ad account is connected on the dashboard (Settings → Meta).",
|
|
221
|
+
];
|
|
222
|
+
if (accounts.length > 1) {
|
|
223
|
+
lines.push("");
|
|
224
|
+
lines.push("Connected ad accounts:");
|
|
225
|
+
for (const a of accounts)
|
|
226
|
+
lines.push(accountLine(a));
|
|
227
|
+
}
|
|
228
|
+
return lines;
|
|
229
|
+
}
|
|
230
|
+
const scope = mixed ? `All ${accounts.length} connected accounts` : account || null;
|
|
231
|
+
const header = [`Ads (${ads.length})`, scope, `${windowLabel} · sorted by ${sortLabel}`]
|
|
232
|
+
.filter(Boolean)
|
|
233
|
+
.join(" — ");
|
|
234
|
+
const lines = [header];
|
|
235
|
+
if (mixed) {
|
|
236
|
+
lines.push("");
|
|
237
|
+
for (const a of accounts)
|
|
238
|
+
lines.push(accountLine(a));
|
|
239
|
+
}
|
|
240
|
+
lines.push("", formatAdsTable(ads, { accounts, showAccount: mixed }), "");
|
|
241
|
+
const total = typeof data.count === "number" ? data.count : ads.length;
|
|
242
|
+
if (total > ads.length) {
|
|
243
|
+
lines.push(`Showing ${ads.length} of ${total}. Raise --limit (max ${MAX_LIMIT}) for more.`);
|
|
244
|
+
}
|
|
245
|
+
lines.push("Open one: exodus ads show <adId>");
|
|
246
|
+
if (mixed) {
|
|
247
|
+
const first = accounts.find((a) => !!a.accountId)?.accountId;
|
|
248
|
+
if (first)
|
|
249
|
+
lines.push(`One account at a time: exodus ads list --account ${safeText(first)}`);
|
|
250
|
+
}
|
|
251
|
+
else if (accounts.length > 1) {
|
|
252
|
+
const others = accounts
|
|
253
|
+
.map((a) => safeText(a.accountId))
|
|
254
|
+
.filter((id) => !!id && id !== safeText(options.account ?? ads[0]?.accountId));
|
|
255
|
+
if (others.length > 0) {
|
|
256
|
+
lines.push(`Other connected accounts: ${others.join(", ")} (--account)`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
function windowBlock(label, stats) {
|
|
262
|
+
if (!stats)
|
|
263
|
+
return [`${label}`, " (no numbers recorded for this window)"];
|
|
264
|
+
return [
|
|
265
|
+
label,
|
|
266
|
+
` results: ${resultsCell(stats)}`,
|
|
267
|
+
` cost/result: ${costPerResultCell(stats)}`,
|
|
268
|
+
` spend: ${spendCell(stats)}`,
|
|
269
|
+
` ctr: ${ctrCell(stats)}`,
|
|
270
|
+
` cpc: ${cpcCell(stats)}`,
|
|
271
|
+
` impressions: ${count(stats.impressions)} · clicks ${count(stats.clicks)}`,
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
function copyBlock(label, text) {
|
|
275
|
+
const clean = safeText(text);
|
|
276
|
+
if (!clean)
|
|
277
|
+
return [`${label}`, " (none on the ad)"];
|
|
278
|
+
return [label, ...clean.split("\n").map((line) => ` ${safeText(line)}`)];
|
|
279
|
+
}
|
|
280
|
+
export function formatAdShow(ad, apiBase) {
|
|
281
|
+
const lines = [];
|
|
282
|
+
lines.push(`${safeTextOr(ad.name, "(unnamed ad)")} — ${safeTextOr(ad.adId, "(no id)")}`);
|
|
283
|
+
lines.push(`status ${safeTextOr(ad.status, "—")} · format ${safeTextOr(ad.format, "—")} · winner ${winnerBadge(ad.winnerStatus)}`);
|
|
284
|
+
lines.push(`account ${safeTextOr(ad.accountId, "—")} · created ${dateOrDash(ad.createdTime)}`);
|
|
285
|
+
const objective = safeText(ad.campaign?.objective);
|
|
286
|
+
const goal = safeText(ad.adset?.optimizationGoal);
|
|
287
|
+
lines.push(`campaign: ${safeTextOr(ad.campaign?.name, "—")}${objective ? ` (${objective})` : ""}`);
|
|
288
|
+
lines.push(`ad set: ${safeTextOr(ad.adset?.name, "—")}${goal ? ` (${goal})` : ""}`);
|
|
289
|
+
lines.push("");
|
|
290
|
+
lines.push(...copyBlock("Headline", ad.headline));
|
|
291
|
+
lines.push("");
|
|
292
|
+
lines.push(...copyBlock("Body copy", ad.bodyText));
|
|
293
|
+
lines.push("");
|
|
294
|
+
lines.push(...windowBlock("Lifetime", ad.lifetime ?? ad));
|
|
295
|
+
lines.push("");
|
|
296
|
+
lines.push(...windowBlock("Last 90 days", ad.last90d));
|
|
297
|
+
lines.push("");
|
|
298
|
+
lines.push("Media");
|
|
299
|
+
const media = ad.media ?? {};
|
|
300
|
+
const image = mediaUrl(apiBase, media.image);
|
|
301
|
+
const video = mediaUrl(apiBase, media.video);
|
|
302
|
+
const poster = mediaUrl(apiBase, media.poster);
|
|
303
|
+
if (!image && !video && !poster) {
|
|
304
|
+
lines.push(" (no creative file stored for this ad)");
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
if (image)
|
|
308
|
+
lines.push(` image: ${image}`);
|
|
309
|
+
if (video)
|
|
310
|
+
lines.push(` video: ${video}`);
|
|
311
|
+
if (poster)
|
|
312
|
+
lines.push(` poster: ${poster}`);
|
|
313
|
+
lines.push(" Each link redirects to a fresh file — fetch it now, don't save the URL.");
|
|
314
|
+
}
|
|
315
|
+
lines.push("");
|
|
316
|
+
const fb = ad.commentCounts?.facebook ?? 0;
|
|
317
|
+
const ig = ad.commentCounts?.instagram ?? 0;
|
|
318
|
+
lines.push(`Comments — ${count(fb)} on Facebook · ${count(ig)} on Instagram`);
|
|
319
|
+
if ((typeof fb === "number" ? fb : 0) + (typeof ig === "number" ? ig : 0) > 0) {
|
|
320
|
+
lines.push(` Read them: exodus comments list --ad ${ad.adId ?? "<adId>"}`);
|
|
321
|
+
}
|
|
322
|
+
lines.push("");
|
|
323
|
+
lines.push(`Synced ${dateOrDash(ad.syncedAt)} — every number above is as of that daily sync.`);
|
|
324
|
+
return lines;
|
|
325
|
+
}
|
|
326
|
+
const LIST_PATH = "/api/v2/ads";
|
|
327
|
+
export async function listFlow(options, json, deps) {
|
|
328
|
+
const res = await deps.get(`${LIST_PATH}${adsListQuery(options)}`);
|
|
329
|
+
const err = errorFor(res, "ads list", json, deps, (id) => `exodus ads list --account ${id}`);
|
|
330
|
+
if (err)
|
|
331
|
+
return err;
|
|
332
|
+
const data = (res.data ?? {});
|
|
333
|
+
if (json)
|
|
334
|
+
return { code: 0, lines: [JSON.stringify(data)] };
|
|
335
|
+
return { code: 0, lines: formatAdsList(data, options) };
|
|
336
|
+
}
|
|
337
|
+
export async function showFlow(adId, json, deps) {
|
|
338
|
+
const res = await deps.get(`${LIST_PATH}/${encodeURIComponent(adId)}`);
|
|
339
|
+
if (!res.ok && res.status === 404 && !json) {
|
|
340
|
+
const unsupported = errorFor(res, "ads show", json, deps, (id) => `exodus ads list --account ${id}`);
|
|
341
|
+
if (unsupported && unsupported.lines[0]?.startsWith("this server does not support")) {
|
|
342
|
+
return unsupported;
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
code: 1,
|
|
346
|
+
lines: [
|
|
347
|
+
`No synced ad matches "${adId}".`,
|
|
348
|
+
"Ad ids come from `exodus ads list` — the sync only stores ads from connected accounts.",
|
|
349
|
+
],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const err = errorFor(res, "ads show", json, deps, (id) => `exodus ads list --account ${id}`);
|
|
353
|
+
if (err)
|
|
354
|
+
return err;
|
|
355
|
+
const ad = (res.data ?? {});
|
|
356
|
+
if (json)
|
|
357
|
+
return { code: 0, lines: [JSON.stringify(ad)] };
|
|
358
|
+
return { code: 0, lines: formatAdShow(ad, deps.apiUrl()) };
|
|
359
|
+
}
|
|
360
|
+
export function parsePositional(args = process.argv.slice(3)) {
|
|
361
|
+
const out = [];
|
|
362
|
+
let i = 0;
|
|
363
|
+
while (i < args.length) {
|
|
364
|
+
const arg = args[i];
|
|
365
|
+
if (arg.startsWith("--")) {
|
|
366
|
+
const key = arg.slice(2).split("=", 1)[0] ?? "";
|
|
367
|
+
if (!arg.includes("=") && VALUE_FLAGS.has(key))
|
|
368
|
+
i += 2;
|
|
369
|
+
else
|
|
370
|
+
i++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
out.push(arg);
|
|
374
|
+
i++;
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
function printResult(result) {
|
|
379
|
+
for (const line of result.lines)
|
|
380
|
+
console.log(line);
|
|
381
|
+
for (const warning of result.warnings ?? [])
|
|
382
|
+
console.error(warning);
|
|
383
|
+
if (result.code !== 0)
|
|
384
|
+
process.exit(result.code);
|
|
385
|
+
}
|
|
386
|
+
function usageError(message, usage) {
|
|
387
|
+
console.error(`Error: ${message}`);
|
|
388
|
+
console.log(`Usage: ${usage}`);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
export async function run(flags) {
|
|
392
|
+
const [sub, ...rest] = parsePositional();
|
|
393
|
+
const json = flags["json"] === true;
|
|
394
|
+
if (!sub || sub === "help") {
|
|
395
|
+
console.log(helpText);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (sub === "list") {
|
|
399
|
+
const parsed = parseAdsListFlags(flags);
|
|
400
|
+
if (!parsed.ok) {
|
|
401
|
+
usageError(parsed.message, "exodus ads list [--sort x] [--since 90d] [--account act_…] [--limit n] [--json]");
|
|
402
|
+
}
|
|
403
|
+
return printResult(await listFlow(parsed.options, json, defaultMetaAdsDeps));
|
|
404
|
+
}
|
|
405
|
+
if (sub === "show") {
|
|
406
|
+
const adId = rest[0];
|
|
407
|
+
if (!adId)
|
|
408
|
+
usageError("ads show needs an ad id.", "exodus ads show <adId> [--json]");
|
|
409
|
+
return printResult(await showFlow(adId, json, defaultMetaAdsDeps));
|
|
410
|
+
}
|
|
411
|
+
console.error(`Unknown subcommand: "${sub}"\n`);
|
|
412
|
+
console.log(helpText);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type AdComment, type CommentsResponse, type FlowResult, type MetaAdsDeps } from "../lib/meta-ads.js";
|
|
2
|
+
export declare const helpText: string;
|
|
3
|
+
export declare const PLATFORMS: readonly ["facebook", "instagram"];
|
|
4
|
+
export type Platform = (typeof PLATFORMS)[number];
|
|
5
|
+
export declare const VALUE_FLAGS: Set<string>;
|
|
6
|
+
export declare const MAX_LIMIT = 500;
|
|
7
|
+
export interface CommentsListOptions {
|
|
8
|
+
adId?: string;
|
|
9
|
+
platform?: Platform;
|
|
10
|
+
limit?: number;
|
|
11
|
+
}
|
|
12
|
+
export type ParseResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
options: CommentsListOptions;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function parseCommentsFlags(flags: Record<string, string | boolean>): ParseResult;
|
|
20
|
+
export declare function commentsPath(options: CommentsListOptions): string;
|
|
21
|
+
export declare function platformTag(platform: unknown): string;
|
|
22
|
+
export declare function commentLines(comment: AdComment, opts: {
|
|
23
|
+
showAdId: boolean;
|
|
24
|
+
}): string[];
|
|
25
|
+
export declare function formatComments(data: CommentsResponse, options: CommentsListOptions): string[];
|
|
26
|
+
export declare function listFlow(options: CommentsListOptions, json: boolean, deps: MetaAdsDeps): Promise<FlowResult>;
|
|
27
|
+
export declare function parsePositional(args?: string[]): string[];
|
|
28
|
+
export declare function run(flags: Record<string, string | boolean>): Promise<void>;
|