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:
CRTkafa 2026-10-26 10:20:27 +07:00
parent b59665c661
commit 5833776a4b
15 changed files with 651 additions and 60 deletions

View file

@ -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 };
}
/**