storebound/cli/lib/play-public.ts

63 lines
3.7 KiB
TypeScript

// 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.
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 versionName the page shows, when it shows one (usually it does not). */
versionName: string | null;
checkedAt: string;
url: string;
error?: string;
}
export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`;
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 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;
}
/** 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> {
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) out = { state: 'public', http, versionName: pageVersion(await r.text()), 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)); } catch { /* cache is optional */ }
}
return out;
}
/** Forget the cached answers (tests). */
export function clearPlayPublicCache(): void { mem.clear(); }