storebound/cli/ads/apple.ts

202 lines
13 KiB
TypeScript

// Apple Ads (formerly Apple Search Ads) for the app's App Store campaigns.
// storebound ads apple status [--json] token works?, ad accounts and roles (GET /v1/acls), the one used
// storebound ads apple campaigns [--all] [--json] App Store campaigns: status, daily budget, countries, adamId
// storebound ads apple report [--days 7 | --start YYYY-MM-DD --end YYYY-MM-DD] [--by campaign|keyword] [--campaign <id>]
// [--adgroup <id>] [--all] [--csv <file>] [--dry] [--json]
// spend, impressions, taps, installs, TTR, conversion rate, CPA (spend / installs), avg CPT; totals. Read-only.
// storebound ads apple bid --keyword <id> --bid <amount> [--campaign <id>] [--adgroup <id>] [--dry] [--yes]
// storebound ads apple budget --campaign <id> --daily <amount> [--dry] [--yes]
// storebound ads apple pause --campaign <id> [--adgroup <id>] [--dry] [--yes]
// Writes read the current value first, print "old -> new", and only without --dry ask in the terminal (confirm) before
// the PUT. They are the owner's decisions (money): never offered over MCP, refused under `storebound serve`.
// Report and campaign lists default to the campaigns of config apple.appId (when set); --all shows every app's.
// Credentials and API: cli/ads/client.ts. API: Apple Ads Platform API (https://api.ads.apple.com/v1), which replaces
// the Campaign Management API v5 (sunset January 26, 2027).
// Sources (checked October 2026): the doc URLs in cli/lib/apple-ads.ts, plus
// https://developer.apple.com/documentation/apple-ads-platform-api/post-campaigns-query (pageSize cap 1000)
// https://developer.apple.com/documentation/apple-ads-platform-api/put-campaigns-_id_ (status / dailyBudget examples)
// https://developer.apple.com/documentation/apple-ads-platform-api/put-keywords-_id_ (bid example; bid null is an error)
// https://developer.apple.com/documentation/apple-ads-platform-api/put-adgroups-_id_ (status)
import { writeFileSync } from 'node:fs';
import { type AclRow, aclRows, campaignQuery, campaignRow, type CampaignRow, canWrite, changeLine, dailyBudgetBody, flattenRows, type J, keywordBidBody,
nextOffset, parseAmount, pauseBody, reportBody, reportCsv, type ReportRow, reportTable, reportWindow, totals } from '../lib/apple-ads';
import { confirm, type Ctx } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { textTable } from '../lib/table';
import { adsClient, type Ads } from './client';
const TAG = 'ads apple';
const SUBS = ['status', 'campaigns', 'report', 'bid', 'budget', 'pause'];
const idArg = (ctx: Ctx, name: string, required: boolean): string | undefined => {
const v = ctx.args.get(name);
if (v === undefined || v === 'true') { if (required) throw new Error(`--${name} <id> is required`); return undefined; }
if (!/^\d+$/.test(v)) throw new Error(`--${name}: a numeric id`);
return v;
};
async function allCampaigns(ads: Ads): Promise<J[]> {
const out: J[] = [];
for (let offset: number | null = 0; offset !== null;) {
const page: J = await ads.post('/campaigns/query', campaignQuery(offset));
out.push(...(page.result ?? []));
offset = (page.result ?? []).length ? nextOffset(page) : null;
}
return out;
}
/** The campaigns a report / list covers: one by id, else the app's (config apple.appId) unless --all or none match. */
async function pickCampaigns(ctx: Ctx, ads: Ads, quiet: boolean): Promise<J[]> {
const id = idArg(ctx, 'campaign', false);
if (id) return [(await ads.get(`/campaigns/${id}`)).result];
const all = await allCampaigns(ads);
const appId = ctx.loaded.config.apple?.appId;
if (!appId || ctx.args.has('all')) return all;
const mine = all.filter(c => String(c.promotedObjectId) === String(appId));
if (!mine.length && all.length && !quiet) warn(TAG, `no campaign promotes apple.appId ${appId}: showing all ${all.length}`);
return mine.length ? mine : all;
}
async function status(ctx: Ctx, ads: Ads, json: boolean): Promise<void> {
await ads.token();
const accounts: AclRow[] = aclRows(await ads.get('/acls', false), ads.accountId);
const used = accounts.find(a => a.used) ?? null;
const account = ads.accountId ? (await ads.get(`/ad-accounts/${ads.accountId}`)).result ?? null : null;
const out = {
token: true, accountId: ads.accountId ?? null, accounts,
account: account && { id: String(account.id), name: account.name, currency: account.currency, timezone: account.timezone, systemStatus: account.systemStatus,
systemStatusReasons: account.systemStatusReasons ?? [], productFeatures: account.productFeatures ?? [] },
canWrite: used ? canWrite(used.roles) : false,
};
if (json) return printJson(out);
log(TAG, 'token: ok (OAuth client credentials)');
if (!accounts.length) warn(TAG, 'no ad accounts for this API user (Apple Ads > Account Settings > User Management: give it an API role on an account)');
else console.log(textTable(['', 'ad account', 'id', 'org', 'roles'], accounts.map(a => [a.used ? '*' : '', a.name, a.accountId, a.orgId, a.roles.join(', ')])));
if (!ads.accountId) { log(TAG, 'APPLE_ADS_ACCOUNT_ID is not set: put one of the ids above in the env or .storebound/credentials.json'); return; }
if (!used) warn(TAG, `APPLE_ADS_ACCOUNT_ID ${ads.accountId} is not in the list above: this API user has no role on it`);
if (out.account) {
const a = out.account;
log(TAG, `using ${a.name} (${a.id}): ${a.currency}, ${a.timezone}, ${a.systemStatus}${a.systemStatusReasons.length ? ' ' + a.systemStatusReasons.join(', ') : ''}, ${a.productFeatures.join(', ') || 'no product features'}`);
if (!a.productFeatures.includes('APPSTORE_APP_MANUAL')) warn(TAG, 'this ad account is not an App Store account (APPSTORE_APP_MANUAL)');
}
log(TAG, out.canWrite ? 'role allows bid / budget / pause changes (each one still asks first)' : 'read-only role: reports and lists only');
}
async function campaigns(ctx: Ctx, ads: Ads, json: boolean): Promise<void> {
const appId = ctx.loaded.config.apple?.appId;
const rows: CampaignRow[] = (await pickCampaigns(ctx, ads, json)).map(c => campaignRow(c, appId));
if (json) return printJson({ accountId: ads.accountId, appId: appId ?? null, campaigns: rows });
if (!rows.length) return log(TAG, 'no App Store campaigns in this ad account');
console.log(textTable(['', 'id', 'campaign', 'status', 'display', 'daily budget', 'bidding', 'countries', 'adamId'],
rows.map(r => [r.thisApp ? '*' : '', r.id, r.name, r.status, r.displayStatus, r.dailyBudget ? `${r.dailyBudget} ${r.currency}` : '', r.bidStrategy, r.countries.join(' '), r.adamId])));
if (appId) log(TAG, `* = promotes apple.appId ${appId}`);
}
async function report(ctx: Ctx, ads: Ads, json: boolean): Promise<void> {
const by = ctx.args.str('by', 'campaign');
if (by !== 'campaign' && by !== 'keyword') throw new Error('--by campaign | keyword');
const w = reportWindow({ days: ctx.args.has('days') ? ctx.args.num('days', 7) : undefined, start: ctx.args.get('start'), end: ctx.args.get('end') });
const adGroupId = idArg(ctx, 'adgroup', false);
const camps = await pickCampaigns(ctx, ads, json);
const names = new Map(camps.map(c => [String(c.id), String(c.name ?? '')] as const));
const rows: ReportRow[] = [];
for (const c of camps) {
for (let offset: number | null = 0; offset !== null;) {
const res: J = await ads.post(`/reports/apps/${by === 'campaign' ? 'campaigns' : 'keywords'}/query`, reportBody({ ...w, campaignId: String(c.id), adGroupId: by === 'keyword' ? adGroupId : undefined, offset }));
const got = flattenRows(by, res, names);
rows.push(...got);
offset = got.length ? nextOffset(res) : null;
}
}
rows.sort((a, b) => b.spend - a.spend);
const t = totals(rows);
const csv = ctx.args.get('csv');
if (csv === 'true') throw new Error('--csv <file>');
if (csv) {
const path = ctx.loaded.resolve(csv);
if (ctx.args.has('dry')) { if (!json) log(TAG, `dry run: would write ${rows.length} row(s) to ${path}`); }
else { writeFileSync(path, reportCsv(rows)); if (!json) log(TAG, `wrote ${rows.length} row(s) to ${path}`); }
}
if (json) return printJson({ accountId: ads.accountId, by, start: w.start, end: w.end, timeZone: 'UTC', campaigns: camps.length, rows, totals: t });
log(TAG, `${by} report ${w.start} .. ${w.end} (UTC), ${camps.length} campaign(s), ${rows.length} row(s) with activity`);
if (!rows.length) return log(TAG, 'no spend, impressions or taps in this window');
console.log(reportTable(rows, t));
if (t.installs === 0 && t.spend > 0) log(TAG, 'CPA n/a: no installs in this window');
}
interface Plan { action: 'bid' | 'budget' | 'pause'; target: string; path: string; body: J; old: string | null; new: string; currency: string; applied: boolean }
async function apply(ctx: Ctx, ads: Ads, json: boolean, p: Plan, unchanged: boolean): Promise<void> {
const say = (m: string) => { if (!json) log(TAG, m); };
say(`${p.action}: ${p.target}: ${p.action === 'pause' ? `${p.old} -> ${p.new}` : changeLine(p.old, p.new, p.currency)}`);
if (unchanged) { say('already at that value: nothing to change'); return json ? printJson(p) : undefined; }
if (ctx.args.has('dry')) { say(`dry run: would PUT ${p.path} ${JSON.stringify(p.body)}`); return json ? printJson(p) : undefined; }
if (!(await confirm(ctx, `Apply this ${p.action} change in Apple Ads (${p.target})?`))) { say('not changed'); return json ? printJson(p) : undefined; }
await ads.put(p.path, p.body);
p.applied = true;
if (json) return printJson(p);
done(TAG, `${p.action} applied`);
}
async function bid(ctx: Ctx, ads: Ads, json: boolean): Promise<void> {
const kid = idArg(ctx, 'keyword', true)!;
const amount = parseAmount(ctx.args.get('bid'), '--bid');
const kw = (await ads.get(`/keywords/${kid}`)).result;
if (!kw || kw.deleted) throw new Error(`keyword ${kid} not found or deleted`);
const camp = idArg(ctx, 'campaign', false), group = idArg(ctx, 'adgroup', false);
if (camp && String(kw.campaignId) !== camp) throw new Error(`keyword ${kid} belongs to campaign ${kw.campaignId}, not ${camp}`);
if (group && String(kw.adGroupId) !== group) throw new Error(`keyword ${kid} belongs to ad group ${kw.adGroupId}, not ${group}`);
const c = (await ads.get(`/campaigns/${kw.campaignId}`)).result;
if (c?.bidStrategy?.bidStrategyType === 'MAX_CONVERSIONS') throw new Error(`campaign ${kw.campaignId} uses Maximize Conversions: keyword bids are not used there`);
const currency = String(kw.bid?.currency ?? c?.dailyBudget?.value?.currency ?? '');
if (!currency) throw new Error('could not tell the account currency from the keyword or its campaign');
const old = kw.bid?.amount != null ? String(kw.bid.amount) : null;
await apply(ctx, ads, json, { action: 'bid', target: `keyword "${kw.text}" (${kw.matchType}, ${kid}) in "${c?.name ?? kw.campaignId}"`, path: `/keywords/${kid}`,
body: keywordBidBody(amount, currency), old, new: amount, currency, applied: false }, old !== null && Number(old) === Number(amount));
}
async function budget(ctx: Ctx, ads: Ads, json: boolean): Promise<void> {
const id = idArg(ctx, 'campaign', true)!;
const amount = parseAmount(ctx.args.get('daily'), '--daily');
const c = (await ads.get(`/campaigns/${id}`)).result;
if (!c || c.deleted) throw new Error(`campaign ${id} not found or deleted`);
const currency = String(c.dailyBudget?.value?.currency ?? '');
if (!currency) throw new Error(`campaign ${id} has no daily budget currency`);
const old = c.dailyBudget?.value?.amount != null ? String(c.dailyBudget.value.amount) : null;
await apply(ctx, ads, json, { action: 'budget', target: `daily budget of "${c.name}" (${id})`, path: `/campaigns/${id}`, body: dailyBudgetBody(amount, currency),
old, new: amount, currency, applied: false }, old !== null && Number(old) === Number(amount));
}
async function pause(ctx: Ctx, ads: Ads, json: boolean): Promise<void> {
const id = idArg(ctx, 'campaign', true)!;
const group = idArg(ctx, 'adgroup', false);
const c = (await ads.get(`/campaigns/${id}`)).result;
if (!c || c.deleted) throw new Error(`campaign ${id} not found or deleted`);
let target = `campaign "${c.name}" (${id})`, path = `/campaigns/${id}`, old = String(c.status ?? '');
if (group) {
const g = (await ads.get(`/adgroups/${group}`)).result;
if (!g || g.deleted) throw new Error(`ad group ${group} not found or deleted`);
if (String(g.campaignId) !== id) throw new Error(`ad group ${group} belongs to campaign ${g.campaignId}, not ${id}`);
target = `ad group "${g.name}" (${group}) in "${c.name}"`; path = `/adgroups/${group}`; old = String(g.status ?? '');
}
await apply(ctx, ads, json, { action: 'pause', target, path, body: pauseBody(), old, new: 'PAUSED', currency: '', applied: false }, old === 'PAUSED');
}
export async function run(ctx: Ctx): Promise<void> {
const sub = ctx.args._[0];
const json = ctx.args.has('json');
if (!sub || ctx.args.has('help') || !SUBS.includes(sub)) {
console.log(`usage: storebound ads apple ${SUBS.join(' | ')} (see the apple-ads skill)`);
if (sub && !SUBS.includes(sub) && !ctx.args.has('help')) process.exitCode = 2;
return;
}
const ads = adsClient(ctx.loaded.root);
switch (sub) {
case 'status': return status(ctx, ads, json);
case 'campaigns': return campaigns(ctx, ads, json);
case 'report': return report(ctx, ads, json);
case 'bid': return bid(ctx, ads, json);
case 'budget': return budget(ctx, ads, json);
case 'pause': return pause(ctx, ads, json);
}
}