analytics and reports for a collector: store-only configs, --json snapshot, App Usage and Play install stock numbers, per-day sales ranges for both stores
This commit is contained in:
parent
b59665c661
commit
5833776a4b
15 changed files with 651 additions and 60 deletions
|
|
@ -1,23 +1,27 @@
|
|||
// Store analytics snapshots next to the listing changes.
|
||||
// storebound analytics setup [--dry] App Store: create the ONGOING analytics report request (Admin key; once)
|
||||
// storebound analytics snapshot [--days 28] [--store apple|play] [--dry] -> .storebound/analytics/<date>.json
|
||||
// storebound analytics snapshot [--days 28] [--store apple|play] [--dry] [--json] [--no-write] -> .storebound/analytics/<date>.json
|
||||
// --json prints the snapshot as one JSON document on stdout (the file is still written unless --no-write or --dry)
|
||||
// storebound analytics compare [--days 7] [--paths store,storebound.config.ts] [--md]
|
||||
// App Store: Analytics Reports API (Discovery and Engagement: impressions, product page views; Downloads: first-time
|
||||
// downloads and redownloads), every segment checked against its md5 checksum. Google Play has no statistics API:
|
||||
// the monthly CSV exports in your Cloud Storage bucket (play.reportsBucket = "pubsite_prod_rev_<id>", Play Console >
|
||||
// Download reports > Statistics > Copy Cloud Storage URI) are read with the service account (store listing visitors,
|
||||
// acquisitions, daily user installs). compare lines the snapshots up with the git history of the store files (listing
|
||||
// acquisitions, daily user installs, active device installs, total user installs). App Usage (App Store: sessions, active
|
||||
// devices, crashes per day from the App Sessions and App Crashes reports). A store-only config needs no play.reportsBucket
|
||||
// (env PLAY_REPORTS_BUCKET or the profile's reportsBucket) and no apple.bundleId (apple.appId is enough). compare lines the snapshots up with the git history of the store files (listing
|
||||
// text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`.
|
||||
import { consoleLink } from './lib/console-links';
|
||||
import { storeReadError } from './lib/google-errors';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createOnce } from './lib/upsert';
|
||||
import { playReportsBucket } from './lib/credentials';
|
||||
import { ascClient, type Asc, type J } from './apple/asc';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import {
|
||||
type AppleDay, APPLE_REPORTS, appleDaily, appleTotals, bucketName, checksumOk, compareAroundEvents, decodeText, effectMarkdown, effectTable, inWindow,
|
||||
latestPerDate, mergeSeries, monthsBetween, snapshotProblem, storeErrors, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot,
|
||||
latestPerDate, mergeAppleUsage, mergeSeries, monthsBetween, snapshotProblem, storeErrors, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot,
|
||||
} from './lib/analytics';
|
||||
import { STORAGE_READ_SCOPE } from './lib/google-auth';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
|
|
@ -55,9 +59,9 @@ async function reportRows(c: Asc, report: J, start: string, tag: string): Promis
|
|||
return latestPerDate(parts);
|
||||
}
|
||||
|
||||
async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: AppleDay[] }> {
|
||||
async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: AppleDay[]; notes: string[] }> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
const c = await ascClient(root, config.apple!.bundleId ?? '', config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const req = await ongoingRequest(c, appId);
|
||||
if (!req) throw new Error('no active ONGOING analytics report request: run storebound analytics setup (Admin key), data starts 1-2 days later');
|
||||
|
|
@ -65,25 +69,36 @@ async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ da
|
|||
const eng = pickReport(reports.filter(r => r.attributes?.category === APPLE_REPORTS.engagement.category), APPLE_REPORTS.engagement.match);
|
||||
const dl = pickReport(reports.filter(r => r.attributes?.category === APPLE_REPORTS.downloads.category), APPLE_REPORTS.downloads.match);
|
||||
if (!eng || !dl) throw new Error(`reports not found yet (${reports.length} listed): wait a day after setup`);
|
||||
const daily = appleDaily(await reportRows(c, eng, start, eng.attributes.name), await reportRows(c, dl, start, dl.attributes.name));
|
||||
return { daily: inWindow(daily, start, end) };
|
||||
let daily = appleDaily(await reportRows(c, eng, start, eng.attributes.name), await reportRows(c, dl, start, dl.attributes.name));
|
||||
// App Usage is optional: a team whose request predates the usage reports, or whose usage reports are not out yet, still gets the rest
|
||||
const notes: string[] = [];
|
||||
const usage = (m: { category: string; match: RegExp }) => pickReport(reports.filter(r => r.attributes?.category === m.category), m.match);
|
||||
const ses = usage(APPLE_REPORTS.sessions), cr = usage(APPLE_REPORTS.crashes);
|
||||
if (ses || cr) {
|
||||
try {
|
||||
daily = mergeAppleUsage(daily, ses ? await reportRows(c, ses, start, ses.attributes.name) : [], cr ? await reportRows(c, cr, start, cr.attributes.name) : []);
|
||||
if (!ses || !cr) notes.push(`App Usage: the ${ses ? 'App Crashes' : 'App Sessions'} report is not listed yet`);
|
||||
} catch (e) { notes.push(`App Usage not read: ${storeReadError((e as Error).message).error.slice(0, 200)}`); }
|
||||
} else notes.push('App Usage: the App Sessions / App Crashes reports are not listed yet (they appear a day or two after the first report)');
|
||||
for (const n of notes) warn(TAG, `apple: ${n}`);
|
||||
return { daily: inWindow(daily, start, end), notes };
|
||||
}
|
||||
|
||||
async function playSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: PlayDay[] }> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const bucket = config.play?.reportsBucket;
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`);
|
||||
const bucket = playReportsBucket(root, config.play?.reportsBucket);
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (env PLAY_REPORTS_BUCKET or the credential profile's reportsBucket also work; ${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`);
|
||||
const g = googleClient(root, STORAGE_READ_SCOPE);
|
||||
const sp: Record<string, string>[] = [], inst: Record<string, string>[] = [];
|
||||
const sp: Record<string, string>[] = [], inst: Record<string, string>[] = [], ov: Record<string, string>[] = [];
|
||||
for (const month of monthsBetween(start, end)) {
|
||||
const o = playObjects(config.play!.packageName, month);
|
||||
for (const [name, into] of [[o.storePerformance, sp], [o.installs, inst]] as const) {
|
||||
for (const [name, into] of [[o.storePerformance, sp], [o.installs, inst], [o.installsOverview, ov]] as const) {
|
||||
const url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(name)}?alt=media`;
|
||||
try { into.push(...parseReport(decodeText(await g.bytes(url)))); log(TAG, `play: ${name}`); }
|
||||
catch (e) { if (/-> 404/.test(String(e))) warn(TAG, `play: ${name} not there (yet)`); else throw e; }
|
||||
}
|
||||
}
|
||||
return { daily: inWindow(playDaily(sp, inst), start, end) };
|
||||
return { daily: inWindow(playDaily(sp, inst, ov), start, end) };
|
||||
}
|
||||
|
||||
function readSnapshots(dir: string): Snapshot[] {
|
||||
|
|
@ -100,7 +115,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
if (sub === 'setup') {
|
||||
if (!config.apple) throw new Error('no apple section in the config (Google Play needs no setup: set play.reportsBucket)');
|
||||
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
|
||||
const c = await ascClient(root, config.apple.bundleId ?? '', config.apple.appId);
|
||||
const appId = await c.app();
|
||||
const cur = await ongoingRequest(c, appId);
|
||||
if (cur) { done(TAG, `apple: ONGOING report request exists (${cur.id})`); return; }
|
||||
|
|
@ -121,20 +136,24 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const end = day(new Date(now.getTime() - 86_400_000)), start = day(new Date(now.getTime() - days * 86_400_000));
|
||||
const snap: Snapshot = { date: day(now), window: { start, end, days } };
|
||||
if ((!store || store === 'apple') && config.apple) {
|
||||
try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily) }; }
|
||||
try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily), ...(r.notes.length ? { notes: r.notes } : {}) }; }
|
||||
catch (e) { snap.apple = { error: storeReadError((e as Error).message).error.slice(0, 400) }; warn(TAG, `apple: ${snap.apple.error}`); }
|
||||
}
|
||||
if ((!store || store === 'play') && config.play) {
|
||||
try { const r = await playSnapshot(ctx, start, end); snap.play = { daily: r.daily, totals: playTotals(r.daily) }; }
|
||||
catch (e) { snap.play = { error: storeReadError((e as Error).message, { app: config.play.packageName }).error.slice(0, 400) }; warn(TAG, `play: ${snap.play.error}`); }
|
||||
}
|
||||
const json = a.has('json');
|
||||
const rows: (string | number)[][] = [];
|
||||
if (snap.apple && !('error' in snap.apple)) { const t = snap.apple.totals; rows.push(['apple', `${t.impressions} impressions`, `${t.pageViews} page views`, `${t.downloads} downloads`, pct(t.conversion ?? null)]); }
|
||||
if (snap.play && !('error' in snap.play)) { const t = snap.play.totals; rows.push(['play', `${t.visitors} visitors`, `${t.acquisitions} acquisitions`, `${t.installs} installs`, pct(t.conversion ?? null)]); }
|
||||
if (rows.length) console.log(textTable(['store', 'reach', 'interest', 'installs', 'conversion'], rows));
|
||||
const file = join(dir, `${snap.date}.json`);
|
||||
const problem = snapshotProblem(snap);
|
||||
// --json: the collector gets the document (with each store's error) on stdout even when nothing could be read; the exit code still says it failed
|
||||
if (json) printJson(snap);
|
||||
if (problem) throw new Error(`no store data, nothing written (${problem})`);
|
||||
if (a.has('no-write')) { done(TAG, `snapshot ${start} to ${end} (not written: --no-write)`); return; }
|
||||
if (dry) { done(TAG, `dry run: would write ${file} (${start} to ${end})`); return; }
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(snap, null, 2) + '\n');
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'check': [() => import('./check'), 'validate config, copy files, data safety answers, products, subscriptions and the app profile (offline); --aso keyword audit'],
|
||||
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
|
||||
'report': [() => import('./report'), 'shareable store readiness report: scan + check + validate (read-only; only the stores with credentials, else it says why) in one self-contained HTML page .storebound/reports/readiness-<date>.html, opened in the browser; --out <file>, --md, --json, --no-open, --offline'],
|
||||
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'],
|
||||
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, App Usage, conversion -> .storebound/analytics; --json prints it) | compare (vs listing changes in git)'],
|
||||
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
|
||||
'art': [() => import('./art'), 'pull [--store apple|play] [--write] [--force]: the live store art (App Store screenshot sets, previews, icon; Play icon, feature graphic, screenshots per language) into store/renders + pulled.json, so checks and uploads treat it like rendered art (upsert; differing local files kept without --force; read-only on the stores) | live [--json]: the live art with thumbnails | replace --from <file> --to <path>: one image of the app folder'],
|
||||
'onboard': [() => import('./onboard'), 'an app that is already live: the live products + subscriptions with their USD / base prices, contact e-mail, website, privacy and support URLs, the App Review contact and demo account, sign-in for review and the account deletion page, from the stores, the code and the app\'s site -> config and .storebound/credentials.json (--write; read-only on the stores, never changes store prices); then the owner questions nothing proves'],
|
||||
'catalog': [() => import('./catalog'), 'diff: cross-store parity of products + subscriptions (missing / extra / mismatched, prices per region, offers); read-only, exit 1 on mismatches | pull [--write]: store texts of IAPs + subscriptions as a config proposal'],
|
||||
'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; read-only'],
|
||||
'reports': [() => import('./reports'), 'apple-sales | apple-finance --month | play-earnings --month | play-sales --month: download to .storebound/reports/, summary table + CSV; apple-sales and play-sales also --days N | --from/--to (per-day JSON); read-only'],
|
||||
'triage': [() => import('./triage'), 'TestFlight crash / screenshot feedback and Play error reports grouped by signature, device, OS version, build; read-only, --json'],
|
||||
'aso': [() => import('./aso'), 'rank | competitors | suggest | audit: App Store ranks, top apps per keyword, keyword field proposals (Apple Ads popularity with credentials) and one ASO report, from the public iTunes Search API -> .storebound/aso/'],
|
||||
'prices': [() => import('./prices'), 'export | import a CSV of product x store x region prices (diff first, asks before writing; Apple future-dated changes) | ppp --base <USD> (purchasing power price sheet, snapped to store price points)'],
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"days",
|
||||
"dir",
|
||||
"md",
|
||||
"no-write",
|
||||
"paths",
|
||||
"store"
|
||||
],
|
||||
|
|
@ -908,11 +909,14 @@
|
|||
"reports": [
|
||||
"csv",
|
||||
"date",
|
||||
"days",
|
||||
"detail",
|
||||
"frequency",
|
||||
"from",
|
||||
"month",
|
||||
"out",
|
||||
"region",
|
||||
"to",
|
||||
"type",
|
||||
"vendor"
|
||||
],
|
||||
|
|
@ -1282,6 +1286,7 @@
|
|||
"no-variants",
|
||||
"no-wait",
|
||||
"no-web",
|
||||
"no-write",
|
||||
"not-sent-for-review",
|
||||
"notify",
|
||||
"off",
|
||||
|
|
@ -1421,7 +1426,7 @@
|
|||
],
|
||||
"analytics": [
|
||||
"storebound analytics setup [--dry] App Store: create the ONGOING analytics report request (Admin key; once)",
|
||||
"storebound analytics snapshot [--days 28] [--store apple|play] [--dry] -> .storebound/analytics/<date>.json",
|
||||
"storebound analytics snapshot [--days 28] [--store apple|play] [--dry] [--json] [--no-write] -> .storebound/analytics/<date>.json",
|
||||
"storebound analytics compare [--days 7] [--paths store,storebound.config.ts] [--md]"
|
||||
],
|
||||
"api": [
|
||||
|
|
@ -1718,7 +1723,9 @@
|
|||
"storebound reports apple-sales [--type SALES] [--frequency DAILY|WEEKLY|MONTHLY|YEARLY] [--date ...] [--vendor N]",
|
||||
"storebound reports apple-finance --month YYYY-MM [--detail] [--region ZZ] [--vendor N]",
|
||||
"storebound reports play-earnings --month YYYY-MM",
|
||||
"storebound reports play-sales --month YYYY-MM"
|
||||
"storebound reports play-sales --month YYYY-MM",
|
||||
"storebound reports apple-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (daily SALES reports)",
|
||||
"storebound reports play-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (per order-charged date and currency)"
|
||||
],
|
||||
"revenuecat": [
|
||||
"storebound revenuecat diff [--json] read-only parity report; exit 1 when out of sync",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
|
|||
import { join } from 'node:path';
|
||||
import { platformsOf, playCategoryOf, profileOf, sellsOn } from './lib/config';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { maskUser, reviewContact, reviewDemoAccount } from './lib/credentials';
|
||||
import { credentialValue, maskUser, reviewContact, reviewDemoAccount } from './lib/credentials';
|
||||
import { DATA_SAFETY_UPLOADED, declarations } from './lib/declarations';
|
||||
import type { ProjectSummary } from './lib/desktop-protocol';
|
||||
import { log, printJson } from './lib/log';
|
||||
|
|
@ -42,7 +42,7 @@ export function summarize(ctx: Ctx): ProjectSummary {
|
|||
profile: profileOf(config),
|
||||
icon: findIcon(config, root),
|
||||
variants: Object.keys(config.shots?.variants ?? {}),
|
||||
playReports: !!config.play?.reportsBucket,
|
||||
playReports: !!config.play?.reportsBucket || !!credentialValue(root, 'PLAY_REPORTS_BUCKET'),
|
||||
playRelease: config.play ? { managedPublishing: config.play.managedPublishing ?? null, countries: config.play.countries?.length ?? null, category: playCategoryOf(config)?.category ?? null } : null,
|
||||
games: config.games ? { gameCenter: config.games.gameCenter === true, playGames: !!config.games.playGames, leaderboards: config.games.leaderboards?.length ?? 0, achievements: config.games.achievements?.length ?? 0 } : null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import { mdTable, pct, textTable } from './table';
|
|||
export const APPLE_REPORTS = {
|
||||
engagement: { category: 'APP_STORE_ENGAGEMENT', match: /discovery and engagement/i },
|
||||
downloads: { category: 'COMMERCE', match: /download/i },
|
||||
// App Usage (Analytics Reports API category APP_USAGE): App Sessions (Sessions, Total Session Duration, Unique Devices) and
|
||||
// App Crashes (Crashes, Unique Devices), per day, version, device, platform version and territory
|
||||
sessions: { category: 'APP_USAGE', match: /app sessions/i },
|
||||
crashes: { category: 'APP_USAGE', match: /app crashes/i },
|
||||
} as const;
|
||||
|
||||
/** Standard over Detailed (Detailed adds noise for privacy and splits by campaign). */
|
||||
|
|
@ -54,8 +58,16 @@ export function latestPerDate(instances: { processingDate: string; rows: Record<
|
|||
return instances.flatMap(i => i.rows.filter(r => best.get(r.date ?? '') === i.processingDate));
|
||||
}
|
||||
|
||||
export interface AppleDay { date: string; impressions: number; uniqueImpressions: number; pageViews: number; uniquePageViews: number; downloads: number; firstTimeDownloads: number }
|
||||
export interface PlayDay { date: string; visitors: number; acquisitions: number; installs: number }
|
||||
export interface AppleDay {
|
||||
date: string; impressions: number; uniqueImpressions: number; pageViews: number; uniquePageViews: number; downloads: number; firstTimeDownloads: number;
|
||||
/** App Usage (only when the App Sessions / App Crashes reports have the day): app sessions, active devices (sum of the
|
||||
* rows' Unique Devices: a device seen in two rows, e.g. two app versions, counts twice), total session time in
|
||||
* seconds, crashes. */
|
||||
sessions?: number; activeDevices?: number; sessionSeconds?: number; crashes?: number;
|
||||
}
|
||||
/** installs = Daily User Installs. The two stock numbers are Play's own columns: devices with the app installed that
|
||||
* were active in the last 30 days (Active Device Installs) and all users who ever installed it (Total User Installs). */
|
||||
export interface PlayDay { date: string; visitors: number; acquisitions: number; installs: number; activeDeviceInstalls?: number; totalUserInstalls?: number }
|
||||
|
||||
const num = (v: string | undefined) => { const n = Number((v ?? '').replace(/,/g, '')); return Number.isFinite(n) ? n : 0; };
|
||||
|
||||
|
|
@ -80,12 +92,50 @@ export function appleDaily(engagement: Record<string, string>[], downloads: Reco
|
|||
return [...days.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
/** Play store performance + installs CSV rows (country files) -> one line per day. Dates are YYYY-MM-DD. */
|
||||
export function playDaily(storePerformance: Record<string, string>[], installs: Record<string, string>[]): PlayDay[] {
|
||||
/** App Sessions + App Crashes rows -> App Usage fields per day, merged into the days (a day only in the usage reports
|
||||
* becomes a day with zero impressions and downloads). Sessions' Unique Devices are the active devices of the day. */
|
||||
export function mergeAppleUsage(daily: AppleDay[], sessions: Record<string, string>[], crashes: Record<string, string>[]): AppleDay[] {
|
||||
const days = new Map(daily.map(d => [d.date, { ...d }]));
|
||||
const day = (d: string) => days.get(d) ?? days.set(d, { date: d, impressions: 0, uniqueImpressions: 0, pageViews: 0, uniquePageViews: 0, downloads: 0, firstTimeDownloads: 0 }).get(d)!;
|
||||
for (const r of sessions) {
|
||||
if (!r.date) continue;
|
||||
const x = day(r.date);
|
||||
x.sessions = (x.sessions ?? 0) + num(r.sessions);
|
||||
x.activeDevices = (x.activeDevices ?? 0) + num(r.unique_devices);
|
||||
x.sessionSeconds = (x.sessionSeconds ?? 0) + num(r.total_session_duration);
|
||||
}
|
||||
for (const r of crashes) {
|
||||
if (!r.date) continue;
|
||||
const x = day(r.date);
|
||||
x.crashes = (x.crashes ?? 0) + num(r.crashes ?? r.counts);
|
||||
}
|
||||
return [...days.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
/** Play store performance + installs CSV rows (country files) -> one line per day. Dates are YYYY-MM-DD. The stock
|
||||
* columns (Active Device Installs, Total User Installs) come from the overview rows when given (all countries in one
|
||||
* row per day), else from the country rows summed. */
|
||||
export function playDaily(storePerformance: Record<string, string>[], installs: Record<string, string>[], overview: Record<string, string>[] = []): PlayDay[] {
|
||||
const days = new Map<string, PlayDay>();
|
||||
const day = (d: string) => days.get(d) ?? days.set(d, { date: d, visitors: 0, acquisitions: 0, installs: 0 }).get(d)!;
|
||||
for (const r of storePerformance) if (r.date) { const x = day(r.date); x.visitors += num(r.store_listing_visitors); x.acquisitions += num(r.store_listing_acquisitions); }
|
||||
for (const r of installs) if (r.date) day(r.date).installs += num(r.daily_user_installs);
|
||||
const stock = (rows: Record<string, string>[]) => {
|
||||
const m = new Map<string, { active?: number; total?: number }>();
|
||||
for (const r of rows) {
|
||||
if (!r.date) continue;
|
||||
const x = m.get(r.date) ?? m.set(r.date, {}).get(r.date)!;
|
||||
if (r.active_device_installs !== undefined && r.active_device_installs !== '') x.active = (x.active ?? 0) + num(r.active_device_installs);
|
||||
if (r.total_user_installs !== undefined && r.total_user_installs !== '') x.total = (x.total ?? 0) + num(r.total_user_installs);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const fromCountries = stock(installs), fromOverview = stock(overview);
|
||||
for (const [date, x] of (fromOverview.size ? fromOverview : fromCountries)) {
|
||||
const d = day(date);
|
||||
if (x.active !== undefined) d.activeDeviceInstalls = x.active;
|
||||
if (x.total !== undefined) d.totalUserInstalls = x.total;
|
||||
}
|
||||
return [...days.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
|
|
@ -101,29 +151,50 @@ export function monthsBetween(start: string, end: string): string[] {
|
|||
export const playObjects = (pkg: string, month: string) => ({
|
||||
storePerformance: `stats/store_performance/store_performance_${pkg}_${month}_country.csv`,
|
||||
installs: `stats/installs/installs_${pkg}_${month}_country.csv`,
|
||||
installsOverview: `stats/installs/installs_${pkg}_${month}_overview.csv`,
|
||||
});
|
||||
|
||||
/** "gs://pubsite_prod_rev_123/" or "pubsite_prod_rev_123" -> bucket name. */
|
||||
export const bucketName = (b: string): string => b.replace(/^gs:\/\//, '').replace(/\/.*$/, '');
|
||||
|
||||
export interface Totals { impressions?: number; pageViews?: number; downloads?: number; conversion?: number | null; visitors?: number; acquisitions?: number; installs?: number }
|
||||
export interface Totals {
|
||||
impressions?: number; pageViews?: number; downloads?: number; conversion?: number | null; visitors?: number; acquisitions?: number; installs?: number;
|
||||
/** App Usage: sessions and crashes summed, active devices as the mean of the days that have usage data. */
|
||||
sessions?: number; crashes?: number; activeDevicesAvg?: number;
|
||||
/** Play stock numbers: the value of the newest day that has one (never summed). */
|
||||
activeDeviceInstalls?: number; totalUserInstalls?: number;
|
||||
}
|
||||
|
||||
export function appleTotals(days: AppleDay[]): Totals {
|
||||
const s = (k: keyof AppleDay) => days.reduce((n, d) => n + (d[k] as number), 0);
|
||||
const uniq = s('uniqueImpressions');
|
||||
return { impressions: s('impressions'), pageViews: s('pageViews'), downloads: s('downloads'), conversion: uniq ? s('downloads') / uniq : null };
|
||||
const t: Totals = { impressions: s('impressions'), pageViews: s('pageViews'), downloads: s('downloads'), conversion: uniq ? s('downloads') / uniq : null };
|
||||
const used = days.filter(d => d.sessions !== undefined || d.crashes !== undefined);
|
||||
if (used.length) {
|
||||
t.sessions = used.reduce((n, d) => n + (d.sessions ?? 0), 0);
|
||||
t.crashes = used.reduce((n, d) => n + (d.crashes ?? 0), 0);
|
||||
const act = used.filter(d => d.activeDevices !== undefined);
|
||||
if (act.length) t.activeDevicesAvg = Math.round(act.reduce((n, d) => n + d.activeDevices!, 0) / act.length);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
export function playTotals(days: PlayDay[]): Totals {
|
||||
const s = (k: keyof PlayDay) => days.reduce((n, d) => n + (d[k] as number), 0);
|
||||
const v = s('visitors');
|
||||
return { visitors: v, acquisitions: s('acquisitions'), installs: s('installs'), conversion: v ? s('acquisitions') / v : null };
|
||||
const t: Totals = { visitors: v, acquisitions: s('acquisitions'), installs: s('installs'), conversion: v ? s('acquisitions') / v : null };
|
||||
const last = (k: 'activeDeviceInstalls' | 'totalUserInstalls') => [...days].reverse().find(d => d[k] !== undefined)?.[k];
|
||||
const a = last('activeDeviceInstalls'), tot = last('totalUserInstalls');
|
||||
if (a !== undefined) t.activeDeviceInstalls = a;
|
||||
if (tot !== undefined) t.totalUserInstalls = tot;
|
||||
return t;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
date: string;
|
||||
window: { start: string; end: string; days: number };
|
||||
apple?: { daily: AppleDay[]; totals: Totals } | { error: string };
|
||||
play?: { daily: PlayDay[]; totals: Totals } | { error: string };
|
||||
/** notes: what is missing but not fatal (e.g. the App Usage reports are not listed yet). */
|
||||
apple?: { daily: AppleDay[]; totals: Totals; notes?: string[] } | { error: string };
|
||||
play?: { daily: PlayDay[]; totals: Totals; notes?: string[] } | { error: string };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -658,6 +658,7 @@ export interface ShipConfig {
|
|||
billingPrograms?: ('user-choice-billing' | 'alternative-billing' | 'external-offers' | 'external-content-links')[];
|
||||
};
|
||||
apple?: {
|
||||
/** Absent / null only in a store-only config with apple.appId (analytics and reports reads); every write needs it. */
|
||||
bundleId: string; appId?: string; primaryLocale?: string; listing?: string; info?: AppleAppInfo; reviewShots?: string;
|
||||
/** App Store name in the primary locale (at most 30 characters) when it differs from app.name, e.g. because the plain
|
||||
* name is taken on the App Store ("STAR DASH: Stone Age to 2100"). Default app.name. A `### name` block in the
|
||||
|
|
@ -1189,7 +1190,9 @@ export function validateConfig(c: unknown): string[] {
|
|||
if (cfg.play?.billingPrograms !== undefined && !(Array.isArray(cfg.play.billingPrograms) && cfg.play.billingPrograms.every(b => (PLAY_BILLING_PROGRAMS as readonly string[]).includes(b))))
|
||||
e.push(`play.billingPrograms: a list of ${PLAY_BILLING_PROGRAMS.join(' | ')}`);
|
||||
if (cfg.apple) {
|
||||
if (!ID_RE.test(cfg.apple.bundleId ?? '')) e.push('apple.bundleId: not a bundle id (com.example.game)');
|
||||
// a store-only config for reads (analytics, reports) may name the app by apple.appId alone: bundleId null / absent
|
||||
const idOnly = cfg.apple.bundleId == null && /^\d+$/.test(cfg.apple.appId ?? '');
|
||||
if (!idOnly && !ID_RE.test(cfg.apple.bundleId ?? '')) e.push('apple.bundleId: not a bundle id (com.example.game)');
|
||||
const an = cfg.apple.name;
|
||||
if (an !== undefined && (typeof an !== 'string' || !an.trim())) e.push('apple.name: the App Store name (text), or leave it out to use app.name');
|
||||
else if (an !== undefined && [...an].length > APPLE_NAME_MAX) e.push(`apple.name: ${[...an].length} characters > ${APPLE_NAME_MAX} (App Store limit)`);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
// The demo account for App Review can also come from env: REVIEW_DEMO_USER, REVIEW_DEMO_PASSWORD.
|
||||
// 4. the default profile (none named): store keys only
|
||||
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
|
||||
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
|
||||
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "reportsBucket"?, "label"? };
|
||||
// with a credential broker (cli/lib/broker.ts) Play needs no key file: "PLAY_SERVICE_ACCOUNT_EMAIL" names the account
|
||||
// the broker mints tokens for, and "broker": false keeps a profile on its local key.
|
||||
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
|
||||
|
|
@ -46,7 +46,7 @@ function fileStore(root: string): Store {
|
|||
export function credentialValue(root: string, name: string): string | undefined { return value(root, name); }
|
||||
/** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number
|
||||
* for reports too; an app's Android upload keystore stays in the project. */
|
||||
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL'] as const;
|
||||
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID', 'PLAY_SERVICE_ACCOUNT_EMAIL', 'PLAY_REPORTS_BUCKET'] as const;
|
||||
export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials');
|
||||
|
|
@ -66,7 +66,9 @@ export function profileEnv(profile: string): Record<string, string> {
|
|||
const out: Record<string, string> = {};
|
||||
for (const k of PROFILE_KEYS) {
|
||||
const env = process.env[profileEnvName(profile, k)];
|
||||
const v = env || (typeof stored[k] === 'string' ? stored[k] as string : '');
|
||||
// PLAY_REPORTS_BUCKET is also the profile field `reportsBucket` (a bucket name, not a secret)
|
||||
const own = stored[k] ?? (k === 'PLAY_REPORTS_BUCKET' ? stored.reportsBucket : undefined);
|
||||
const v = env || (typeof own === 'string' ? own : '');
|
||||
if (!v) continue;
|
||||
// a path in the file: relative to the profile folder (inline JSON / PEM stays as it is)
|
||||
const isPath = !env && !/^\s*\{/.test(v) && !/BEGIN PRIVATE KEY/.test(v) && (k === 'PLAY_SERVICE_ACCOUNT' || k === 'ASC_P8');
|
||||
|
|
@ -346,6 +348,16 @@ export function ascVendorNumber(root: string): string | null {
|
|||
return v && /^\d{5,12}$/.test(v.trim()) ? v.trim() : null;
|
||||
}
|
||||
|
||||
/** The Play reports bucket (analytics statistics, sales / earnings reports): play.reportsBucket of the config, else env
|
||||
* PLAY_REPORTS_BUCKET, else the credential profile's `reportsBucket` (a store-only config has no play.reportsBucket).
|
||||
* Null when none is set; a value that is not a Play reports bucket throws (a bucket name is not a secret). */
|
||||
export function playReportsBucket(root: string, fromConfig?: string): string | null {
|
||||
const v = (fromConfig ?? value(root, 'PLAY_REPORTS_BUCKET') ?? '').trim();
|
||||
if (!v) return null;
|
||||
if (!/^(gs:\/\/)?pubsite_prod(_rev)?_\d+\/?$/.test(v)) throw new Error(`reports bucket "${v}": the Play reports bucket, e.g. "pubsite_prod_rev_<number>" (play.reportsBucket, env PLAY_REPORTS_BUCKET or the profile field reportsBucket)`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Credential paths that do not exist (a machine move left another computer's absolute paths) and an upload keystore
|
||||
* the stored password / alias do not open: each names the field. `keytool` runs only when it is on PATH. */
|
||||
export function credentialFileIssues(root: string, run: (argv: string[]) => { ok: boolean; out: string } | null = keytool): string[] {
|
||||
|
|
|
|||
|
|
@ -148,3 +148,95 @@ export const parsePlayCsv = (text: string): Record<string, string>[] => {
|
|||
const keys = head.map(h => h.trim());
|
||||
return rest.map(r => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()])));
|
||||
};
|
||||
|
||||
// ---- day ranges (a collector reads N days in one JSON) ----
|
||||
|
||||
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const addDay = (d: string, n: number): string => new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
|
||||
export const MAX_RANGE_DAYS = 366;
|
||||
|
||||
/** Every date from..to inclusive (YYYY-MM-DD). Pure. */
|
||||
export function dateRange(from: string, to: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let d = from; d <= to; d = addDay(d, 1)) out.push(d);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* --days N (the N days ending yesterday) or --from/--to (inclusive; --to defaults to yesterday): the dates to read, or
|
||||
* null when none of the three is given. Daily reports of both stores come out the next day, so the newest day may be
|
||||
* missing; that is reported, not an error. Pure.
|
||||
*/
|
||||
export function reportRange(o: { days?: string; from?: string; to?: string }, now = new Date()): { from: string; to: string; dates: string[] } | null {
|
||||
if (o.days === undefined && o.from === undefined && o.to === undefined) return null;
|
||||
if (o.days !== undefined && (o.from !== undefined || o.to !== undefined)) throw new Error('--days or --from/--to, not both');
|
||||
const yesterday = addDay(now.toISOString().slice(0, 10), -1);
|
||||
let from: string, to: string;
|
||||
if (o.days !== undefined) {
|
||||
const n = Number(o.days);
|
||||
if (!Number.isInteger(n) || n < 1 || n > MAX_RANGE_DAYS) throw new Error(`--days: 1-${MAX_RANGE_DAYS}`);
|
||||
to = yesterday; from = addDay(to, -(n - 1));
|
||||
} else {
|
||||
if (o.from === undefined) throw new Error('--to needs --from YYYY-MM-DD');
|
||||
to = o.to ?? yesterday; from = o.from;
|
||||
if (!DAY_RE.test(from) || !DAY_RE.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('--from / --to: YYYY-MM-DD');
|
||||
if (from > to) throw new Error('--from is after --to');
|
||||
if (dateRange(from, to).length > MAX_RANGE_DAYS) throw new Error(`--from/--to: at most ${MAX_RANGE_DAYS} days`);
|
||||
}
|
||||
return { from, to, dates: dateRange(from, to) };
|
||||
}
|
||||
|
||||
export interface DaySummary { date: string; rows: number; summary: SummaryRow[]; totals: { currency: string; units: number; amount: number }[] }
|
||||
|
||||
/** The days of a range as the collector reads them: per-day summary and totals, totals over the range per currency
|
||||
* (never added across currencies), and the dates that had no report. Pure. */
|
||||
export function rangeReport(days: DaySummary[], dates: string[]): { days: DaySummary[]; totals: { currency: string; units: number; amount: number }[]; missing: string[] } {
|
||||
const have = new Set(days.map(d => d.date));
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
return { days: sorted, totals: totalsByCurrency(sorted.flatMap(d => d.summary)), missing: dates.filter(d => !have.has(d)) };
|
||||
}
|
||||
|
||||
export const rangeCsvRows = (days: DaySummary[]): (string | number)[][] =>
|
||||
[['date', 'key', 'title', 'currency', 'units', 'amount', 'rows'], ...days.flatMap(d => d.summary.map(r => [d.date, r.key, r.title, r.currency, r.units, r.amount, r.rows]))];
|
||||
|
||||
export interface PlayDaySales { date: string; currency: string; orders: number; refunds: number; itemPrice: number; taxes: number; charged: number; refunded: number; net: number }
|
||||
|
||||
/**
|
||||
* Play estimated sales rows -> one line per order-charged date and currency of sale. charged = Charged Amount (tax
|
||||
* included) of the Charged rows, refunded = the Refund rows (always negative), net = charged + refunded; itemPrice and
|
||||
* taxes are the Charged rows' Item Price and Taxes Collected. Rows outside from..to are left out. Estimated figures
|
||||
* before Google's service fee: the earnings report (monthly) has the proceeds. Pure.
|
||||
*/
|
||||
export function summarizePlaySalesByDay(rows: Record<string, string>[], from: string, to: string): PlayDaySales[] {
|
||||
const m = new Map<string, PlayDaySales>();
|
||||
for (const r of rows) {
|
||||
const date = (r['Order Charged Date'] ?? '').slice(0, 10);
|
||||
if (!DAY_RE.test(date) || date < from || date > to) continue;
|
||||
const status = r['Financial Status'] ?? '';
|
||||
const refund = /refund|chargeback/i.test(status);
|
||||
if (!refund && !/^charge/i.test(status)) continue;
|
||||
const currency = r['Currency of Sale'] ?? '';
|
||||
const id = `${date}\u0000${currency}`;
|
||||
const x = m.get(id) ?? { date, currency, orders: 0, refunds: 0, itemPrice: 0, taxes: 0, charged: 0, refunded: 0, net: 0 };
|
||||
const amount = num(r['Charged Amount']);
|
||||
if (refund) { x.refunds++; x.refunded += -Math.abs(amount); }
|
||||
else { x.orders++; x.charged += amount; x.itemPrice += num(r['Item Price']); x.taxes += num(r['Taxes Collected']); }
|
||||
m.set(id, x);
|
||||
}
|
||||
return [...m.values()].map(x => ({ ...x, itemPrice: round2(x.itemPrice), taxes: round2(x.taxes), charged: round2(x.charged), refunded: round2(x.refunded), net: round2(x.charged + x.refunded) }))
|
||||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
|
||||
}
|
||||
|
||||
/** Range totals per currency of the daily Play sales lines. Pure. */
|
||||
export function playSalesTotals(days: PlayDaySales[]): { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }[] {
|
||||
const m = new Map<string, { currency: string; orders: number; refunds: number; charged: number; refunded: number; net: number }>();
|
||||
for (const d of days) {
|
||||
const t = m.get(d.currency) ?? { currency: d.currency, orders: 0, refunds: 0, charged: 0, refunded: 0, net: 0 };
|
||||
t.orders += d.orders; t.refunds += d.refunds; t.charged = round2(t.charged + d.charged); t.refunded = round2(t.refunded + d.refunded); t.net = round2(t.net + d.net);
|
||||
m.set(d.currency, t);
|
||||
}
|
||||
return [...m.values()].sort((a, b) => (a.currency < b.currency ? -1 : a.currency > b.currency ? 1 : 0));
|
||||
}
|
||||
|
||||
/** The months (YYYY-MM) a date window touches, for the Play sales file names. Pure. */
|
||||
export const monthsOfRange = (from: string, to: string): string[] => [...new Set(dateRange(from, to).map(d => d.slice(0, 7)))];
|
||||
|
|
|
|||
|
|
@ -412,8 +412,9 @@ export const TOOLS: ToolDef[] = [
|
|||
description: 'Download a sales or finance report into .storebound/reports/ (gitignored) and print a summary per product and currency (amounts never added across currencies) plus a summary CSV. Apple needs ASC_VENDOR_NUMBER and a team key; Play needs play.reportsBucket and "View financial data". dry_run shows the request only.',
|
||||
props: { kind: { type: 'string', enum: ['apple-sales', 'apple-finance', 'play-earnings', 'play-sales'] }, month: { type: 'string', pattern: '^\\d{4}-\\d{2}$', description: 'YYYY-MM (apple-finance: Apple fiscal month; Play reports)' },
|
||||
date: { type: 'string', pattern: '^\\d{4}(-\\d{2}(-\\d{2})?)?$', description: 'apple-sales report date (DAILY / WEEKLY YYYY-MM-DD, MONTHLY YYYY-MM, YEARLY YYYY)' },
|
||||
days: { type: 'integer', minimum: 1, maximum: 366, description: 'apple-sales / play-sales: the last N days as one per-day JSON' }, from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'apple-sales / play-sales: first day of a range' }, to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'last day of the range (default yesterday)' },
|
||||
frequency: { type: 'string', enum: ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'] }, type: { type: 'string', enum: ['SALES', 'PRE_ORDER', 'SUBSCRIPTION', 'SUBSCRIPTION_EVENT', 'SUBSCRIBER', 'SUBSCRIPTION_OFFER_CODE_REDEMPTION', 'WIN_BACK_ELIGIBILITY'] } },
|
||||
required: ['kind'], args: i => [String(i.kind), ...opt('month', i.month), ...opt('date', i.date), ...opt('frequency', i.frequency), ...opt('type', i.type), '--json'] },
|
||||
required: ['kind'], args: i => [String(i.kind), ...opt('month', i.month), ...opt('days', i.days), ...opt('from', i.from), ...opt('to', i.to), ...opt('date', i.date), ...opt('frequency', i.frequency), ...opt('type', i.type), '--json'] },
|
||||
{ name: 'triage', title: 'Crash and feedback triage', command: ['triage'], write: false, network: true,
|
||||
description: 'Read-only: TestFlight crash reports (signature from each crash log), TestFlight screenshot feedback and Google Play error reports of the top issues, grouped by signature with their top devices, OS versions and builds. Tester emails are never read.',
|
||||
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 14 }, build: { type: 'string', pattern: '^[0-9][0-9.]*$' },
|
||||
|
|
|
|||
138
cli/reports.ts
138
cli/reports.ts
|
|
@ -3,6 +3,9 @@
|
|||
// storebound reports apple-finance --month YYYY-MM [--detail] [--region ZZ] [--vendor N]
|
||||
// storebound reports play-earnings --month YYYY-MM
|
||||
// storebound reports play-sales --month YYYY-MM
|
||||
// a day range in one JSON (per-day summary and totals per currency; days with no report listed in "missing", not an error):
|
||||
// storebound reports apple-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (daily SALES reports)
|
||||
// storebound reports play-sales --days 30 | --from YYYY-MM-DD [--to YYYY-MM-DD] --json (per order-charged date and currency)
|
||||
// common: [--out .storebound/reports] [--csv <summary.csv>] [--dry] [--json]
|
||||
// The raw report is saved as it came (the TSV / the CSV out of the zip) next to a <name>.summary.csv with only
|
||||
// aggregates (per product and currency; amounts are never added across currencies). --dry prints what would be
|
||||
|
|
@ -14,15 +17,15 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|||
import { dirname, join } from 'node:path';
|
||||
import { ascClient } from './apple/asc';
|
||||
import { bucketName } from './lib/analytics';
|
||||
import { ascVendorNumber } from './lib/credentials';
|
||||
import { ascVendorNumber, playReportsBucket } from './lib/credentials';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { need } from './lib/ctx';
|
||||
import { toCsv } from './lib/csv';
|
||||
import { STORAGE_READ_SCOPE } from './lib/google-auth';
|
||||
import { done, log, printJson, warn } from './lib/log';
|
||||
import {
|
||||
APPLE_SALES_COMBOS, appleFinancePath, appleReportDate, appleSalesPath, decodeReport, parsePlayCsv, parseTsv, playReportPrefix, type SummaryRow, summarizeAppleFinance,
|
||||
summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, summaryCsvRows, totalsByCurrency,
|
||||
APPLE_SALES_COMBOS, appleFinancePath, appleReportDate, appleSalesPath, type DaySummary, decodeReport, monthsOfRange, parsePlayCsv, parseTsv, playReportPrefix, playSalesTotals, rangeCsvRows, rangeReport,
|
||||
reportRange, type SummaryRow, summarizeAppleFinance, summarizeAppleSales, summarizePlayEarnings, summarizePlaySales, summarizePlaySalesByDay, summaryCsvRows, totalsByCurrency,
|
||||
} from './lib/reports';
|
||||
import { textTable } from './lib/table';
|
||||
import { unzip } from './lib/zip';
|
||||
|
|
@ -30,7 +33,15 @@ import { googleClient } from './play/google';
|
|||
import * as rt from './lib/rt';
|
||||
|
||||
const TAG = 'reports';
|
||||
const USAGE = 'usage: storebound reports apple-sales | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM';
|
||||
const USAGE = 'usage: storebound reports apple-sales [--days N | --from YYYY-MM-DD [--to ...]] | apple-finance --month YYYY-MM | play-earnings --month YYYY-MM | play-sales --month YYYY-MM | play-sales --days N | --from YYYY-MM-DD [--to ...]';
|
||||
|
||||
/** Runs fn over items, at most `limit` at a time, results in item order. */
|
||||
async function pool<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]!); } }));
|
||||
return out;
|
||||
}
|
||||
|
||||
function show(name: string, rows: SummaryRow[], json: boolean, extra: Record<string, unknown>): void {
|
||||
const totals = totalsByCurrency(rows);
|
||||
|
|
@ -57,6 +68,28 @@ export function appleReportHint(msg: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
type Google = ReturnType<typeof googleClient>;
|
||||
|
||||
/** The CSV rows of a Play report month (all zips under the prefix, all CSVs in them); null when the bucket has none. */
|
||||
async function playMonth(g: Google, bucket: string, kind: 'earnings' | 'sales', month: string): Promise<{ rows: Record<string, string>[]; text: string } | null> {
|
||||
const prefix = playReportPrefix(kind, month);
|
||||
const list = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o?prefix=${encodeURIComponent(prefix)}`;
|
||||
const items = ((await g.json<{ items?: { name: string }[] }>('GET', list)).items ?? []).filter(o => o.name.endsWith('.zip'));
|
||||
if (!items.length) return null;
|
||||
const rows: Record<string, string>[] = [];
|
||||
let text = '';
|
||||
for (const o of items) {
|
||||
log(TAG, `gs://${bucketName(bucket)}/${o.name}`);
|
||||
const zip = await g.bytes(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(o.name)}?alt=media`);
|
||||
for (const e of unzip(zip).filter(x => x.name.toLowerCase().endsWith('.csv'))) {
|
||||
const t = decodeReport(e.data);
|
||||
text += (text ? '\n' : '') + t;
|
||||
rows.push(...parsePlayCsv(t));
|
||||
}
|
||||
}
|
||||
return { rows, text };
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
|
|
@ -82,9 +115,46 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
path = appleFinancePath(vendor, month, { type, region: a.get('region') });
|
||||
name = `apple-${type === 'FINANCIAL' ? 'finance' : 'finance-detail'}-${month}.tsv`;
|
||||
}
|
||||
const range = sub === 'apple-sales' ? reportRange({ days: a.get('days'), from: a.get('from'), to: a.get('to') }) : null;
|
||||
if (sub === 'apple-finance' && (a.has('days') || a.has('from') || a.has('to'))) throw new Error('--days / --from / --to are for apple-sales and play-sales (finance reports are monthly: --month)');
|
||||
if (range) {
|
||||
const type = a.str('type', 'SALES').toUpperCase();
|
||||
if (type !== 'SALES') throw new Error('--days / --from / --to read the daily SALES reports: leave out --type (or SALES)');
|
||||
if (a.has('frequency') && a.str('frequency', 'DAILY').toUpperCase() !== 'DAILY') throw new Error('--days / --from / --to read DAILY reports');
|
||||
const pathOf = (date: string) => appleSalesPath(vendor, { type: 'SALES', frequency: 'DAILY', date });
|
||||
const shownRange = `GET ${pathOf('<date>').replace(vendor, '<vendor>')} for ${range.dates.length} day(s) ${range.from}..${range.to}`;
|
||||
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: shownRange, dir: out }); else log(TAG, `would ${shownRange} -> ${out}`); return; }
|
||||
const c = await ascClient(root, config.apple?.bundleId ?? '', config.apple?.appId);
|
||||
mkdirSync(out, { recursive: true });
|
||||
let rowCount = 0;
|
||||
const got = await pool(range.dates, 4, async (date): Promise<DaySummary | null> => {
|
||||
let gz: Uint8Array | null;
|
||||
try { gz = await c.bytes(pathOf(date)); } catch (e) { const hint = appleReportHint((e as Error).message); throw new Error(hint ? `${(e as Error).message}
|
||||
${hint}` : (e as Error).message); }
|
||||
if (!gz) return null;
|
||||
const text = new TextDecoder().decode(gz[0] === 0x1f && gz[1] === 0x8b ? rt.gunzipSync(gz as Uint8Array<ArrayBuffer>) : gz);
|
||||
writeFileSync(join(out, `apple-sales-daily-${date}.tsv`), text);
|
||||
const rows = parseTsv(text);
|
||||
rowCount += rows.length;
|
||||
const summary = summarizeAppleSales(rows);
|
||||
return { date, rows: rows.length, summary, totals: totalsByCurrency(summary) };
|
||||
});
|
||||
const rep = rangeReport(got.filter((x): x is DaySummary => !!x), range.dates);
|
||||
const summaryFile = csvOut ?? join(out, `apple-sales-daily-${range.from}_${range.to}.summary.csv`);
|
||||
mkdirSync(dirname(summaryFile), { recursive: true });
|
||||
writeFileSync(summaryFile, toCsv(rangeCsvRows(rep.days)));
|
||||
if (rep.missing.length) warn(TAG, `no report for ${rep.missing.join(', ')} (no sales then, or not published yet: daily reports come out the next day)`);
|
||||
if (a.has('json')) printJson({ report: 'apple-sales', type: 'SALES', frequency: 'DAILY', from: range.from, to: range.to, rows: rowCount, days: rep.days, totals: rep.totals, missing: rep.missing, files: { dir: out, summary: summaryFile } });
|
||||
else {
|
||||
console.log(rep.days.length ? textTable(['date', 'currency', 'units', 'amount'], rep.days.flatMap(d => d.totals.map(t => [d.date, t.currency, t.units, t.amount.toFixed(2)]))) : 'no rows');
|
||||
if (rep.totals.length) console.log('\n' + textTable(['currency', 'units', 'total'], rep.totals.map(t => [t.currency, t.units, t.amount.toFixed(2)])));
|
||||
}
|
||||
done(TAG, `${rep.days.length}/${range.dates.length} day(s), ${rowCount} rows -> ${out}; summary ${summaryFile}`);
|
||||
return;
|
||||
}
|
||||
const shown = path.replace(vendor, '<vendor>');
|
||||
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET ${shown}`, file: join(out, name) }); else log(TAG, `would GET ${shown} -> ${join(out, name)}`); return; }
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const c = await ascClient(root, config.apple?.bundleId ?? '', config.apple?.appId);
|
||||
log(TAG, `GET ${shown}`);
|
||||
let gz: Uint8Array | null;
|
||||
try { gz = await c.bytes(path); } catch (e) { const hint = appleReportHint((e as Error).message); throw new Error(hint ? `${(e as Error).message}\n${hint}` : (e as Error).message); }
|
||||
|
|
@ -100,27 +170,51 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
|
||||
if (sub === 'play-earnings' || sub === 'play-sales') {
|
||||
const bucket = config.play?.reportsBucket;
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-financial', '> Copy Cloud Storage URI')})`);
|
||||
const month = need(a.get('month'), '--month YYYY-MM');
|
||||
const bucket = playReportsBucket(root, config.play?.reportsBucket);
|
||||
if (!bucket) throw new Error(`play.reportsBucket is not set (env PLAY_REPORTS_BUCKET or the credential profile's reportsBucket also work; ${consoleLink('play.reports-financial', '> Copy Cloud Storage URI')})`);
|
||||
const kind = sub === 'play-earnings' ? 'earnings' : 'sales';
|
||||
const range = reportRange({ days: a.get('days'), from: a.get('from'), to: a.get('to') });
|
||||
if (range && kind === 'earnings') throw new Error('play-earnings is monthly (--month YYYY-MM); the daily revenue is play-sales --days N');
|
||||
if (range) {
|
||||
const months = monthsOfRange(range.from, range.to);
|
||||
const where = months.map(m => `gs://${bucketName(bucket)}/${playReportPrefix('sales', m)}*.zip`);
|
||||
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET ${where.join(' ')}`, from: range.from, to: range.to, dir: out }); else log(TAG, `would read ${where.join(', ')} -> ${out}`); return; }
|
||||
const g = googleClient(root, STORAGE_READ_SCOPE);
|
||||
const all: Record<string, string>[] = [];
|
||||
const missingMonths: string[] = [];
|
||||
const files: string[] = [];
|
||||
mkdirSync(out, { recursive: true });
|
||||
for (const m of months) {
|
||||
const r = await playMonth(g, bucket, 'sales', m);
|
||||
if (!r) { missingMonths.push(m); continue; }
|
||||
all.push(...r.rows);
|
||||
const file = join(out, `play-sales-${m}.csv`);
|
||||
writeFileSync(file, r.text);
|
||||
files.push(file);
|
||||
}
|
||||
const days = summarizePlaySalesByDay(all, range.from, range.to);
|
||||
const have = new Set(days.map(d => d.date));
|
||||
const emptyDays = range.dates.filter(d => !have.has(d));
|
||||
const summaryFile = csvOut ?? join(out, `play-sales-daily-${range.from}_${range.to}.summary.csv`);
|
||||
mkdirSync(dirname(summaryFile), { recursive: true });
|
||||
writeFileSync(summaryFile, toCsv([['date', 'currency', 'orders', 'refunds', 'item_price', 'taxes', 'charged', 'refunded', 'net'], ...days.map(d => [d.date, d.currency, d.orders, d.refunds, d.itemPrice, d.taxes, d.charged, d.refunded, d.net])]));
|
||||
const totals = playSalesTotals(days);
|
||||
if (missingMonths.length) warn(TAG, `no sales report for month(s) ${missingMonths.join(', ')} in the bucket (not published yet, or the service account lacks "View financial data")`);
|
||||
if (a.has('json')) printJson({ report: 'play-sales', from: range.from, to: range.to, rows: all.length, days, totals, emptyDays, missingMonths, estimated: true, files: { raw: files, summary: summaryFile } });
|
||||
else {
|
||||
console.log(days.length ? textTable(['date', 'currency', 'orders', 'refunds', 'charged', 'refunded', 'net'], days.map(d => [d.date, d.currency, d.orders, d.refunds, d.charged.toFixed(2), d.refunded.toFixed(2), d.net.toFixed(2)])) : 'no orders in the range');
|
||||
if (totals.length) console.log('\n' + textTable(['currency', 'orders', 'refunds', 'charged', 'refunded', 'net'], totals.map(t => [t.currency, t.orders, t.refunds, t.charged.toFixed(2), t.refunded.toFixed(2), t.net.toFixed(2)])));
|
||||
}
|
||||
done(TAG, `${days.length} order day line(s) of ${range.dates.length} day(s) -> ${summaryFile}`);
|
||||
return;
|
||||
}
|
||||
const month = need(a.get('month'), '--month YYYY-MM (or --days N / --from YYYY-MM-DD for play-sales)');
|
||||
const prefix = playReportPrefix(kind, month);
|
||||
const list = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o?prefix=${encodeURIComponent(prefix)}`;
|
||||
if (dry) { if (a.has('json')) printJson({ dryRun: true, request: `GET gs://${bucketName(bucket)}/${prefix}*.zip`, dir: out }); else log(TAG, `would read gs://${bucketName(bucket)}/${prefix}*.zip -> ${out}`); return; }
|
||||
const g = googleClient(root, STORAGE_READ_SCOPE);
|
||||
const items = ((await g.json<{ items?: { name: string }[] }>('GET', list)).items ?? []).filter(o => o.name.endsWith('.zip'));
|
||||
if (!items.length) { done(TAG, `no ${prefix}*.zip in the bucket (not published yet: earnings come early in the next month; or the service account lacks "View financial data")`); if (a.has('json')) printJson({ report: kind, month, empty: true }); return; }
|
||||
const all: Record<string, string>[] = [];
|
||||
let text = '';
|
||||
for (const o of items) {
|
||||
log(TAG, `gs://${bucketName(bucket)}/${o.name}`);
|
||||
const zip = await g.bytes(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(o.name)}?alt=media`);
|
||||
for (const e of unzip(zip).filter(x => x.name.toLowerCase().endsWith('.csv'))) {
|
||||
const t = decodeReport(e.data);
|
||||
text += (text ? '\n' : '') + t;
|
||||
all.push(...parsePlayCsv(t));
|
||||
}
|
||||
}
|
||||
const month1 = await playMonth(g, bucket, kind, month);
|
||||
if (!month1) { done(TAG, `no ${prefix}*.zip in the bucket (not published yet: earnings come early in the next month; or the service account lacks "View financial data")`); if (a.has('json')) printJson({ report: kind, month, empty: true }); return; }
|
||||
const all = month1.rows, text = month1.text;
|
||||
if (!all.length) warn(TAG, 'the zip(s) held no CSV rows');
|
||||
const summary = kind === 'earnings' ? summarizePlayEarnings(all) : summarizePlaySales(all);
|
||||
const files = save(out, `play-${kind}-${month}.csv`, text, summary, csvOut);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue