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

@ -4,7 +4,7 @@
// yours once per field. Every generated section carries a marker line until a human reviewed it:
// <!-- storebound-translation from=en-US status=translate --> source text copied in, still to translate
// <!-- storebound-translation from=en-US status=draft --> machine-translated: review, then `listing reviewed`
// Limits per store field (characters; App Store keywords also 100 bytes, as the existing listing check counts them):
// Limits per store field, in characters (App Store keywords too: live fields of 265 UTF-8 bytes are accepted):
// App Store (appStoreVersionLocalizations / appInfoLocalizations): name 30, subtitle 30, promotionalText 170,
// keywords 100, description 4000. https://developer.apple.com/help/app-store-connect/reference/app-information
// Google Play (edits.listings): title 30, shortDescription 80, fullDescription 4000.
@ -108,7 +108,7 @@ export const unmark = (sec: string): string => sec.replace(/^<!--\s*storebound-t
// ---- keyword rules ----
/** App Store keywords: split on commas (also 、,،), trimmed, deduplicated (case-insensitive), words of the name /
* subtitle dropped, joined with "," and cut at whole keywords to fit 100 characters and 100 bytes. Pure. */
* subtitle dropped, joined with "," and cut at whole keywords to fit 100 characters. Pure. */
export function normalizeKeywords(text: string, o: { name?: string; subtitle?: string; limit?: number } = {}): { text: string; dropped: { kw: string; why: string }[] } {
const limit = o.limit ?? APPLE_LIMITS.keywords;
const taken = new Set(`${o.name ?? ''} ${o.subtitle ?? ''}`.toLocaleLowerCase().split(/[\s,.:;!?·|/–-]+/).filter(w => w.length > 1));
@ -123,14 +123,14 @@ export function normalizeKeywords(text: string, o: { name?: string; subtitle?: s
seen.add(key);
if (taken.has(key)) { dropped.push({ kw, why: 'already in the name or subtitle' }); continue; }
const next = [...kept, kw].join(',');
if (len(next) > limit || Buffer.byteLength(next) > limit) { dropped.push({ kw, why: `over ${limit}` }); continue; }
if (len(next) > limit) { dropped.push({ kw, why: `over ${limit}` }); continue; }
kept.push(kw);
}
return { text: kept.join(','), dropped };
}
/** `chars` is what the store counts: characters, except App Store keywords, which App Store Connect limits in UTF-8 bytes
* (`unit` says which). */
/** `chars` is what the store counts: characters, App Store keywords included (App Store Connect counts characters,
* not UTF-8 bytes: store-pitfalls 43). `unit` stays for readers of the JSON and is always 'chars'. */
export interface FieldCheck { store: 'apple' | 'play'; field: string; chars: number; unit: 'chars' | 'bytes'; limit: number; problems: string[] }
/** Limits and rules of one translated section. `source` = the source language's fields (identical text is flagged). */
@ -141,11 +141,11 @@ export function checkFields(store: 'apple' | 'play', f: Record<string, string |
const v = f[k];
if (v === undefined) continue;
const limit = (FIELD_LIMITS[store] as Record<string, number>)[k]!;
const bytes = store === 'apple' && k === 'keywords';
const n = bytes ? Buffer.byteLength(v, 'utf8') : len(v);
const c: FieldCheck = { store, field: k, chars: n, unit: bytes ? 'bytes' : 'chars', limit, problems: [] };
if (n > limit) c.problems.push(`${n} > ${limit} ${bytes ? 'bytes' : 'characters'}`);
if (bytes) {
const isKw = store === 'apple' && k === 'keywords';
const n = len(v);
const c: FieldCheck = { store, field: k, chars: n, unit: 'chars', limit, problems: [] };
if (n > limit) c.problems.push(`${n} > ${limit} characters`);
if (isKw) {
if (/,\s|\s,/.test(v)) c.problems.push('spaces around commas');
const words = v.split(',').map(w => w.trim().toLocaleLowerCase()).filter(Boolean);
const dup = words.filter((w, i) => words.indexOf(w) !== i);