v0.5.110: stop carrying stale live badges; probe live status in local serve.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 01:11:13 +08:00
co-authored by Cursor
parent d6212169af
commit fe1b13b7c1
10 changed files with 125 additions and 56 deletions
+3
View File
@@ -60,6 +60,7 @@
{
"id": "yaseguilai",
"platform": "douyin",
"live_url": "https://live.douyin.com/146909015971",
"profile_url": "https://v.douyin.com/gQDc0B9yPv4/",
"heroes": [
"slark"
@@ -80,6 +81,7 @@
{
"id": "gudu",
"platform": "douyin",
"live_url": "https://live.douyin.com/750711463576",
"profile_url": "https://v.douyin.com/411MHPZgDeY/",
"heroes": [
"kez"
@@ -119,6 +121,7 @@
{
"id": "dadigua",
"platform": "douyin",
"live_url": "https://live.douyin.com/499355244796",
"profile_url": "https://v.douyin.com/t1E-4M1xrJ4/",
"heroes": [
"axe"
+1 -1
View File
@@ -52,7 +52,7 @@ from shared.paths import (
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.109"
SITE_VERSION = "0.5.110"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
+8 -6
View File
@@ -4749,9 +4749,9 @@ function syncStreamerCardLive(card, row) {
/**
* Visit-triggered live refresh: the Pages Function coalesces concurrent
* visitors through a 5-minute edge cache. Local `serve_relations.py` returns
* an empty stub (no probing), so that path keeps the data.json is_live
* fallback. Fires at most once per page load.
* visitors through a 5-minute edge cache; the local development server uses
* the same platform probes with a short in-memory cache. Fires at most once
* per page load.
*/
let liveStatusFetched = false;
@@ -4776,9 +4776,11 @@ function refreshStreamerLiveStatus() {
for (const row of rows) {
const cell = row && row.id ? probed[row.id] : null;
if (!cell || typeof cell.is_live !== "boolean") continue;
if (cell.stale) console.info(`live-status: ${row.id} using stale carry-over`);
if (row.is_live !== cell.is_live) {
row.is_live = cell.is_live;
// A failed/stale probe is unknown, never evidence that a room is live.
const isLive = cell.stale ? false : cell.is_live;
if (cell.stale) console.info(`live-status: ${row.id} probe stale; hiding badge`);
if (row.is_live !== isLive) {
row.is_live = isLive;
changed = true;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.109";
var SITE_VERSION = "0.5.110";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+34 -36
View File
@@ -15,12 +15,10 @@
* 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
* 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.
* 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.
@@ -292,6 +290,23 @@ 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: {
@@ -324,17 +339,12 @@ async function probeFresh(requestUrl, cachedData) {
}
if (!targets.length) {
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
if (cachedData) {
return { body: staleOfflinePayload(cachedData), cacheState: "stale-override" };
}
return { body: emptyPayload(), cacheState: "error" };
}
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 = {};
@@ -346,31 +356,19 @@ async function probeFresh(requestUrl, cachedData) {
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 };
}
// 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): serve the stale
// snapshot if one exists, otherwise an explicitly empty payload.
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" };
// 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 };
+5 -5
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.109" />
<script src="/mobile-gate.js?v=0.5.109"></script>
<link rel="stylesheet" href="/style.css?v=0.5.110" />
<script src="/mobile-gate.js?v=0.5.110"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
@@ -178,8 +178,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.109"></script>
<script src="/router.js?v=0.5.109"></script>
<script src="/app.js?v=0.5.109"></script>
<script src="/config.js?v=0.5.110"></script>
<script src="/router.js?v=0.5.110"></script>
<script src="/app.js?v=0.5.110"></script>
</body>
</html>
+63 -3
View File
@@ -19,7 +19,9 @@ import argparse
import json
import mimetypes
import threading
import time
import webbrowser
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
@@ -47,11 +49,15 @@ from shared.paths import (
)
from shared.relations import DEFAULT_RELATIONS, load_relations
from fetch_streamer_live import probe_streamers
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
WEB_DIR = WEB_FRONTEND
MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json"
_MOBILE_DEMAND_LOCK = threading.Lock()
_LIVE_STATUS_LOCK = threading.Lock()
_LIVE_STATUS_TTL = 60
_LIVE_STATUS_CACHE: tuple[float, dict] | None = None
def _read_mobile_demand_count() -> int:
@@ -76,6 +82,62 @@ def _inc_mobile_demand_count() -> int:
return n
def _probe_live_status() -> dict:
"""Probe local streamer rooms without mutating streamers.json."""
global _LIVE_STATUS_CACHE
now = time.monotonic()
cached = _LIVE_STATUS_CACHE
if cached and now - cached[0] < _LIVE_STATUS_TTL:
return cached[1]
with _LIVE_STATUS_LOCK:
now = time.monotonic()
cached = _LIVE_STATUS_CACHE
if cached and now - cached[0] < _LIVE_STATUS_TTL:
return cached[1]
try:
payload = json.loads(STREAMERS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
result = {
"probed_at": datetime.now(timezone.utc).isoformat(),
"ttl": _LIVE_STATUS_TTL,
"streamers": {},
}
_LIVE_STATUS_CACHE = (now, result)
return result
rows = payload.get("streamers")
if not isinstance(rows, list):
rows = []
for row in rows:
if isinstance(row, dict) and row.get("live_url"):
# A failed probe is unknown and must not preserve a stale live badge.
row["is_live"] = False
row.pop("live_probed_at", None)
probe_streamers(payload)
streamers = {}
for row in rows:
if not isinstance(row, dict) or not row.get("live_url"):
continue
sid = str(row.get("id") or "").strip()
if not sid:
continue
cell = {"is_live": row.get("is_live") is True}
if not row.get("live_probed_at"):
cell["stale"] = True
streamers[sid] = cell
result = {
"probed_at": datetime.now(timezone.utc).isoformat(),
"ttl": _LIVE_STATUS_TTL,
"streamers": streamers,
}
_LIVE_STATUS_CACHE = (time.monotonic(), result)
return result
GRID_ORDER_PATH = DATA / "hero_grid_order.json"
HERO_ITEMS_PATH = DATA / "hero_items.json"
HERO_STATS_PATH = DATA / "hero_stats.json"
@@ -842,9 +904,7 @@ class Handler(BaseHTTPRequestHandler):
self._json(200, build_payload())
return
if path == "/api/live-status":
# Local-dev stub for the Pages Function (edge probing runs in
# production only); the empty map keeps the UI on data.json is_live.
self._json(200, {"probed_at": None, "ttl": 300, "streamers": {}})
self._json(200, _probe_live_status())
return
if path == "/api/mobile-demand":
self._json(200, {"count": _read_mobile_demand_count()})