console: Play App content card Saves are the agent's (declaration-save; Send changes for review, Submit, Publish, Restart review stay forbidden), one owner batch per plan with the IARC Terms tick before the run, console plan play-app-content --all, New App fields typed not set, play.appId from the Create app redirect (console verify --url --write) and storebound play app-id across app list pages

This commit is contained in:
CRTkafa 2026-10-13 05:43:56 +08:00
parent 1366e7e231
commit f741c15829
29 changed files with 1029 additions and 165 deletions

View file

@ -22,6 +22,9 @@ export interface FieldDef {
options?: Record<string, Name[]>;
/** An agreement checkbox: located and highlighted, NEVER ticked (whatever the answer says). */
agreement?: boolean;
/** An agreement the page's Next waits for (the IARC Terms of Use, verified 2026-10-13): the flow waits until the owner
* ticked it instead of pressing a Next that does nothing. */
blocksNext?: boolean;
/** radio: the options count only inside the named group (no group on the page: the field is not there). The IARC
* follow-ups, whose Yes / No options would otherwise match every question of the page. */
scoped?: boolean;
@ -157,7 +160,7 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
{ title: 'Email address and category', fields: [
{ id: 'email', kind: 'text', names: ['Email address', 'Email'] },
{ id: 'category', kind: 'radio', options: { 'Game': [/^Game\b/i], 'Social or communication': [/^Social/i], 'All other app types': [/^All other app types/i, /^Utility/i] } },
{ id: 'agree:iarcTerms', kind: 'checkbox', agreement: true, names: [/IARC Terms of Use/i, /terms of use/i] },
{ id: 'agree:iarcTerms', kind: 'checkbox', agreement: true, blocksNext: true, names: [/IARC Terms of Use/i, /terms of use/i] },
] },
{ title: 'Questionnaire', fields: answers.filter(a => a.id.startsWith('q:')).map(a => ({ id: a.id, kind: 'radio' as const, names: IARC_NAMES[a.id.slice(2)], options: yesNo() })) },
// follow-ups a Yes reveals on the same page (and Miscellaneous): optional, filled when they appear
@ -191,9 +194,42 @@ const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}
/** Controls never pressed by storebound or by an agent following `console plan`, whatever the form: help panels and
* the final buttons (the agent-facing never-press list is built from this and FORM_BUTTONS). */
export const NEVER_PRESS = ['Publish', 'Submit', 'Create', 'Create app', 'Send', 'Release', 'Definitions and Examples', '?', 'More information'];
/** Play Console's publishing buttons (Publishing overview, verified 2026-10-12): in every Play plan's never-press list. */
export const PLAY_NEVER_PRESS = ['Send changes for review', 'Submit N changes for review', 'Restart review'];
/** true when storebound must not click a control with this accessible name. */
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase());
/** true when storebound must not click a control with this accessible name (field clicks: absolute; the scoped
* exceptions are navAllowed, createAppAllowed, setUpAllowed, flowAllowed and declarationSaveAllowed). */
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase())
|| publishingControl(name);
/**
* Buttons that send something to Google or Apple, publish, or restart a review: never pressed by storebound or an agent,
* in any form and by any exception (English and Turkish). Play's "Send changes for review", "Submit N changes for
* review" and "Restart review" (Publishing overview; verified 2026-10-12), Submit, Publish, a release or rollout.
*/
const PUBLISHING = /(?<![\p{L}])(?:(?:send (?:\d+ )?changes? for review|submit|publish|restart (?:the |your )?review|send for review|release|roll ?out)(?![\p{L}])|yayınla|yayımla|gönder|incelemeye|incelemeyi yeniden)/iu;
export const publishingControl = (name: string): boolean => PUBLISHING.test(name.normalize('NFC').replace(/\s+/g, ' '));
/** The exact names of a Play App content card's Save (English and Turkish). */
const CARD_SAVE = ['save', 'kaydet'];
/** Where a card Save sits: the page URL (an App content card is /app/<appId>/app-content/<card>, not the overview). */
export interface CardWhere { url: string }
/**
* The App content declaration Save (verified 2026-10-13): saving a card (Privacy policy, Ads, Sign in details,
* Advertising ID, Government apps, Financial features, Health apps, Target audience, the content rating questionnaire
* and its Summary, Data safety) is not public: Play sends it to Google only with the next "Send changes for review" in
* Publishing overview, which stays the owner's. So the agent (and the scripted engine) presses it: a button whose whole
* name is exactly Save / Kaydet on a Play Console App content card page. Everything that publishes or sends
* (publishingControl) stays refused here too.
*/
export function declarationSaveAllowed(name: string, role: string, where: CardWhere): boolean {
const n = name.trim().replace(/\s+/g, ' ').toLocaleLowerCase('tr');
if (role !== 'button' || !CARD_SAVE.includes(n) || publishingControl(name)) return false;
try {
const u = new URL(where.url);
return /\/developers\/\d{1,25}\/app\/\d{1,25}\/app-content\/(?!overview(?:\/|$))[a-z-]+/.test(u.pathname);
} catch { return false; }
}
/**
* Ticking a checkbox / radio never saves or sends anything, so the button word list above does not apply to them (their
@ -395,7 +431,9 @@ export function navAllowed(name: string, role: string): boolean {
// Google "Target audience and content" https://support.google.com/googleplay/android-developer/answer/9867159 ;
// Google "Create and set up your app" https://support.google.com/googleplay/android-developer/answer/9859152
export type ButtonClass = 'navigation' | 'draft-save' | 'final-submit';
// A fourth class (2026-10-13): declaration-save, an App content card's Save. It stores a declaration that Google gets
// only with the next "Send changes for review" (the owner's, in Publishing overview), so the agent presses it.
export type ButtonClass = 'navigation' | 'draft-save' | 'declaration-save' | 'final-submit';
export interface ButtonRule { button: string; class: ButtonClass; where: string; auto: boolean }
const NEXT: Name[] = ['Next', 'Continue', 'İleri', 'Sonraki', 'Devam', 'Devam et'];
@ -448,13 +486,15 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
{ button: 'Start / Start questionnaire', class: 'navigation', where: 'App content > Content ratings (content-rating-overview): opens the questionnaire', auto: true },
{ button: 'Next', class: 'navigation', where: 'after the category, and from the questions to the Summary', auto: true },
{ button: 'Save draft / Save (questionnaire page)', class: 'draft-save', where: 'questionnaire: stores the answers as the one draft of the app; nothing goes to IARC before the Summary\'s Save', auto: true },
{ button: 'IARC Terms of Use box', class: 'final-submit', where: 'page 1: an agreement, never ticked by the agent or storebound', auto: false },
{ button: 'Save (Summary page)', class: 'final-submit', where: 'Summary (per-authority ratings): sends the questionnaire to IARC and applies the ratings (verified 2026-10-12: "Save", not "Submit")', auto: false },
{ button: 'IARC Terms of Use box', class: 'final-submit', where: 'page 1: an agreement, never ticked by the agent or storebound (the owner ticks it before the run: Next does nothing until it is ticked, verified 2026-10-13)', auto: false },
{ button: 'Save (Summary page)', class: 'declaration-save', where: 'Summary (per-authority ratings; "Save", not "Submit", verified 2026-10-12): stores the rating declaration; Google gets it only with the next Send changes for review', auto: true },
{ button: 'Send changes for review', class: 'final-submit', where: 'Publishing overview, after every App content card: sends the changes to Google (the owner\'s)', auto: false },
],
'play-target-audience': [
{ button: 'Start / Manage', class: 'navigation', where: 'App content: opens the section', auto: true },
{ button: 'Next', class: 'navigation', where: 'between Target age, App details, Ads and Store presence', auto: true },
{ button: 'Save', class: 'final-submit', where: 'the last page: stores the declaration, which Google then reviews', auto: false },
{ button: 'Save', class: 'declaration-save', where: 'the last page: stores the declaration; Google gets it only with the next Send changes for review', auto: true },
{ button: 'Send changes for review', class: 'final-submit', where: 'Publishing overview, after every App content card: sends the changes to Google (the owner\'s)', auto: false },
],
};
@ -491,10 +531,14 @@ export function flowClass(form: FormId, name: string, role: string, where: FlowW
return null;
}
if (nameIs(n, SAVE_DRAFT)) return 'draft-save';
if (nameIs(n, SAVE) && DECLARATION_SAVE_FORMS.includes(form)) return 'declaration-save';
if (nameIs(n, SAVE) || nameIs(n, DONE)) return 'final-submit';
return null;
}
/** The scripted forms whose Save is an App content declaration save (the agent's: verified 2026-10-13). */
export const DECLARATION_SAVE_FORMS: FormId[] = ['play-content-rating', 'play-target-audience'];
/** The texts that open one data type's questions ("Set Up Coarse Location"; Turkish "<type> ayarla"), for its labels. */
export const setUpTexts = (t: string): string[] => typeLabels(t).flatMap(l => [`Set Up ${l}`, `${l} ayarla`]);
@ -514,13 +558,17 @@ export function setUpAllowed(text: string, where: FlowWhere): boolean {
export const AUTO_DRAFT_SAVE: FormId[] = ['apple-app-privacy', 'play-content-rating'];
/**
* The flow click gate: a navigation control of the form, App Privacy's Save inside a questionnaire dialog, or the
* content rating's Save draft (drafts: nothing is public or sent before Publish / Submit). Everything else (Publish,
* Submit, Create, a final Save, an unknown control) stays the owner's click.
* The flow click gate: a navigation control of the form, App Privacy's Save inside a questionnaire dialog, the
* content rating's Save draft (drafts: nothing is public or sent before Publish / Submit), or a Play App content
* declaration's Save (content rating Summary, target audience: sent to Google only with the owner's Send changes for
* review). Everything else (Publish, Submit, Create, Send changes for review, Restart review, an unknown control)
* stays the owner's click.
*/
export function flowAllowed(form: FormId, name: string, role: string, where: FlowWhere): boolean {
const c = flowClass(form, name, role, where);
if (publishingControl(name)) return false;
if (c === 'navigation') return true;
if (c === 'declaration-save') return DECLARATION_SAVE_FORMS.includes(form) && !where.inDialog;
if (c !== 'draft-save' || !AUTO_DRAFT_SAVE.includes(form)) return false;
return form === 'apple-app-privacy' ? where.inDialog && where.privacyPage : true;
}