/** * Pages Function: GET /api/live-status * * Visit-triggered live-status probing with request coalescing via the edge * Cache API (no KV, no wrangler config). The first visitor after the 5-minute * freshness window triggers a re-probe of every streamer with `live_url`; * concurrent visitors within the window share the cached JSON. Concurrent cold * starts in the same isolate also share one in-flight probe promise. * * Probe logic is a JS port of fetch_streamer_live.py: * - Bilibili: api.live.bilibili.com Room/get_info; data.live_status === 1 is * live (0 offline, 2 replay counts as offline). * - Douyin: warm up cookies (www.douyin.com + live.douyin.com), then GET * live.douyin.com/ with a browser UA and parse the escaped JSON in * the SSR pace chunks: roomStore.roomInfo.room.status (2 live / 4 offline); * the embedded web_rid must match the requested one. * * Soft-fail everywhere: douyin blocks from datacenter IPs are expected. A * failed probe is reported as offline with `stale: true`; a stale positive is * never carried over because the live badge must only reflect a successful * current probe. The handler never throws a 500 for probe failures. * * Named exports double as the local test surface; the Pages runtime only * routes onRequest* handlers. */ const CACHE_KEY = "https://live-status.internal/v1"; const FRESH_TTL_S = 300; const FRESH_TTL_MS = FRESH_TTL_S * 1000; // Store longer than the 5-min freshness window so expired-for-serve entries // remain readable as carry-over material; freshness is governed by probed_at. const CACHE_STORE_MAX_AGE_S = 6 * 60 * 60; const CLIENT_MAX_AGE_S = 60; const PROBE_TIMEOUT_MS = 8000; const BATCH_SIZE = 3; const DOUYIN_SPACING_MS = 800; const BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36"; const DOUYIN_HOME = "https://www.douyin.com/"; const DOUYIN_LIVE_HOME = "https://live.douyin.com/"; const BILIBILI_INFO_URL = "https://api.live.bilibili.com/room/v1/Room/get_info?room_id="; // Escaped JSON inside the SSR pace chunks: \"roomStore\":{\"roomInfo\":{\"room\":{ const DOUYIN_ROOMSTORE_RE = /\\"roomStore\\":\s*\{\\"roomInfo\\":\s*\{\\"room\\":\s*\{/; const DOUYIN_STATUS_RE = /\\"status\\":\s*(\d)/; const DOUYIN_WEBRID_RE = /\\"web_rid\\":\s*\\"(\d+)\\"/; /** Isolate-local coalescing for concurrent cold starts (same Worker isolate). */ let inFlightProbe = null; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function fetchWithTimeout(url, init = {}) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS); return fetch(url, { ...init, signal: ctrl.signal }).finally(() => clearTimeout(timer) ); } /** Set-Cookie reader portable across Workers (getAll) and Node/undici. */ function setCookiesOf(res) { const h = res && res.headers; if (!h) return []; if (typeof h.getSetCookie === "function") return h.getSetCookie() || []; if (typeof h.getAll === "function") { try { return h.getAll("Set-Cookie") || []; } catch { return []; } } return []; } function collectCookies(res, jar) { for (const sc of setCookiesOf(res)) { const pair = String(sc).split(";")[0]; const eq = pair.indexOf("="); if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); } } function cookieHeader(jar) { return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; "); } function douyinHeaders(referer, jar) { const headers = { "User-Agent": BROWSER_UA, Accept: "*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", Referer: referer, }; const cookie = jar && jar.size ? cookieHeader(jar) : ""; if (cookie) headers.Cookie = cookie; return headers; } /** Seed cookies once so subsequent room-page requests are not blocked. */ export async function warmDouyinCookies(jar = new Map()) { for (const url of [DOUYIN_HOME, DOUYIN_LIVE_HOME]) { try { const res = await fetchWithTimeout(url, { headers: douyinHeaders(DOUYIN_HOME, jar), }); collectCookies(res, jar); await res.arrayBuffer(); // drain the body } catch { // warm-up is best-effort; the room probe below is the real check } await sleep(DOUYIN_SPACING_MS); } return jar; } /** Parse roomStore status from the SSR live room page (2 live / 4 offline). */ export async function probeDouyinRoom(rid, jar) { const res = await fetchWithTimeout(DOUYIN_LIVE_HOME + rid, { headers: douyinHeaders(DOUYIN_LIVE_HOME, jar), }); collectCookies(res, jar); const html = await res.text(); if (!html) throw new Error("empty room page"); const store = DOUYIN_ROOMSTORE_RE.exec(html); if (!store) throw new Error("no roomStore in page (blocked or layout changed)"); // The room object opens with id_str/status; a short window is enough. const end = store.index + store[0].length; const win = html.slice(end, end + 3000); const statusM = DOUYIN_STATUS_RE.exec(win); if (!statusM) throw new Error("roomStore has no status field"); const embedded = DOUYIN_WEBRID_RE.exec(html); if (!embedded || embedded[1] !== rid) { throw new Error("page resolved to a different room (stale web_rid?)"); } const status = parseInt(statusM[1], 10); if (status === 2) return true; if (status === 4) return false; throw new Error(`unexpected room status ${status}`); } /** live_status: 0 offline, 1 live, 2 replay (replay counts as offline). */ export async function probeBilibiliRoom(roomId) { const res = await fetchWithTimeout(BILIBILI_INFO_URL + roomId, { headers: { "User-Agent": BROWSER_UA, Accept: "application/json" }, }); const payload = await res.json(); if (!payload || payload.code !== 0) { throw new Error(`bilibili api error: code=${payload && payload.code}`); } const data = payload.data; if (!data || typeof data !== "object") { throw new Error("bilibili api returned no data"); } return data.live_status === 1; } /** First path segment of the live room URL (douyin web_rid / bilibili room id). */ export function roomRefFromUrl(liveUrl) { let path = ""; try { path = new URL(String(liveUrl).trim()).pathname; } catch { return null; } const seg = path.replace(/^\/+|\/+$/g, "").split("/")[0]; return seg || null; } /** Probe backend follows the live room host (may differ from profile platform). */ export function livePlatformFromUrl(liveUrl, fallback = "") { try { const host = new URL(String(liveUrl).trim()).hostname.toLowerCase(); if (host.includes("bilibili.com")) return "bilibili"; if (host.includes("douyin.com")) return "douyin"; } catch { /* keep fallback */ } return String(fallback || "") .trim() .toLowerCase(); } /** * Extract the streamer row list from a data.json payload. * Production nests under `streamers.streamers`; also accept a bare array. */ export function streamerRowsFromPayload(payload) { if (!payload || typeof payload !== "object") return []; if (Array.isArray(payload.streamers)) return payload.streamers; const nested = payload.streamers; if (nested && typeof nested === "object" && Array.isArray(nested.streamers)) { return nested.streamers; } return []; } /** Extract probe targets (id/platform/room ref) from a data.json payload. */ export function targetsFromPayload(payload) { const rows = streamerRowsFromPayload(payload); const targets = []; for (const row of rows) { if (!row || typeof row !== "object") continue; const sid = String(row.id || "").trim(); const liveUrl = String(row.live_url || "").trim(); if (!sid || !liveUrl) continue; const fallback = String(row.platform || "").trim().toLowerCase(); const platform = livePlatformFromUrl(liveUrl, fallback); const ref = roomRefFromUrl(liveUrl); if (!ref) continue; targets.push({ id: sid, platform, ref }); } return targets; } /** Seed carry-over map from data.json is_live (daily snapshot). */ export function seedLiveFromPayload(payload) { const out = {}; for (const row of streamerRowsFromPayload(payload)) { if (!row || typeof row !== "object") continue; const sid = String(row.id || "").trim(); if (!sid || typeof row.is_live !== "boolean") continue; out[sid] = { is_live: row.is_live }; } return out; } async function probeAll(targets) { const jar = targets.some((t) => t.platform === "douyin") ? await warmDouyinCookies() : new Map(); const results = new Map(); // id -> { is_live } | { error } for (let i = 0; i < targets.length; i += BATCH_SIZE) { const batch = targets.slice(i, i + BATCH_SIZE); await Promise.all( batch.map(async (t) => { try { let isLive; if (t.platform === "douyin") isLive = await probeDouyinRoom(t.ref, jar); else if (t.platform === "bilibili") isLive = await probeBilibiliRoom(t.ref); else throw new Error(`unsupported platform ${t.platform}`); results.set(t.id, { is_live: isLive }); } catch (err) { results.set(t.id, { error: String((err && err.message) || err) }); } }) ); // Douyin rate-limits aggressively; keep spacing between its requests. if (i + BATCH_SIZE < targets.length && jar.size) await sleep(DOUYIN_SPACING_MS); } return results; } function jsonResponse(body, cacheState, extraHeaders = {}) { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": `public, max-age=${CLIENT_MAX_AGE_S}`, "X-Live-Cache": cacheState, ...extraHeaders, }, }); } async function readCachedPayload() { try { const cached = await caches.default.match(CACHE_KEY); if (!cached) return null; const data = await cached.json(); return data && typeof data === "object" ? data : null; } catch { return null; } } function isFresh(data) { const ts = Date.parse(data && data.probed_at); return Number.isFinite(ts) && Date.now() - ts < FRESH_TTL_MS; } function emptyPayload() { return { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers: {} }; } /** Failed probes must never preserve a stale live badge. */ function staleOfflinePayload(source) { const streamers = {}; const previous = source && source.streamers && typeof source.streamers === "object" ? source.streamers : {}; for (const id of Object.keys(previous)) { streamers[id] = { is_live: false, stale: true }; } return { probed_at: (source && source.probed_at) || new Date().toISOString(), ttl: FRESH_TTL_S, streamers, }; } async function putCache(body) { const cached = new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`, }, }); await caches.default.put(CACHE_KEY, cached); } /** * Load targets + build a fresh probe payload (or return null when there is * nothing to probe / total failure with no seed). * Returns { body, cacheState } where cacheState is miss | stale-override | error. */ async function probeFresh(requestUrl, cachedData) { let dataPayload = null; let targets = []; try { const dataUrl = new URL("/data.json", requestUrl); const res = await fetchWithTimeout(dataUrl.toString(), { headers: { Accept: "application/json" }, }); if (res.ok) { dataPayload = await res.json(); targets = targetsFromPayload(dataPayload); } } catch { // data.json unreachable: fall through to stale/empty below } if (!targets.length) { if (cachedData) { return { body: staleOfflinePayload(cachedData), cacheState: "stale-override" }; } return { body: emptyPayload(), cacheState: "error" }; } const probed = await probeAll(targets); const streamers = {}; let freshCount = 0; for (const t of targets) { const r = probed.get(t.id); if (r && typeof r.is_live === "boolean") { streamers[t.id] = { is_live: r.is_live }; freshCount += 1; continue; } // Unknown is not live: never carry a stale positive badge forward. streamers[t.id] = { is_live: false, stale: true }; } if (freshCount === 0) { // Total probe failure (e.g. douyin blocking this colo): keep the target // shape but suppress every unconfirmed live badge. const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers, }; return { body, cacheState: "stale-override" }; } const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers }; return { body, cacheState: "miss" }; } async function handle(context) { const { request } = context; const cachedData = await readCachedPayload(); if (cachedData && isFresh(cachedData)) { return jsonResponse(cachedData, "hit"); } // Coalesce concurrent cold starts in this isolate onto one probe run. if (!inFlightProbe) { inFlightProbe = probeFresh(request.url, cachedData).finally(() => { inFlightProbe = null; }); } const { body, cacheState } = await inFlightProbe; if (cacheState === "miss") { context.waitUntil(putCache(body)); } return jsonResponse(body, cacheState); } export async function onRequestGet(context) { try { return await handle(context); } catch { // Never 500 because of probing: last-resort empty payload. return jsonResponse(emptyPayload(), "error"); } }