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:
parent
dbd9f2973c
commit
b7bfe89151
37 changed files with 1624 additions and 121 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue