122 lines
7.6 KiB
TypeScript
122 lines
7.6 KiB
TypeScript
// The Play Console app id (play.appId: the digits in /console/u/0/developers/<developerId>/app/<appId>/...), which
|
|
// deep-links every Play console plan. The Play Developer API does not return it, and the console's app list shows 10
|
|
// apps per page with a new app not on page 1 (verified 2026-10-13), so finding it by hand took several steps. Two
|
|
// ways, both agent steps: (1) right after Create app the console redirects to /app/<appId>/app-dashboard: `console
|
|
// verify play-app-record --url <url>` reads it there; (2) `storebound play app-id`: the agent captures the app list
|
|
// pages (the search box or ?page=) and storebound finds the row with the package name. Either way the change to
|
|
// play.appId is shown as a dry diff first and written with --write (an upsert). Pure, except writePlayAppId.
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import type { ShipConfig } from './config';
|
|
import { upsertNestedString } from './scan/config-edit';
|
|
|
|
const PLAY = 'https://play.google.com/console';
|
|
const ID = /^\d{1,25}$/;
|
|
|
|
/** The app id in a Play Console URL or in any text that holds one (a capture JSON, page text): the first
|
|
* /developers/<digits>/app/<digits>. null when there is none. */
|
|
export function playAppIdInText(text: string): string | null {
|
|
return /\/developers\/\d{1,25}\/app\/(\d{1,25})(?=[/?#"'\s]|$)/.exec(text)?.[1] ?? null;
|
|
}
|
|
|
|
/** One captured page of the app list (APP_LIST_CAPTURE). */
|
|
export interface AppListPage {
|
|
url?: string;
|
|
/** Each app row: its visible text (name, package name, status) and the href of its link. */
|
|
rows: { text: string; href: string }[];
|
|
/** The list has a next page (its "Next page" control is there and enabled). */
|
|
hasNext?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Run in the app list page (one evaluate call): the rows with an /app/<id>/ link and their row text, and whether a next
|
|
* page exists. Modeled on the verified URL pattern and a paginated list of 10; the row markup was not recorded, so it
|
|
* takes the nearest row-like ancestor of each app link.
|
|
*/
|
|
export const APP_LIST_CAPTURE = `JSON.stringify((() => {
|
|
const t = e => (e?.innerText ?? e?.textContent ?? '').replace(/\\s+/g, ' ').trim();
|
|
const rows = [], seen = new Set();
|
|
for (const a of document.querySelectorAll('a[href*="/app/"]')) {
|
|
const href = a.getAttribute('href') || '';
|
|
if (!/\\/developers\\/\\d+\\/app\\/\\d+/.test(href) || seen.has(href)) continue;
|
|
seen.add(href);
|
|
const row = a.closest('[role=row], tr, li, [role=listitem]') || a.parentElement;
|
|
rows.push({ text: t(row), href });
|
|
}
|
|
const next = [...document.querySelectorAll('button, a, [role=button]')].find(b => /^next( page)?$/i.test((b.getAttribute('aria-label') || t(b)).trim()));
|
|
return { url: location.href, rows, hasNext: !!next && !next.disabled && next.getAttribute('aria-disabled') !== 'true' };
|
|
})())`;
|
|
|
|
/** One capture, a JSON array of captures, or one capture per line. Throws on anything else. */
|
|
export function parseAppListPages(raw: string): AppListPage[] {
|
|
const s = raw.trim();
|
|
const one = (j: unknown): AppListPage => {
|
|
const o = j as Record<string, unknown>;
|
|
if (!o || typeof o !== 'object' || !Array.isArray(o.rows)) throw new Error('play app-id: a capture needs "rows" (run the APP_LIST_CAPTURE snippet in the app list)');
|
|
const rows = (o.rows as unknown[]).flatMap(r => {
|
|
const x = r as Record<string, unknown>;
|
|
return x && typeof x.text === 'string' && typeof x.href === 'string' ? [{ text: x.text, href: x.href }] : [];
|
|
});
|
|
return { rows, ...(typeof o.url === 'string' ? { url: o.url } : {}), ...(typeof o.hasNext === 'boolean' ? { hasNext: o.hasNext } : {}) };
|
|
};
|
|
try {
|
|
const j = JSON.parse(s) as unknown;
|
|
return Array.isArray(j) ? j.map(one) : [one(j)];
|
|
} catch (e) {
|
|
if (!(e instanceof SyntaxError)) throw e;
|
|
return s.split(/\r?\n/).filter(l => l.trim()).map(l => one(JSON.parse(l)));
|
|
}
|
|
}
|
|
|
|
/** `id` as a whole token of `text` (com.example.game, not com.example.game2). */
|
|
const hasToken = (text: string, id: string): boolean => new RegExp(`(?<![\\w.])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.])`).test(text);
|
|
|
|
export type AppIdFind = { found: true; appId: string; page: number; row: string } | { found: false; pages: number; hasNext: boolean; ambiguous?: string[] };
|
|
|
|
/** The row whose text holds the package name as a whole token, across the captured pages (page numbers from 1). */
|
|
export function findPlayAppId(pages: AppListPage[], packageName: string): AppIdFind {
|
|
const hits: { appId: string; page: number; row: string }[] = [];
|
|
pages.forEach((p, i) => {
|
|
for (const r of p.rows) {
|
|
const id = playAppIdInText(r.href.startsWith('/') ? r.href : (() => { try { return new URL(r.href).pathname; } catch { return r.href; } })());
|
|
if (id && hasToken(r.text, packageName) && !hits.some(h => h.appId === id)) hits.push({ appId: id, page: i + 1, row: r.text });
|
|
}
|
|
});
|
|
if (hits.length === 1) return { found: true, ...hits[0]! };
|
|
return { found: false, pages: pages.length, hasNext: pages.at(-1)?.hasNext ?? false, ...(hits.length > 1 ? { ambiguous: hits.map(h => h.appId) } : {}) };
|
|
}
|
|
|
|
/** The app list of a developer account, page `page` (1 = the first; the `page` query parameter is modeled: prefer the
|
|
* list's own next-page control when it differs). */
|
|
export const appListPage = (developerId: string, page = 1): string =>
|
|
`${PLAY}/u/0/developers/${encodeURIComponent(developerId)}/app-list${page > 1 ? `?page=${page}` : ''}`;
|
|
|
|
/** The steps an agent follows to find the app id in the console (read-only). */
|
|
export function appIdSteps(config: ShipConfig, developerId: string | null): string[] {
|
|
const pkg = config.play?.packageName ?? '<play.packageName>';
|
|
const list = developerId ? appListPage(developerId) : `${PLAY} (pick the developer account; set play.developerId for a deep link)`;
|
|
return [
|
|
`Open the app list: ${list}.`,
|
|
`Fastest: type the package name (${pkg}) into the list's search box ("Search apps") and wait for the list to update; the one row left is the app.`,
|
|
'Run the capture snippet (capture) in the page and pipe its JSON to `storebound play app-id --from-text -` (it finds the row with the package name and reads the id from its /app/<id>/ link).',
|
|
`Not found and the capture says hasNext: press the list's next-page control (aria-label "Next page"; the URL pattern is /app-list?page=<n>, 10 apps per page, new apps are not on page 1), capture again, and pipe ALL captures (a JSON array or one per line) to the same command.`,
|
|
'Found: the command shows the play.appId change as a dry diff; run it again with --write to save it into storebound.config.ts.',
|
|
];
|
|
}
|
|
|
|
/** What --write would change: play.appId from -> to. */
|
|
export interface AppIdChange { from: string | null; to: string; changed: boolean }
|
|
export const appIdChange = (config: ShipConfig, id: string): AppIdChange => ({ from: config.play?.appId ?? null, to: id, changed: config.play?.appId !== id });
|
|
|
|
/** The dry diff line. */
|
|
export const appIdDiff = (c: AppIdChange, file: string): string => c.changed
|
|
? `${file}: play.appId ${c.from ? `'${c.from}'` : '(not set)'} -> '${c.to}'`
|
|
: `${file}: play.appId is already '${c.to}' (nothing to change)`;
|
|
|
|
/** Writes play.appId into the config file (an upsert); false when it was already that id. */
|
|
export function writePlayAppId(configPath: string, config: ShipConfig, id: string): boolean {
|
|
if (!ID.test(id)) throw new Error(`play.appId: "${id}" is not a Play Console app id (digits)`);
|
|
if (!config.play) throw new Error('the config has no play block: add play.packageName first');
|
|
if (!appIdChange(config, id).changed) return false;
|
|
writeFileSync(configPath, upsertNestedString(readFileSync(configPath, 'utf8'), configPath, 'play', 'appId', id));
|
|
return true;
|
|
}
|