v0.5.84: matches origin filter, mobile gate, refresh reliability.

Ship Web refresh cache/lock, mobile demand gate, matches 职业/国服 filter, and related site updates through 0.5.84.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-29 18:31:55 +08:00
co-authored by Cursor
parent 7681fdb069
commit b01552ee6e
50 changed files with 3406 additions and 487 deletions
+117 -31
View File
@@ -4,7 +4,8 @@
* 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 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
@@ -16,10 +17,10 @@
*
* Soft-fail everywhere: douyin blocks from datacenter IPs are expected. When a
* single probe fails, the streamer carries over the last known is_live from
* the previous (stale) cache entry with `stale: true`; when every probe fails
* the stale cache entry is served wholesale (X-Live-Cache: stale-override),
* or an empty payload when nothing was ever cached (X-Live-Cache: error).
* The handler never throws a 500 for probe failures.
* data.json / the previous (stale) cache entry with `stale: true`; when every
* probe fails the stale cache entry is served wholesale (X-Live-Cache:
* stale-override), or an empty payload when nothing was ever cached
* (X-Live-Cache: error). The handler never throws a 500 for probe failures.
*
* Named exports double as the local test surface; the Pages runtime only
* routes onRequest* handlers.
@@ -51,6 +52,9 @@ const DOUYIN_ROOMSTORE_RE = /\\"roomStore\\":\s*\{\\"roomInfo\\":\s*\{\\"room\\"
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));
}
@@ -172,16 +176,45 @@ export function roomRefFromUrl(liveUrl) {
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 = payload && Array.isArray(payload.streamers) ? payload.streamers : [];
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 platform = String(row.platform || "").trim().toLowerCase();
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 });
@@ -189,6 +222,18 @@ export function targetsFromPayload(payload) {
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()
@@ -247,34 +292,49 @@ function emptyPayload() {
return { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers: {} };
}
async function handle(context) {
const { request } = context;
const cachedData = await readCachedPayload();
if (cachedData && isFresh(cachedData)) {
return jsonResponse(cachedData, "hit");
}
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", request.url);
const dataUrl = new URL("/data.json", requestUrl);
const res = await fetchWithTimeout(dataUrl.toString(), {
headers: { Accept: "application/json" },
});
if (res.ok) targets = targetsFromPayload(await res.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 jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
return { body: emptyPayload(), cacheState: "error" };
}
const staleStreamers =
const seeded = seedLiveFromPayload(dataPayload);
const cachedStreamers =
cachedData && cachedData.streamers && typeof cachedData.streamers === "object"
? cachedData.streamers
: {};
// Prefer previous edge cache over the daily snapshot when both exist.
const staleStreamers = { ...seeded, ...cachedStreamers };
const probed = await probeAll(targets);
const streamers = {};
@@ -296,21 +356,47 @@ async function handle(context) {
if (freshCount === 0) {
// Total probe failure (e.g. douyin blocking this colo): serve the stale
// snapshot if one exists, otherwise an explicitly empty payload.
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
if (Object.keys(seeded).length) {
const body = {
probed_at: new Date().toISOString(),
ttl: FRESH_TTL_S,
streamers: Object.fromEntries(
Object.entries(seeded).map(([id, cell]) => [
id,
{ is_live: cell.is_live, stale: true },
])
),
};
return { body, cacheState: "stale-override" };
}
return { body: emptyPayload(), cacheState: "error" };
}
const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers };
const res = jsonResponse(body, "miss");
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
// Cache write must not block the response.
context.waitUntil(caches.default.put(CACHE_KEY, cached));
return res;
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) {