Play edits under a machine-wide per-package lock (parallel reads of one app no longer delete each other's edits; a read whose edit was deleted is redone once, writes are not); a store that failed to read is 'Couldn't read Google Play' and keeps its last good releases with 'couldn't refresh'; stores on different versions: neutral 'Current · Play x · App Store y' header and each lane its own state, 'Not on this store' only without a record; Play status decided from the public store page's live version (Live / In review / In review (live: x) / managed publishing / Rolling out / Halted / Draft; only a network failure says 'Couldn't check the store page', with the last known answer and its time); tests/play-edit-lock.test.ts, recorded page fixture, boot check auto-read cases (pitfall 58)
This commit is contained in:
parent
e59f439101
commit
714352ec18
24 changed files with 570 additions and 103 deletions
|
|
@ -1,7 +1,9 @@
|
|||
// Is the app public on Google Play? The Play Developer API has no review state: a production release reads
|
||||
// "completed" from the moment it is sent, while Google may still be reviewing it (a first release is not public
|
||||
// until then). The public store page answers it: 200 = published, 404 = not public yet. No auth, a fast timeout,
|
||||
// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it), "unknown" when offline.
|
||||
// "completed" from the moment it is sent, also while Google reviews it. The public store page answers it: 404 = not
|
||||
// public yet (a first release in review), 200 = published, and the page's "Version" (About this app) is the live
|
||||
// versionName, so a completed release whose versionName is not the page's is in review. No auth, a fast timeout,
|
||||
// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it). Only a network failure
|
||||
// gives "unknown"; it then returns the last known answer (any age) marked `stale` with the time it was read.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
|
@ -11,25 +13,45 @@ export interface PlayPublicPage {
|
|||
state: PublicState;
|
||||
/** HTTP status of the store page (null when it could not be read). */
|
||||
http: number | null;
|
||||
/** The versionName the page shows, when it shows one (usually it does not). */
|
||||
/** The live versionName the page shows ("Version" in About this app); null when it shows none. */
|
||||
versionName: string | null;
|
||||
/** The page says "Varies with device" instead of a version (several APKs): the version cannot be compared. */
|
||||
varies?: boolean;
|
||||
/** A network failure: this is the last known answer from `checkedAt`, not a fresh one. */
|
||||
stale?: boolean;
|
||||
checkedAt: string;
|
||||
url: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`;
|
||||
export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en&gl=US`;
|
||||
|
||||
const TTL_MS = 5 * 60_000;
|
||||
const mem = new Map<string, PlayPublicPage>();
|
||||
const cacheFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.json`);
|
||||
const lastFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.last.json`);
|
||||
const fresh = (p: PlayPublicPage | undefined | null): p is PlayPublicPage => !!p && p.state !== 'unknown' && Date.now() - Date.parse(p.checkedAt) < TTL_MS;
|
||||
|
||||
/** The versionName in the page's structured data, when it has one ("softwareVersion"). */
|
||||
export function pageVersion(html: string): string | null {
|
||||
const m = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/);
|
||||
return m && /\d/.test(m[1]!) ? m[1]! : null;
|
||||
/**
|
||||
* The live versionName of a Play store page. The page embeds "About this app" in its AF_initDataCallback data blobs
|
||||
* (ds:5 in 2026-10) as `[[["0.8.1"]],[[[36]],[[[24,"7.0"]]]]]`: the version, then the target and minimum SDK with the
|
||||
* Android version. "Varies with device" stands in for the version when the app ships several APKs. Older pages had
|
||||
* schema.org "softwareVersion". Recorded shape: tests/fixtures/play-page-version.html.
|
||||
*/
|
||||
export function pageVersionInfo(html: string): { versionName: string | null; varies: boolean } {
|
||||
const blobs = [...html.matchAll(/AF_initDataCallback\(\{key: '(ds:\d+)'[\s\S]*?<\/script>/g)].map(m => m[0]);
|
||||
const about = /\[\[\["([^"\\]{1,60})"\]\],\[\[\[\d+\]\],\[\[\[\d+,"[^"\\]{0,20}"\]\]\]\]/;
|
||||
for (const b of blobs.length ? blobs : [html]) {
|
||||
const m = about.exec(b);
|
||||
if (!m) continue;
|
||||
if (/varies with device/i.test(m[1]!)) return { versionName: null, varies: true };
|
||||
if (/\d/.test(m[1]!)) return { versionName: m[1]!, varies: false };
|
||||
}
|
||||
const ld = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/);
|
||||
if (ld && /varies with device/i.test(ld[1]!)) return { versionName: null, varies: true };
|
||||
return { versionName: ld && /\d/.test(ld[1]!) ? ld[1]! : null, varies: false };
|
||||
}
|
||||
export const pageVersion = (html: string): string | null => pageVersionInfo(html).versionName;
|
||||
|
||||
/** Reads the public page (cached 5 minutes; STOREBOUND_PLAY_PUBLIC=public|notPublic|unknown overrides it for tests). */
|
||||
export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCache?: boolean } = {}): Promise<PlayPublicPage> {
|
||||
|
|
@ -46,7 +68,7 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac
|
|||
try {
|
||||
const r = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(o.timeoutMs ?? 4000), headers: { 'User-Agent': 'Mozilla/5.0 storebound', 'Accept-Language': 'en' } });
|
||||
const http = r.status;
|
||||
if (http === 200) out = { state: 'public', http, versionName: pageVersion(await r.text()), checkedAt: new Date().toISOString(), url };
|
||||
if (http === 200) { const v = pageVersionInfo(await r.text()); out = { state: 'public', http, versionName: v.versionName, ...(v.varies ? { varies: true } : {}), checkedAt: new Date().toISOString(), url }; }
|
||||
else if (http === 404) out = { state: 'notPublic', http, versionName: null, checkedAt: new Date().toISOString(), url };
|
||||
else out = { state: 'unknown', http, versionName: null, checkedAt: new Date().toISOString(), url, error: `HTTP ${http}` };
|
||||
} catch (e) {
|
||||
|
|
@ -54,8 +76,14 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac
|
|||
}
|
||||
if (out.state !== 'unknown') {
|
||||
mem.set(pkg, out);
|
||||
try { mkdirSync(join(tmpdir(), 'storebound-play-public'), { recursive: true }); writeFileSync(cacheFile(pkg), JSON.stringify(out)); } catch { /* cache is optional */ }
|
||||
try { mkdirSync(join(tmpdir(), 'storebound-play-public'), { recursive: true }); writeFileSync(cacheFile(pkg), JSON.stringify(out)); writeFileSync(lastFile(pkg), JSON.stringify(out)); } catch { /* cache is optional */ }
|
||||
return out;
|
||||
}
|
||||
// a network failure: the last known answer with its time, never a guess
|
||||
try {
|
||||
const last = JSON.parse(readFileSync(lastFile(pkg), 'utf8')) as PlayPublicPage;
|
||||
if (last.url === url && last.state !== 'unknown') return { ...last, stale: true, error: out.error };
|
||||
} catch { /* none known */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue