Onboarding live apps without owner questions for what the stores and the code prove: storebound onboard imports the live products and subscriptions with USD / base prices (catalog diff clean), contact values, the App Review contact and demo account (credentials.json, masked), sign-in for review from Apple's review detail and auth code, and the account deletion page from the app's site; App Store keywords count characters (live fields up to 265 bytes accepted); validate accepts the 6.5-inch iPhone set and Play's default-language graphics fallback; Play Reporting API off gives the project's enable link and apps list falls back to local apps; accounts default <profile>; store-only apps (apps add --store --app-id) shown read-only in the desktop (pitfalls 43-52)

This commit is contained in:
CRTkafa 2026-10-13 07:25:01 +08:00
parent d2ce185441
commit e5f148e534
55 changed files with 1648 additions and 158 deletions

View file

@ -17,7 +17,7 @@ import { ascClient, type J } from './apple/asc';
import { type CatalogRow, catalogDiff, fromApplePeriod, fromAppleType, regionOf, type StoreItem, wantedCatalog } from './lib/catalog';
import type { Ctx } from './lib/ctx';
import { done, log, printJson } from './lib/log';
import { fromMoney } from './lib/money';
import { fromMoney, type Money } from './lib/money';
import { listOneTimeProducts } from './play/products';
import { playClient } from './play/client';
import { appleSubPrices, basePlanId } from './lib/subscriptions';
@ -25,7 +25,7 @@ import { textTable } from './lib/table';
const TAG = 'catalog';
async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
export async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const appId = await c.app();
@ -37,11 +37,15 @@ async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
const hasSchedule = !!(await c.maybe(`/v2/inAppPurchases/${p.id}/iapPriceSchedule`))?.data;
const today = new Date().toISOString().slice(0, 10);
// today's price per territory: entries that started (or have no start), the latest start wins; manual over automatic
const rows = hasSchedule ? (await appleCurrent(c, p.id, p.attributes.productId, true)).rows.filter(r => (r.startDate ?? '') <= today)
.sort((x, y) => (x.kind === 'auto' ? 0 : 1) - (y.kind === 'auto' ? 0 : 1) || (x.startDate ?? '').localeCompare(y.startDate ?? '')) : [];
const cur = hasSchedule ? await appleCurrent(c, p.id, p.attributes.productId, true) : null;
const rows = (cur?.rows ?? []).filter(r => (r.startDate ?? '') <= today)
.sort((x, y) => (x.kind === 'auto' ? 0 : 1) - (y.kind === 'auto' ? 0 : 1) || (x.startDate ?? '').localeCompare(y.startDate ?? ''));
const byRegion = new Map(rows.map(r => [r.region, r] as const));
// the price schedule's base territory (alpha-3) and its price today
const base = cur?.base ? byRegion.get(regionOf(cur.base)) : undefined;
out.push({ id: p.attributes.productId, kind: fromAppleType(p.attributes.inAppPurchaseType), state: p.attributes.state, locales,
prices: [...byRegion.values()].map(r => ({ region: r.region, currency: r.currency, price: r.price })) });
prices: [...byRegion.values()].map(r => ({ region: r.region, currency: r.currency, price: r.price })),
...(base ? { base: { region: base.region, currency: base.currency, price: base.price } } : {}) });
}
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
for (const s of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
@ -52,14 +56,14 @@ async function appleItems(ctx: Ctx): Promise<StoreItem[]> {
const promos = (await c.all(`/v1/subscriptions/${s.id}/promotionalOffers?limit=200`)).map((o: J) => o.attributes.offerCode as string);
const codes = (await c.all(`/v1/subscriptions/${s.id}/offerCodes?limit=200`)).map((o: J) => `code:${o.attributes.name}`);
out.push({ id: s.attributes.productId, kind: 'subscription', state: s.attributes.state, period: fromApplePeriod(s.attributes.subscriptionPeriod),
level: s.attributes.groupLevel, locales, prices: prices.map(p => ({ region: regionOf(p.territory), currency: p.currency ?? '', price: p.price })),
level: s.attributes.groupLevel, group: g.attributes.referenceName, locales, prices: prices.map(p => ({ region: regionOf(p.territory), currency: p.currency ?? '', price: p.price })),
offers: [...intro, ...promos, ...codes] });
}
}
return out;
}
async function playItems(ctx: Ctx): Promise<StoreItem[]> {
export async function playItems(ctx: Ctx): Promise<StoreItem[]> {
const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName);
const out: StoreItem[] = [];
@ -69,7 +73,8 @@ async function playItems(ctx: Ctx): Promise<StoreItem[]> {
if (!p) continue;
const opt = p.purchaseOptions?.find(o => o.purchaseOptionId === 'buy') ?? p.purchaseOptions?.[0];
out.push({ id: productId, kind: 'one_time', state: opt?.state, locales: ((p.listings as { languageCode: string }[] | undefined) ?? []).map(l => l.languageCode),
prices: (opt?.regionalPricingAndAvailabilityConfigs ?? []).filter(r => r.price && r.availability !== 'NO_LONGER_AVAILABLE').map(r => ({ region: r.regionCode, currency: r.price!.currencyCode, price: fromMoney(r.price!) })) });
prices: (opt?.regionalPricingAndAvailabilityConfigs ?? []).filter(r => r.price && r.availability !== 'NO_LONGER_AVAILABLE').map(r => ({ region: r.regionCode, currency: r.price!.currencyCode, price: fromMoney(r.price!) })),
...((opt?.newRegionsConfig as { usdPrice?: Money } | undefined)?.usdPrice ? { usd: fromMoney((opt!.newRegionsConfig as { usdPrice: Money }).usdPrice) } : {}) });
}
let page = '';
do {
@ -83,14 +88,15 @@ async function playItems(ctx: Ctx): Promise<StoreItem[]> {
const offers = bp ? ((await play.call<{ subscriptionOffers?: { offerId: string }[] }>('GET', `${play.API}/subscriptions/${encodeURIComponent(s.productId)}/basePlans/${bp.basePlanId}/offers`)).subscriptionOffers ?? []).map(o => o.offerId) : [];
out.push({ id: s.productId, kind: 'subscription', state: bp?.state, period: bp?.autoRenewingBasePlanType?.billingPeriodDuration ?? bp?.prepaidBasePlanType?.billingPeriodDuration,
locales: (s.listings ?? []).map((l: J) => l.languageCode),
prices: (bp?.regionalConfigs ?? []).filter((x: J) => x.price).map((x: J) => ({ region: x.regionCode, currency: x.price.currencyCode, price: fromMoney(x.price) })), offers });
prices: (bp?.regionalConfigs ?? []).filter((x: J) => x.price).map((x: J) => ({ region: x.regionCode, currency: x.price.currencyCode, price: fromMoney(x.price) })), offers,
...(bp?.otherRegionsConfig?.usdPrice ? { usd: fromMoney(bp.otherRegionsConfig.usdPrice) } : {}) });
}
page = r.nextPageToken ?? '';
} while (page);
return out;
}
async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
export async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const appId = await c.app();
@ -106,7 +112,7 @@ async function appleTexts(ctx: Ctx): Promise<RemoteCatalog> {
return out;
}
async function playTexts(ctx: Ctx): Promise<RemoteCatalog> {
export async function playTexts(ctx: Ctx): Promise<RemoteCatalog> {
const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName);
const out: RemoteCatalog = {};