251 lines
16 KiB
TypeScript
251 lines
16 KiB
TypeScript
// Apple Ads (formerly Apple Search Ads), pure parts: the OAuth client secret (ES256 JWT), request bodies, report row
|
|
// flattening, CPA math, the text table and CSV. No IO; the network side is cli/ads/client.ts, the command cli/ads/apple.ts.
|
|
//
|
|
// storebound targets the Apple Ads Platform API (https://api.ads.apple.com/v1, released August 2026). It supersedes the
|
|
// Apple Ads Campaign Management API v5 (api.searchads.apple.com/api/v5), which Apple sunsets on January 26, 2027.
|
|
// Same OAuth credentials for both; the Platform API scopes calls by ad account (X-AP-Context: adAccountId=<id>)
|
|
// instead of org (orgId=<id>), queries with POST .../query, and wraps every payload in `result`.
|
|
//
|
|
// Sources (checked October 2026):
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/implementing-oauth-for-the-apple-ads-platform-api
|
|
// client secret: header alg ES256 + kid = keyId; claims sub = clientId, iss = teamId, aud https://appleid.apple.com,
|
|
// iat, exp (> now, < iat + 180 days). Token: POST https://appleid.apple.com/auth/oauth2/token,
|
|
// application/x-www-form-urlencoded grant_type=client_credentials, client_id, client_secret, scope=searchadsorg;
|
|
// answer { access_token, token_type: Bearer, expires_in: 3600 }.
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/calling-apple-ads-platform-api (headers, QueryRequest,
|
|
// `result` + `pagination` envelope, error codes)
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/access-overview (GET /v1/acls, GET /v1/ad-accounts/{id})
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/useracl (roles)
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/campaign, .../campaignupdate, .../dailybudgetupdate,
|
|
// .../money (amounts are decimal strings, currency = the ad account's), .../keyword, .../keywordupdate, .../adgroupupdate
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/reports, .../appsreportingrequest, .../timerange,
|
|
// .../appsoptions, .../appsmetrics (localSpend, impressions, taps, ttr, tapInstalls, viewInstalls, totalInstalls,
|
|
// totalNewDownloads, totalRedownloads), .../get-app-campaign-reports, .../get-app-keyword-reports, .../reportingkeyword
|
|
// https://developer.apple.com/documentation/apple-ads-platform-api/rate-limits (429 + Retry-After)
|
|
// https://developer.apple.com/documentation/apple_ads/implementing-oauth-for-the-apple-search-ads-api (v5, deprecation note)
|
|
import { createPrivateKey, sign, type KeyObject } from 'node:crypto';
|
|
import { toCsv } from './csv';
|
|
import { pct, textTable } from './table';
|
|
|
|
export const ADS_API = 'https://api.ads.apple.com/v1';
|
|
export const TOKEN_URL = 'https://appleid.apple.com/auth/oauth2/token';
|
|
export const ADS_SCOPE = 'searchadsorg';
|
|
export const SECRET_AUDIENCE = 'https://appleid.apple.com';
|
|
/** Apple's limit for a client secret's lifetime. storebound signs a fresh one per run with a much shorter one. */
|
|
export const MAX_SECRET_TTL = 180 * 86400;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export type J = any;
|
|
|
|
export interface AdsCreds { clientId: string; teamId: string; keyId: string; pem: string; accountId?: string }
|
|
|
|
const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url');
|
|
|
|
/** The client secret: an ES256 JWT signed with the private key whose public half is uploaded in Apple Ads
|
|
* (Account Settings > API). `ttl` seconds, at most 180 days. */
|
|
export function clientSecret(c: Pick<AdsCreds, 'clientId' | 'teamId' | 'keyId'>, key: KeyObject | string, now = Math.floor(Date.now() / 1000), ttl = 86400): string {
|
|
if (!(ttl > 0 && ttl <= MAX_SECRET_TTL)) throw new Error('client secret lifetime must be 1 s .. 180 days');
|
|
const k = typeof key === 'string' ? createPrivateKey(key) : key;
|
|
const h = b64(JSON.stringify({ alg: 'ES256', kid: c.keyId }));
|
|
const p = b64(JSON.stringify({ sub: c.clientId, iss: c.teamId, aud: SECRET_AUDIENCE, iat: now, exp: now + ttl }));
|
|
return `${h}.${p}.${b64(sign('sha256', Buffer.from(`${h}.${p}`), { key: k, dsaEncoding: 'ieee-p1363' }))}`;
|
|
}
|
|
|
|
/** Form body of the token request (client credentials grant). */
|
|
export const tokenForm = (clientId: string, secret: string): URLSearchParams =>
|
|
new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: secret, scope: ADS_SCOPE });
|
|
|
|
/** Headers of an API call. GET /v1/acls, /v1/me and /v1/orgs/{id} work without the ad account context. */
|
|
export function adsHeaders(token: string, accountId?: string): Record<string, string> {
|
|
const h: Record<string, string> = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
if (accountId) h['X-AP-Context'] = `adAccountId=${accountId}`;
|
|
return h;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
// Accounts and campaigns
|
|
|
|
export interface AclRow { accountId: string; name: string; orgId: string; roles: string[]; used: boolean }
|
|
|
|
/** GET /v1/acls -> rows; `used` marks the configured APPLE_ADS_ACCOUNT_ID. */
|
|
export function aclRows(res: J, accountId?: string): AclRow[] {
|
|
const acls: J[] = res?.result?.acls ?? [];
|
|
return acls.map(a => ({ accountId: String(a.adAccount?.id ?? ''), name: String(a.adAccount?.name ?? ''), orgId: String(a.adAccount?.orgId ?? ''),
|
|
roles: (a.roles ?? []).map(String), used: !!accountId && String(a.adAccount?.id) === accountId }));
|
|
}
|
|
|
|
/** Roles that may change bids, budgets and status (useracl roles reference). */
|
|
export const canWrite = (roles: string[]): boolean => roles.some(r => r === 'Admin' || r === 'API Account Manager' || r === 'Limited Access: API Read & Write');
|
|
|
|
export interface CampaignRow {
|
|
id: string; name: string; status: string; displayStatus: string; systemStatus: string; dailyBudget: string; currency: string;
|
|
countries: string[]; adamId: string; bidStrategy: string; thisApp: boolean;
|
|
}
|
|
|
|
export function campaignRow(c: J, appId?: string): CampaignRow {
|
|
const adamId = c.promotedObjectType === 'APPSTORE_APP' || c.promotedObjectType === undefined ? String(c.promotedObjectId ?? '') : '';
|
|
return {
|
|
id: String(c.id), name: String(c.name ?? ''), status: String(c.status ?? ''), displayStatus: String(c.displayStatus ?? ''),
|
|
systemStatus: String(c.systemStatus ?? ''), dailyBudget: String(c.dailyBudget?.value?.amount ?? ''), currency: String(c.dailyBudget?.value?.currency ?? ''),
|
|
countries: (c.targeting?.countryOrRegion?.include ?? []).map(String), adamId, bidStrategy: String(c.bidStrategy?.bidStrategyType ?? ''),
|
|
thisApp: !!appId && adamId === String(appId),
|
|
};
|
|
}
|
|
|
|
/** POST /v1/campaigns/query body: App Store campaigns only, by id, one page (the endpoint caps pageSize at 1000). */
|
|
export const campaignQuery = (offset = 0, pageSize = 1000): J => ({
|
|
filters: [{ field: 'promotedObjectType', operator: 'EQUALS', value: 'APPSTORE_APP' }],
|
|
sorting: [{ field: 'id', order: 'ASC' }],
|
|
pagination: { offset, pageSize, fetchTotalCount: true },
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
// Reports
|
|
|
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
|
|
|
/** --days N (ending yesterday, UTC: today is still incomplete) or --start / --end (inclusive). */
|
|
export function reportWindow(o: { days?: number; start?: string; end?: string }, today = new Date()): { start: string; end: string } {
|
|
if (o.start || o.end) {
|
|
if (!o.start || !o.end || !DAY.test(o.start) || !DAY.test(o.end)) throw new Error('--start and --end: both, as YYYY-MM-DD');
|
|
if (o.start > o.end) throw new Error('--start is after --end');
|
|
return { start: o.start, end: o.end };
|
|
}
|
|
const days = o.days ?? 7;
|
|
if (!Number.isInteger(days) || days < 1 || days > 365) throw new Error('--days: 1..365');
|
|
const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 1));
|
|
const start = new Date(end.getTime() - (days - 1) * 86400_000);
|
|
return { start: iso(start), end: iso(end) };
|
|
}
|
|
|
|
/**
|
|
* AppsReportingRequest for POST /v1/reports/apps/{campaigns|keywords}/query. Every apps report needs a campaignId
|
|
* filter (scalar EQUALS, as in Apple's payload examples); keywords can be narrowed by adGroupId. No granularity: one
|
|
* total per row over the window. UTC, sorted by spend.
|
|
*/
|
|
export function reportBody(o: { start: string; end: string; campaignId: string; adGroupId?: string; offset?: number; pageSize?: number }): J {
|
|
const filters: J[] = [{ field: 'campaignId', operator: 'EQUALS', value: String(o.campaignId) }];
|
|
if (o.adGroupId) filters.push({ field: 'adGroupId', operator: 'EQUALS', value: String(o.adGroupId) });
|
|
return {
|
|
pagination: { offset: o.offset ?? 0, pageSize: o.pageSize ?? 1000 },
|
|
sorting: [{ field: 'localSpend', order: 'DESC' }],
|
|
filters,
|
|
timeRange: { start: o.start, end: o.end, timeZone: 'UTC' },
|
|
options: { includeRows: ['GRAND_TOTAL'] },
|
|
};
|
|
}
|
|
|
|
export interface ReportRow {
|
|
level: 'campaign' | 'keyword';
|
|
campaignId: string; campaign: string;
|
|
adGroupId: string; adGroup: string;
|
|
keywordId: string; keyword: string; matchType: string;
|
|
currency: string; spend: number;
|
|
impressions: number; taps: number; installs: number; tapInstalls: number; viewInstalls: number; newDownloads: number; redownloads: number;
|
|
/** taps / impressions */ ttr: number | null;
|
|
/** installs / taps */ conversionRate: number | null;
|
|
/** spend / installs; null with 0 installs */ cpa: number | null;
|
|
/** spend / taps */ avgCpt: number | null;
|
|
}
|
|
|
|
const num = (x: unknown): number => { const n = Number(x ?? 0); return Number.isFinite(n) ? n : 0; };
|
|
const ratio = (a: number, b: number): number | null => (b > 0 ? a / b : null);
|
|
const cents = (x: number): number => Math.round(x * 100) / 100;
|
|
|
|
/** Derived metrics from the counts (the same definitions Apple documents for ttr / totalInstallRate). */
|
|
export function derive<T extends { spend: number; impressions: number; taps: number; installs: number }>(r: T): T & Pick<ReportRow, 'ttr' | 'conversionRate' | 'cpa' | 'avgCpt'> {
|
|
return { ...r, ttr: ratio(r.taps, r.impressions), conversionRate: ratio(r.installs, r.taps), cpa: ratio(r.spend, r.installs), avgCpt: ratio(r.spend, r.taps) };
|
|
}
|
|
|
|
function metrics(m: J): Pick<ReportRow, 'currency' | 'spend' | 'impressions' | 'taps' | 'installs' | 'tapInstalls' | 'viewInstalls' | 'newDownloads' | 'redownloads'> {
|
|
const tapInstalls = num(m?.tapInstalls), viewInstalls = num(m?.viewInstalls);
|
|
return {
|
|
currency: String(m?.localSpend?.currency ?? ''), spend: cents(num(m?.localSpend?.amount)),
|
|
impressions: num(m?.impressions), taps: num(m?.taps),
|
|
// totalInstalls = tap + view installs; older rows may only carry the two parts
|
|
installs: m?.totalInstalls !== undefined ? num(m.totalInstalls) : tapInstalls + viewInstalls,
|
|
tapInstalls, viewInstalls, newDownloads: num(m?.totalNewDownloads), redownloads: num(m?.totalRedownloads),
|
|
};
|
|
}
|
|
|
|
/** Report response rows -> flat rows. `campaignNames` fills the campaign name of keyword rows. */
|
|
export function flattenRows(level: ReportRow['level'], res: J, campaignNames: Map<string, string> = new Map()): ReportRow[] {
|
|
const rows: J[] = res?.result?.rows ?? [];
|
|
return rows.map(r => {
|
|
const md = r.metadata ?? {};
|
|
const campaignId = String(level === 'campaign' ? md.id ?? '' : md.campaignId ?? '');
|
|
return derive({
|
|
level, campaignId,
|
|
campaign: level === 'campaign' ? String(md.name ?? '') : campaignNames.get(campaignId) ?? '',
|
|
adGroupId: level === 'keyword' ? String(md.adGroupId ?? '') : '', adGroup: level === 'keyword' ? String(md.adGroup?.name ?? '') : '',
|
|
keywordId: level === 'keyword' ? String(md.id ?? '') : '', keyword: level === 'keyword' ? String(md.text ?? '') : '', matchType: level === 'keyword' ? String(md.matchType ?? '') : '',
|
|
...metrics(r.totalMetrics),
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Offset of the next page, or null on the last one (offset paging). */
|
|
export const nextOffset = (res: J): number | null => {
|
|
const p = res?.pagination;
|
|
if (!p || typeof p.totalCount !== 'number') return null;
|
|
const next = num(p.offset) + num(p.pageSize);
|
|
return next < p.totalCount && num(p.pageSize) > 0 ? next : null;
|
|
};
|
|
|
|
export type ReportTotals = Omit<ReportRow, 'level' | 'campaignId' | 'campaign' | 'adGroupId' | 'adGroup' | 'keywordId' | 'keyword' | 'matchType'>;
|
|
|
|
export function totals(rows: ReportRow[]): ReportTotals {
|
|
const cur = [...new Set(rows.map(r => r.currency).filter(Boolean))];
|
|
const sum = (k: 'spend' | 'impressions' | 'taps' | 'installs' | 'tapInstalls' | 'viewInstalls' | 'newDownloads' | 'redownloads') => rows.reduce((a, r) => a + r[k], 0);
|
|
return derive({
|
|
currency: cur.length === 1 ? cur[0]! : cur.length ? 'mixed' : '', spend: cents(sum('spend')),
|
|
impressions: sum('impressions'), taps: sum('taps'), installs: sum('installs'), tapInstalls: sum('tapInstalls'), viewInstalls: sum('viewInstalls'),
|
|
newDownloads: sum('newDownloads'), redownloads: sum('redownloads'),
|
|
});
|
|
}
|
|
|
|
const money = (x: number | null, cur = ''): string => (x === null ? 'n/a' : `${x.toFixed(2)}${cur ? ' ' + cur : ''}`);
|
|
|
|
export function reportTable(rows: ReportRow[], t: ReportTotals): string {
|
|
const kw = rows.some(r => r.level === 'keyword');
|
|
const head = [...(kw ? ['keyword', 'match', 'ad group'] : []), 'campaign', 'spend', 'impr', 'taps', 'installs', 'TTR', 'CR', 'CPA', 'avg CPT'];
|
|
const line = (r: ReportRow | ReportTotals, lead: string[]) => [...lead, money(r.spend, r.currency), r.impressions, r.taps, r.installs, pct(r.ttr), pct(r.conversionRate), money(r.cpa), money(r.avgCpt)];
|
|
return textTable(head, [
|
|
...rows.map(r => line(r, [...(kw ? [r.keyword, r.matchType, r.adGroup || r.adGroupId] : []), r.campaign || r.campaignId])),
|
|
line(t, [...(kw ? ['', '', ''] : []), 'TOTAL']),
|
|
]);
|
|
}
|
|
|
|
export const CSV_HEAD = ['level', 'campaign_id', 'campaign', 'ad_group_id', 'ad_group', 'keyword_id', 'keyword', 'match_type', 'currency', 'spend', 'impressions', 'taps',
|
|
'installs', 'tap_installs', 'view_installs', 'new_downloads', 'redownloads', 'ttr', 'conversion_rate', 'cpa', 'avg_cpt'];
|
|
|
|
const dec = (x: number | null, d: number): string => (x === null ? '' : x.toFixed(d));
|
|
|
|
/** One CSV line per row (empty cells for "no value": CPA with 0 installs). Rates as fractions (0.0123). */
|
|
export function reportCsv(rows: ReportRow[]): string {
|
|
return toCsv([CSV_HEAD, ...rows.map(r => [r.level, r.campaignId, r.campaign, r.adGroupId, r.adGroup, r.keywordId, r.keyword, r.matchType, r.currency, r.spend.toFixed(2),
|
|
r.impressions, r.taps, r.installs, r.tapInstalls, r.viewInstalls, r.newDownloads, r.redownloads, dec(r.ttr, 6), dec(r.conversionRate, 6), dec(r.cpa, 2), dec(r.avgCpt, 2)])]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
// Writes (bid, budget, pause): the bodies and the "old -> new" line. The command reads first, prints, asks.
|
|
|
|
/** An amount as the API wants it: a positive decimal string, no leading zeros, at most 2 decimals ("5.00", not "05.00"). */
|
|
export function parseAmount(v: string | undefined, what: string): string {
|
|
const s = (v ?? '').trim();
|
|
if (!/^(0|[1-9]\d*)(\.\d{1,2})?$/.test(s) || !(Number(s) > 0)) throw new Error(`${what}: a positive amount like 1.25 (account currency, max 2 decimals)`);
|
|
return s;
|
|
}
|
|
|
|
/** "1.50 -> 2.00 USD (+33%)" */
|
|
export function changeLine(oldAmount: string | null, newAmount: string, currency: string): string {
|
|
if (oldAmount === null) return `(default) -> ${newAmount} ${currency}`;
|
|
const o = Number(oldAmount), n = Number(newAmount);
|
|
const d = o > 0 ? ` (${n >= o ? '+' : ''}${Math.round(((n - o) / o) * 100)}%)` : '';
|
|
return `${oldAmount} -> ${newAmount} ${currency}${d}`;
|
|
}
|
|
|
|
export const keywordBidBody = (amount: string, currency: string): J => ({ bid: { amount, currency } });
|
|
export const dailyBudgetBody = (amount: string, currency: string): J => ({ dailyBudget: { value: { amount, currency } } });
|
|
export const pauseBody = (): J => ({ status: 'PAUSED' });
|