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:
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Pages Function: GET|POST /api/mobile-demand
|
||||
*
|
||||
* Best-effort edge counter for "please add mobile support" demand.
|
||||
* Stored via the Cache API (no KV / wrangler binding), same pattern as
|
||||
* live-status. Counts may undercount under concurrent colo races and can
|
||||
* reset if the edge entry is evicted; good enough as a demand signal.
|
||||
*
|
||||
* GET -> { count }
|
||||
* POST -> increment once, return { count, voted: true }
|
||||
*/
|
||||
|
||||
const CACHE_KEY = "https://mobile-demand.internal/v1";
|
||||
// Long store TTL so eviction is rare; freshness is not time-gated.
|
||||
const CACHE_STORE_MAX_AGE_S = 365 * 24 * 60 * 60;
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function readCount() {
|
||||
try {
|
||||
const cached = await caches.default.match(CACHE_KEY);
|
||||
if (!cached) return 0;
|
||||
const data = await cached.json();
|
||||
const n = Number(data && data.count);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCount(count) {
|
||||
const body = {
|
||||
count,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
export async function onRequestGet() {
|
||||
try {
|
||||
const count = await readCount();
|
||||
return jsonResponse({ count });
|
||||
} catch {
|
||||
return jsonResponse({ count: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function onRequestPost() {
|
||||
try {
|
||||
const next = (await readCount()) + 1;
|
||||
await writeCount(next);
|
||||
return jsonResponse({ count: next, voted: true });
|
||||
} catch {
|
||||
return jsonResponse({ error: "increment_failed" }, 503);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user