// 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, 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'; export type PublicState = 'public' | 'notPublic' | 'unknown'; export interface PlayPublicPage { state: PublicState; /** HTTP status of the store page (null when it could not be read). */ http: number | null; /** 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&gl=US`; const TTL_MS = 5 * 60_000; const mem = new Map(); 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 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 { const url = playPageUrl(pkg); const forced = process.env.STOREBOUND_PLAY_PUBLIC; if (forced === 'public' || forced === 'notPublic' || forced === 'unknown') return { state: forced, http: forced === 'public' ? 200 : forced === 'notPublic' ? 404 : null, versionName: process.env.STOREBOUND_PLAY_PUBLIC_VERSION ?? null, checkedAt: new Date().toISOString(), url }; if (!o.noCache) { const m = mem.get(pkg); if (fresh(m)) return m; try { const f = JSON.parse(readFileSync(cacheFile(pkg), 'utf8')) as PlayPublicPage; if (fresh(f) && f.url === url) { mem.set(pkg, f); return f; } } catch { /* no cache */ } } let out: PlayPublicPage; 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) { 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) { out = { state: 'unknown', http: null, versionName: null, checkedAt: new Date().toISOString(), url, error: (e as Error).message || 'offline' }; } if (out.state !== 'unknown') { mem.set(pkg, out); 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; } /** Forget the cached answers (tests). */ export function clearPlayPublicCache(): void { mem.clear(); }