Play console steps without an API as agent plans: play-store-settings (App category; play listing cannot set it), play-app-content (every card in order, one owner batch) and its cards (privacy policy, ads, sign in details, advertising ID, government, financial, health), play-production-countries and play-production-release; IARC plan with the Terms of Use agreement, follow-ups (console.contentRating.details, products[].randomRewards, social) and the Summary's final Save; config play.category / countries / managedPublishing, console.appContent; check, validate, play status and play release reminders; inPageLoop helper and material checkbox clicks; App Privacy published signal; first IAPs via apple iap-submit --with-version. Pitfalls 27, 28

This commit is contained in:
CRTkafa 2026-10-12 10:16:34 +08:00
parent dbd9f2973c
commit b7bfe89151
37 changed files with 1624 additions and 121 deletions

View file

@ -16,13 +16,23 @@
// https://support.google.com/googleplay/android-developer/answer/9859655 (content ratings)
// https://support.google.com/googleplay/android-developer/answer/9867159 (target audience and content)
import { AGE_RATING_NONE } from '../apple/appinfo';
import { type AgeGroup, AGE_GROUPS, appleName, IARC_QUESTIONS, type IarcQuestion, profileOf, type ShipConfig } from './config';
import { type AgeGroup, AGE_GROUPS, appleName, IARC_DETAIL_KEYS, IARC_DETAIL_SECTIONS, IARC_QUESTIONS, type IarcDetailSection, type IarcQuestion, profileOf, type ShipConfig } from './config';
import { APPLE_PRIVACY, applePurpose } from './declarations';
import { APPLE_DATA_TYPE_IDS, APPLE_PRIVACY_TYPES, APPLE_PURPOSE_IDS, type AppleDataTypeId, type AppleTypeAnswer, purposeName } from './app-privacy';
import { maskUser } from './credentials';
export const FORM_IDS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] as const;
export type FormId = typeof FORM_IDS[number];
/** Forms with a console plan only (the agent drives them; no scripted fill): Play Store settings (the App category the
* API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */
export const PLAN_ONLY_FORM_IDS = ['play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details',
'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release'] as const;
export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number];
/** Every form `console plan` / `console verify` know. */
export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const;
export type PlanFormId = typeof PLAN_FORM_IDS[number];
export const isPlanForm = (s: string | undefined): s is PlanFormId => !!s && (PLAN_FORM_IDS as readonly string[]).includes(s);
export const isPlanOnly = (s: string): s is PlanOnlyFormId => (PLAN_ONLY_FORM_IDS as readonly string[]).includes(s);
export type Store = 'apple' | 'play';
export type AnswerValue = string | boolean | null;
@ -79,8 +89,9 @@ export function formMeta(id: FormId, config: ShipConfig): FormMeta {
'Free or paid is an owner check: a free app can never become paid later on Google Play.',
'The Developer Program Policies and US export laws boxes are agreements: storebound never ticks them. Read and tick them yourself, then press Create app.',
] };
case 'play-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Submit', fillable: true,
open: [pickApp, 'Policy > App content > Content rating > Start (or Start new questionnaire)'] };
case 'play-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Save', fillable: true,
open: [pickApp, 'Policy > App content > Content ratings > Start (app-content/content-rating-overview > Start questionnaire)'],
notes: ['The IARC Terms of Use box on page 1 is an agreement: storebound never ticks it.', 'The final button on the Summary (per-authority ratings) is "Save", not "Submit" (verified 2026-10-12): it is the owner\'s.'] };
case 'play-target-audience': return { id, store: 'play', title: 'Play Console: Target audience and content', url: PLAY, stopBefore: 'Save', fillable: true,
open: [pickApp, 'Policy > App content > Target audience and content > Start (or Manage); the Ads declaration is App content > Ads'] };
}
@ -258,18 +269,96 @@ export const IARC_LABELS: Record<IarcQuestion, string> = {
};
const CATEGORY_LABELS = { game: 'Game', social: 'Social or communication', other: 'All other app types' } as const;
/** The IARC follow-up questions (console.contentRating.details), in the page's order: section -> key -> label. The
* structure was recorded on a real questionnaire (2026-10-12); the wording is storebound's. */
export const IARC_DETAIL_LABELS: Record<IarcDetailSection, Record<string, string>> = {
violence: {
humans: 'Violence: against humans or human-like characters (checkbox)', nonHumans: 'Violence: against non-humans (fantasy creatures, animals, robots; checkbox)',
setting: 'Violence: the setting (option)', childlike: 'Violence: in a cartoon or childlike style?', reactions: 'Violence: reactions to harm (checkboxes)',
presentation: 'Violence: how it is presented (checkboxes)', blood: 'Violence: blood level (option)', historicalWar: 'Violence: set in a historical war?',
innocents: 'Violence: against innocent or defenseless characters?', fierceSounds: 'Violence: fierce or realistic sounds?',
},
fear: {}, sexuality: {}, gambling: {}, language: {}, controlledSubstances: {}, crudeHumor: {},
digitalPurchases: { lootBoxes: 'Digital purchases: random items (loot boxes)?', playerTrading: 'Digital purchases: can players trade items with each other?' },
userInteraction: { block: 'User interaction: users can block other users?', report: 'User interaction: users can report users or content?',
moderation: 'User interaction: chat or shared content is moderated?', friendsOnly: 'User interaction: only with accepted friends?' },
miscellaneous: { naziSymbols: 'Miscellaneous: Nazi symbols or references?', koreaHistory: 'Miscellaneous: content about Korean history that may be sensitive?',
terrorism: 'Miscellaneous: terrorism or extremist content?', crimes: 'Miscellaneous: depicts or encourages crimes?' },
};
/** Detail section -> the yes / no topic that reveals it (miscellaneous: always shown). */
const DETAIL_TOPIC: Record<IarcDetailSection, IarcQuestion[] | null> = {
violence: ['violence'], fear: ['fear'], sexuality: ['sexuality'], gambling: ['gambling', 'simulatedGambling'], language: ['language'],
controlledSubstances: ['controlledSubstances'], crudeHumor: ['crudeHumor'], digitalPurchases: ['digitalPurchases'], userInteraction: ['userInteraction'], miscellaneous: null,
};
const SECTION_NAMES: Record<IarcDetailSection, string> = {
violence: 'Violence', fear: 'Fear', sexuality: 'Sexuality', gambling: 'Gambling', language: 'Language', controlledSubstances: 'Controlled Substance',
crudeHumor: 'Crude Humor', digitalPurchases: 'Digital Purchases', userInteraction: 'Miscellaneous (user interaction)', miscellaneous: 'Miscellaneous',
};
export const iarcSectionName = (s: IarcDetailSection): string => SECTION_NAMES[s];
/** The follow-up ids a topic reveals when it is Yes (for the note of an unanswered topic). */
const followUpsOf = (q: IarcQuestion): string => {
const secs = IARC_DETAIL_SECTIONS.filter(s => DETAIL_TOPIC[s]?.includes(q));
const keys = secs.flatMap(s => Object.keys(IARC_DETAIL_KEYS[s]).filter(k => !(s === 'digitalPurchases' && k === 'lootBoxes')).map(k => `${s}.${k}`));
return secs.length ? ` If Yes, the page asks follow-ups: set console.contentRating.details.${keys.length ? keys.join(', ') : `${secs[0]}.other`} too.` : '';
};
function contentRating(config: ShipConfig): Answer[] {
const cr = config.console?.contentRating;
const cr = config.console?.contentRating, det = cr?.details ?? {}, social = config.social;
const out: Answer[] = [ok('email', 'Email address', maskUser(config.app.contactEmail), 'app.contactEmail', 'IARC writes to this address; the page gets the full address from the config.')];
const cat = cr?.category ?? (profileOf(config) === 'game' && config.app.profile ? 'game' : undefined);
out.push(cat ? ok('category', 'Category', CATEGORY_LABELS[cat], cr?.category ? 'console.contentRating.category' : 'app.profile')
: ask('category', 'Category', 'console.contentRating.category', 'Pick the category; set console.contentRating.category to have it filled.'));
const sells = (config.products?.length ?? 0) > 0 || (config.subscriptions?.length ?? 0) > 0;
out.push({ ...ask('agree:iarcTerms', 'IARC Terms of Use', 'none', 'An agreement on page 1 (a checkbox): only the owner ticks it, never the agent or storebound.'), agreement: true });
const products = config.products ?? [];
const sells = products.length > 0 || (config.subscriptions?.length ?? 0) > 0;
const derived = (q: IarcQuestion): [boolean, string] | null => {
if (q === 'digitalPurchases' && sells) return [true, products.length ? 'products' : 'subscriptions'];
if (q === 'lootBoxes') {
if (det.digitalPurchases?.lootBoxes !== undefined) return [det.digitalPurchases.lootBoxes, 'console.contentRating.details.digitalPurchases.lootBoxes'];
if (products.some(p => p.randomRewards === true)) return [true, `products[].randomRewards (${products.filter(p => p.randomRewards).map(p => p.id).join(', ')})`];
if (products.length && products.every(p => p.randomRewards === false) && !config.subscriptions?.length) return [false, 'products[].randomRewards (all false)'];
if (!sells) return [false, 'products (nothing is sold)'];
}
if (q === 'userInteraction' && social) return [social.interaction, 'social.interaction'];
if (q === 'sharesLocation' && social?.sharesLocation !== undefined) return [social.sharesLocation, 'social.sharesLocation'];
return null;
};
const topic = new Map<IarcQuestion, boolean | null>();
for (const q of IARC_QUESTIONS) {
const v = cr?.answers?.[q];
const v = cr?.answers?.[q], d = v === undefined ? derived(q) : null;
topic.set(q, v ?? d?.[0] ?? null);
if (v !== undefined) out.push(ok(`q:${q}`, IARC_LABELS[q], v, `console.contentRating.answers.${q}`));
else if (q === 'digitalPurchases' && sells) out.push(ok(`q:${q}`, IARC_LABELS[q], true, config.products?.length ? 'products' : 'subscriptions'));
else out.push(ask(`q:${q}`, IARC_LABELS[q], `console.contentRating.answers.${q}`, 'Answer from the app\'s real content; set console.contentRating.answers to have it filled.'));
else if (d) out.push(ok(`q:${q}`, IARC_LABELS[q], d[0], d[1], q === 'lootBoxes' && d[0] ? 'Paid random items: ClassInd (Brazil) rated a game with them 18 on a real run (2026-10-12).' : undefined));
else out.push(ask(`q:${q}`, IARC_LABELS[q], `console.contentRating.answers.${q}`, `Answer from the app's real content; set console.contentRating.answers to have it filled.${followUpsOf(q)}`));
}
// the follow-ups a Yes reveals (and Miscellaneous, always shown): from details, else proven facts, else the owner
for (const sec of IARC_DETAIL_SECTIONS) {
const topics = DETAIL_TOPIC[sec];
if (topics && !topics.some(t => topic.get(t) === true)) continue;
const given = (det as Record<string, Record<string, unknown> | undefined>)[sec] ?? {};
const src = (k: string) => `console.contentRating.details.${sec}.${k}`;
for (const [k, kind] of Object.entries(IARC_DETAIL_KEYS[sec])) {
if (sec === 'digitalPurchases' && k === 'lootBoxes') continue; // the q:lootBoxes answer above
const label = IARC_DETAIL_LABELS[sec][k]!, id = `d:${sec}:${k}`, v = given[k];
if (kind === 'labels') {
if (Array.isArray(v)) for (const l of v as string[]) out.push(ok(`${id}:${l}`, `${label}: ${l}`, true, src(k))); // [] = tick none
else out.push(ask(id, label, src(k), `Tick the boxes that fit; set ${src(k)} (the console's labels, [] for none) to have it filled.`));
continue;
}
if (v !== undefined) { out.push(ok(id, label, v as string | boolean, src(k))); continue; }
const fact = sec === 'userInteraction' && social && social[k as 'block'] !== undefined ? [social[k as 'block'] as boolean, `social.${k}`] as const
: sec === 'digitalPurchases' && k === 'playerTrading' && social && !social.interaction ? [false, 'social.interaction (no interaction, so no trading)'] as const : null;
if (fact) out.push(ok(id, label, fact[0], fact[1]));
else out.push(ask(id, label, src(k), `Answer from the app's real content; set ${src(k)} to have it filled.`));
}
const other = Object.entries((given.other as Record<string, boolean | string | string[]> | undefined) ?? {});
for (const [qText, v] of other) {
if (Array.isArray(v)) for (const l of v) out.push(ok(`d:${sec}:other:${qText}:${l}`, `${SECTION_NAMES[sec]}: ${qText}: ${l}`, true, src('other')));
else out.push(ok(`d:${sec}:other:${qText}`, `${SECTION_NAMES[sec]}: ${qText}`, v, src('other')));
}
if (!Object.keys(IARC_DETAIL_KEYS[sec]).length && !other.length)
out.push(ask(`d:${sec}:followUps`, `${SECTION_NAMES[sec]}: the follow-up questions shown after Yes`, src('other'), `Read them on the page once Yes is picked; set ${src('other')} ({ '<question as shown>': true | false | '<option>' | ['<labels>'] }) to have them filled.`));
}
return out;
}