@aicopycoders/exodus 2026.9.200 → 2026.9.1100-beta.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/README.md +11 -0
- package/assets/docs/CLAUDE.md +2 -1
- package/assets/skills/exodus/SKILL.md +2 -1
- package/assets/skills/exodus-ads/SKILL.md +165 -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
|
@@ -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>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { count, dateOrDash, defaultMetaAdsDeps, errorFor, safeText, truncate, } from "../lib/meta-ads.js";
|
|
2
|
+
export const helpText = `
|
|
3
|
+
exodus comments — read the comments on your own synced Meta ads (read-only)
|
|
4
|
+
|
|
5
|
+
The daily Meta sync stores the comments people left on the brand's ads, on both
|
|
6
|
+
Facebook and Instagram. This reads them back. Commenter names and profile links
|
|
7
|
+
are stripped server-side before they are ever stored — you get the words, not
|
|
8
|
+
the person.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
exodus comments list [--ad <adId>] [--platform x] [--limit n] [--json]
|
|
12
|
+
|
|
13
|
+
Flags:
|
|
14
|
+
--ad <adId> Only this ad's comments (ad ids come from exodus ads list)
|
|
15
|
+
--platform <x> facebook | instagram — one surface only
|
|
16
|
+
--limit <n> How many comments, 1–500 (default 100)
|
|
17
|
+
--json Machine-readable JSON instead of the human list
|
|
18
|
+
|
|
19
|
+
Notes:
|
|
20
|
+
• Newest first.
|
|
21
|
+
• Scopes to your active brand (exodus brand current).
|
|
22
|
+
• Nothing here posts, replies, hides or deletes — reading is all it does.
|
|
23
|
+
• Counts per ad live on \`exodus ads show <adId>\`.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
exodus comments list
|
|
27
|
+
exodus comments list --ad 120210000000000
|
|
28
|
+
exodus comments list --platform instagram --limit 50
|
|
29
|
+
exodus comments list --ad 120210000000000 --json
|
|
30
|
+
`.trim();
|
|
31
|
+
export const PLATFORMS = ["facebook", "instagram"];
|
|
32
|
+
export const VALUE_FLAGS = new Set(["ad", "platform", "limit"]);
|
|
33
|
+
export const MAX_LIMIT = 500;
|
|
34
|
+
export function parseCommentsFlags(flags) {
|
|
35
|
+
const options = {};
|
|
36
|
+
const ad = flags["ad"];
|
|
37
|
+
if (ad !== undefined) {
|
|
38
|
+
if (typeof ad !== "string" || !ad.trim()) {
|
|
39
|
+
return { ok: false, message: "--ad needs an ad id (see: exodus ads list)" };
|
|
40
|
+
}
|
|
41
|
+
options.adId = ad.trim();
|
|
42
|
+
}
|
|
43
|
+
const platform = flags["platform"];
|
|
44
|
+
if (platform !== undefined) {
|
|
45
|
+
if (typeof platform !== "string" ||
|
|
46
|
+
!PLATFORMS.includes(platform.trim().toLowerCase())) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
message: `--platform must be one of: ${PLATFORMS.join(", ")} (got "${String(platform)}")`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
options.platform = platform.trim().toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
const limitRaw = flags["limit"];
|
|
55
|
+
if (limitRaw !== undefined) {
|
|
56
|
+
const n = typeof limitRaw === "string" ? Number(limitRaw) : NaN;
|
|
57
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_LIMIT) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
message: `--limit must be a whole number 1–${MAX_LIMIT} (got "${String(limitRaw)}")`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
options.limit = n;
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, options };
|
|
66
|
+
}
|
|
67
|
+
export function commentsPath(options) {
|
|
68
|
+
const params = new URLSearchParams();
|
|
69
|
+
if (options.platform)
|
|
70
|
+
params.set("platform", options.platform);
|
|
71
|
+
if (options.limit !== undefined)
|
|
72
|
+
params.set("limit", String(options.limit));
|
|
73
|
+
const qs = params.toString();
|
|
74
|
+
const suffix = qs ? `?${qs}` : "";
|
|
75
|
+
return options.adId
|
|
76
|
+
? `/api/v2/ads/${encodeURIComponent(options.adId)}/comments${suffix}`
|
|
77
|
+
: `/api/v2/comments${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
const PLATFORM_TAGS = {
|
|
80
|
+
facebook: "fb",
|
|
81
|
+
instagram: "ig",
|
|
82
|
+
};
|
|
83
|
+
export function platformTag(platform) {
|
|
84
|
+
const clean = safeText(platform).toLowerCase();
|
|
85
|
+
if (!clean)
|
|
86
|
+
return "??";
|
|
87
|
+
return PLATFORM_TAGS[clean] ?? clean.slice(0, 2);
|
|
88
|
+
}
|
|
89
|
+
export function commentLines(comment, opts) {
|
|
90
|
+
const meta = [
|
|
91
|
+
`[${platformTag(comment.platform)}]`,
|
|
92
|
+
dateOrDash(comment.createdAt),
|
|
93
|
+
typeof comment.likeCount === "number" && comment.likeCount > 0
|
|
94
|
+
? `${count(comment.likeCount)} like${comment.likeCount === 1 ? "" : "s"}`
|
|
95
|
+
: null,
|
|
96
|
+
comment.parentId ? "reply" : null,
|
|
97
|
+
opts.showAdId && comment.adId ? `ad ${safeText(comment.adId)}` : null,
|
|
98
|
+
]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(" · ");
|
|
101
|
+
const text = truncate(comment.text, 400) || "(no text — media-only comment)";
|
|
102
|
+
return [` ${meta}`, ` ${text}`];
|
|
103
|
+
}
|
|
104
|
+
export function formatComments(data, options) {
|
|
105
|
+
const comments = Array.isArray(data.comments) ? data.comments : [];
|
|
106
|
+
const scope = options.adId ? `ad ${options.adId}` : "this brand's ads";
|
|
107
|
+
const surface = options.platform ? ` on ${options.platform}` : "";
|
|
108
|
+
if (comments.length === 0) {
|
|
109
|
+
return [
|
|
110
|
+
`No stored comments for ${scope}${surface}.`,
|
|
111
|
+
"The daily Meta sync stores comments as it finds them — an ad with no comments yet, or one synced before comments landed, shows nothing here.",
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
const total = typeof data.count === "number" ? data.count : comments.length;
|
|
115
|
+
const lines = [
|
|
116
|
+
`Comments (${comments.length}${total > comments.length ? ` of ${total}` : ""}) — ${scope}${surface}, newest first`,
|
|
117
|
+
"",
|
|
118
|
+
];
|
|
119
|
+
const showAdId = !options.adId;
|
|
120
|
+
for (const comment of comments) {
|
|
121
|
+
lines.push(...commentLines(comment, { showAdId }));
|
|
122
|
+
}
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push("Commenter names and links are stripped before storage — the words are all Exodus keeps.");
|
|
125
|
+
if (total > comments.length) {
|
|
126
|
+
lines.push(`Raise --limit (max ${MAX_LIMIT}) to see more.`);
|
|
127
|
+
}
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
export async function listFlow(options, json, deps) {
|
|
131
|
+
const res = await deps.get(commentsPath(options));
|
|
132
|
+
if (!res.ok && res.status === 404 && options.adId && !json) {
|
|
133
|
+
const laddered = errorFor(res, "comments list", json, deps, (id) => `exodus comments list --account ${id}`);
|
|
134
|
+
if (laddered && laddered.lines[0]?.startsWith("this server does not support")) {
|
|
135
|
+
return laddered;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
code: 1,
|
|
139
|
+
lines: [
|
|
140
|
+
`No synced ad matches "${options.adId}".`,
|
|
141
|
+
"Ad ids come from `exodus ads list`.",
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const err = errorFor(res, "comments list", json, deps, (id) => `exodus ads list --account ${id}`);
|
|
146
|
+
if (err)
|
|
147
|
+
return err;
|
|
148
|
+
const data = (res.data ?? {});
|
|
149
|
+
if (json)
|
|
150
|
+
return { code: 0, lines: [JSON.stringify(data)] };
|
|
151
|
+
return { code: 0, lines: formatComments(data, options) };
|
|
152
|
+
}
|
|
153
|
+
export function parsePositional(args = process.argv.slice(3)) {
|
|
154
|
+
const out = [];
|
|
155
|
+
let i = 0;
|
|
156
|
+
while (i < args.length) {
|
|
157
|
+
const arg = args[i];
|
|
158
|
+
if (arg.startsWith("--")) {
|
|
159
|
+
const key = arg.slice(2).split("=", 1)[0] ?? "";
|
|
160
|
+
if (!arg.includes("=") && VALUE_FLAGS.has(key))
|
|
161
|
+
i += 2;
|
|
162
|
+
else
|
|
163
|
+
i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
out.push(arg);
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
function printResult(result) {
|
|
172
|
+
for (const line of result.lines)
|
|
173
|
+
console.log(line);
|
|
174
|
+
for (const warning of result.warnings ?? [])
|
|
175
|
+
console.error(warning);
|
|
176
|
+
if (result.code !== 0)
|
|
177
|
+
process.exit(result.code);
|
|
178
|
+
}
|
|
179
|
+
export async function run(flags) {
|
|
180
|
+
const [sub] = parsePositional();
|
|
181
|
+
const json = flags["json"] === true;
|
|
182
|
+
if (!sub || sub === "help") {
|
|
183
|
+
console.log(helpText);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (sub === "list") {
|
|
187
|
+
const parsed = parseCommentsFlags(flags);
|
|
188
|
+
if (!parsed.ok) {
|
|
189
|
+
console.error(`Error: ${parsed.message}`);
|
|
190
|
+
console.log("Usage: exodus comments list [--ad <adId>] [--platform x] [--limit n] [--json]");
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
return printResult(await listFlow(parsed.options, json, defaultMetaAdsDeps));
|
|
194
|
+
}
|
|
195
|
+
console.error(`Unknown subcommand: "${sub}"\n`);
|
|
196
|
+
console.log(helpText);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|