Reorganize repository into pc web shared monorepo
Separate the local recognition, web publishing, and shared data paths while preserving direct script execution and existing site content. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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/<web_rid> 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. 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.
|
||||
*
|
||||
* 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+)\\"/;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 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 ref = roomRefFromUrl(liveUrl);
|
||||
if (!ref) continue;
|
||||
targets.push({ id: sid, platform, ref });
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
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: {} };
|
||||
}
|
||||
|
||||
async function handle(context) {
|
||||
const { request } = context;
|
||||
|
||||
const cachedData = await readCachedPayload();
|
||||
if (cachedData && isFresh(cachedData)) {
|
||||
return jsonResponse(cachedData, "hit");
|
||||
}
|
||||
|
||||
let targets = [];
|
||||
try {
|
||||
const dataUrl = new URL("/data.json", request.url);
|
||||
const res = await fetchWithTimeout(dataUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (res.ok) targets = targetsFromPayload(await res.json());
|
||||
} catch {
|
||||
// data.json unreachable: fall through to stale/empty below
|
||||
}
|
||||
|
||||
if (!targets.length) {
|
||||
if (cachedData) return jsonResponse(cachedData, "stale-override");
|
||||
return jsonResponse(emptyPayload(), "error");
|
||||
}
|
||||
|
||||
const staleStreamers =
|
||||
cachedData && cachedData.streamers && typeof cachedData.streamers === "object"
|
||||
? cachedData.streamers
|
||||
: {};
|
||||
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;
|
||||
}
|
||||
// Per-streamer soft-fail: carry over the last known state, marked stale.
|
||||
const prev = staleStreamers[t.id];
|
||||
if (prev && typeof prev.is_live === "boolean") {
|
||||
streamers[t.id] = { is_live: prev.is_live, stale: true };
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function onRequestGet(context) {
|
||||
try {
|
||||
return await handle(context);
|
||||
} catch {
|
||||
// Never 500 because of probing: last-resort empty payload.
|
||||
return jsonResponse(emptyPayload(), "error");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user