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,20 @@
|
||||
/*
|
||||
X-Content-Type-Options: nosniff
|
||||
|
||||
/index.html
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
|
||||
/data.json
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
|
||||
/app.js
|
||||
Cache-Control: public, max-age=60, must-revalidate
|
||||
|
||||
/config.js
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
|
||||
/router.js
|
||||
Cache-Control: public, max-age=60, must-revalidate
|
||||
|
||||
/style.css
|
||||
Cache-Control: public, max-age=300, must-revalidate
|
||||
+4895
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||
var SITE_VERSION = "0.5.61";
|
||||
var ABILITY_VIDEO_BASE = "";
|
||||
var STATIC_ASSET_BASE = "";
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-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.61" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand" aria-label="Dota 2 上分帝">
|
||||
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
|
||||
<span class="brand-title">上分帝</span>
|
||||
</div>
|
||||
<a
|
||||
class="contact-mail"
|
||||
href="mailto:c9mhs8vfmuyv@outlook.com"
|
||||
aria-label="邮件联系"
|
||||
title="c9mhs8vfmuyv@outlook.com"
|
||||
>
|
||||
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<nav class="main-tabs" aria-label="主分页">
|
||||
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
|
||||
<button type="button" class="main-tab" data-page="rankings">排行</button>
|
||||
<button type="button" class="main-tab" data-page="streamers">主播</button>
|
||||
<button type="button" class="main-tab" data-page="trends">走势</button>
|
||||
<button type="button" class="main-tab" data-page="mechanics">机制</button>
|
||||
<button type="button" class="main-tab" data-page="items">物品</button>
|
||||
<button type="button" class="main-tab" data-page="patches">版本</button>
|
||||
</nav>
|
||||
|
||||
<div id="heroes-toolbar" class="toolbar">
|
||||
<div class="tagbar" id="tagbar" aria-label="定位筛选"></div>
|
||||
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
|
||||
</div>
|
||||
|
||||
<div id="items-toolbar" class="toolbar hidden">
|
||||
<div class="item-tools">
|
||||
<input id="q-item" class="search search-inline" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="heroes-view" class="board">
|
||||
<div class="columns" id="columns"></div>
|
||||
</main>
|
||||
|
||||
<main id="rankings-view" class="board rankings-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap">
|
||||
<div class="rankings-body" id="rankings-body"></div>
|
||||
<aside class="rankings-aside" aria-label="排行筛选">
|
||||
<h2 class="rankings-title">
|
||||
<span class="rankings-title-row">
|
||||
<span class="rankings-kicker" id="rankings-kicker">Immortal 排行榜</span>
|
||||
<span id="rankings-tip-slot"></span>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="rankings-regions" id="rankings-regions" role="tablist" aria-label="选择地区"></div>
|
||||
<p class="rankings-sub" id="rankings-sub"></p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="streamers-view" class="board rankings-board streamers-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap streamers-center-wrap">
|
||||
<div class="rankings-body streamers-body" id="streamers-body"></div>
|
||||
<footer class="streamers-foot" id="streamers-foot" aria-hidden="true"></footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="trends-view" class="board rankings-board trends-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap trends-center-wrap">
|
||||
<div class="rankings-body" id="trends-body"></div>
|
||||
<aside class="rankings-aside" aria-label="走势筛选">
|
||||
<h2 class="rankings-title">
|
||||
<span class="rankings-title-row">
|
||||
<span class="rankings-kicker" id="trends-kicker">过去 8 周走势</span>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="trends-brackets" id="trends-brackets" aria-label="选择段位"></div>
|
||||
<div class="trends-sort" id="trends-sort" role="tablist" aria-label="排序"></div>
|
||||
<p class="rankings-sub" id="trends-sub"></p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="mechanics-view" class="board rankings-board mechanics-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap mechanics-center-wrap">
|
||||
<div class="rankings-body mechanics-body" id="mechanics-body"></div>
|
||||
<aside class="rankings-aside" aria-label="机制筛选">
|
||||
<h2 class="rankings-title">
|
||||
<span class="rankings-title-row">
|
||||
<span class="rankings-kicker">机制查询</span>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="mechanics-effects" id="mechanics-effects" aria-label="选择状态效果"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="items-view" class="board items-board hidden">
|
||||
<div class="items-cluster">
|
||||
<div class="items-main">
|
||||
<div class="shop-col-headers" aria-hidden="true">
|
||||
<span class="shop-col-header basic">基础分类</span>
|
||||
<span class="shop-col-header upgraded">合成分类</span>
|
||||
</div>
|
||||
<div id="item-shop" class="item-shop" aria-live="polite"></div>
|
||||
</div>
|
||||
<aside id="item-detail-box" class="item-detail-box empty" aria-live="polite">
|
||||
点击物品查看详情与合成
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="patches-view" class="board patches-board hidden">
|
||||
<div class="patches-header">
|
||||
<h2 class="patches-title"><span class="patches-kicker">游戏性更新</span><span class="patches-ver" id="patch-ver"></span></h2>
|
||||
<select id="patch-select" aria-label="选择版本"></select>
|
||||
</div>
|
||||
<div class="patches-detail" id="patches-detail"></div>
|
||||
<footer class="patches-site-version" id="patches-site-version" aria-hidden="true"></footer>
|
||||
</main>
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/config.js?v=0.5.61"></script>
|
||||
<script src="/router.js?v=0.5.61"></script>
|
||||
<script src="/app.js?v=0.5.61"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,245 @@
|
||||
/* global window, history, location */
|
||||
|
||||
/**
|
||||
* Hash-based router for the Climperor web site (web/relations).
|
||||
*
|
||||
* Synchronizes the browser URL with app state across these dimensions:
|
||||
* - page: heroes | rankings | streamers | trends | mechanics | items | patches (top-level tab)
|
||||
* - hero: selected hero key + detail sub-tab
|
||||
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats → trends)
|
||||
* - rankings: Immortal leaderboard region
|
||||
* #/rankings[/region] (legacy #/rankings/meta[/bracket] → players / china)
|
||||
* - streamers: curated Douyin streamer directory
|
||||
* #/streamers
|
||||
* - trends: medal bracket for the 8-week win/pick board
|
||||
* #/trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted)
|
||||
* - mechanics: applies-effect query (dispel / CC)
|
||||
* #/mechanics[/{effect}] (default effect basic_dispel omitted)
|
||||
* - item: selected shop item key (items page)
|
||||
* - patch: selected version string (patches page; latest when absent)
|
||||
* - query params: tags=csv (heroes), q=search text (heroes + items)
|
||||
*
|
||||
* Two-way binding without feedback loops:
|
||||
* - App calls syncStateToUrl() after each state mutation. We use
|
||||
* history.pushState/replaceState, which update the URL silently
|
||||
* (no hashchange event fires) — so applyUrlToState is never re-entered.
|
||||
* - Back/forward navigation fires hashchange → applyUrlToState parses
|
||||
* the new hash, hands a validated patch back to app, and app re-renders.
|
||||
*
|
||||
* Depends on app.js for state shape and render(); installed via installRouter().
|
||||
*/
|
||||
|
||||
const ROUTE_DEFAULT = "#/heroes";
|
||||
const VALID_PAGES = ["heroes", "rankings", "streamers", "trends", "mechanics", "items", "patches"];
|
||||
const VALID_DETAIL_TABS = [
|
||||
"skills",
|
||||
"core",
|
||||
"fears",
|
||||
"trends",
|
||||
"stats",
|
||||
"matchups",
|
||||
"matches",
|
||||
"streamers",
|
||||
"patches",
|
||||
];
|
||||
const DEFAULT_RANKING_REGION = "china";
|
||||
const DEFAULT_TRENDS_BRACKET = "legend";
|
||||
const DEFAULT_TRENDS_SORT = "wr_end";
|
||||
const VALID_TRENDS_SORTS = ["wr_end", "pr_end"];
|
||||
const TRENDS_SORT_URL = { wr_end: "wr", pr_end: "pr" };
|
||||
const TRENDS_SORT_FROM_URL = { wr: "wr_end", pr: "pr_end", wr_end: "wr_end", pr_end: "pr_end" };
|
||||
const DEFAULT_MECHANIC_EFFECT = "basic_dispel";
|
||||
const VALID_MECHANIC_EFFECTS = [
|
||||
"basic_dispel",
|
||||
"strong_dispel",
|
||||
"root",
|
||||
"disarm",
|
||||
"silence",
|
||||
"mute",
|
||||
"stun",
|
||||
"hex",
|
||||
"break",
|
||||
"sleep",
|
||||
"fear",
|
||||
"taunt",
|
||||
"blind",
|
||||
"leash",
|
||||
"invis",
|
||||
"ethereal",
|
||||
"cyclone",
|
||||
];
|
||||
|
||||
let _deps = null;
|
||||
|
||||
/**
|
||||
* Bind the router. deps = {
|
||||
* getState: () => state,
|
||||
* applyPatch: (patch) => void // validates + merges + renders
|
||||
* }
|
||||
* Call once after state.data is loaded.
|
||||
*/
|
||||
function installRouter(deps) {
|
||||
_deps = deps;
|
||||
window.addEventListener("hashchange", applyUrlToState);
|
||||
}
|
||||
|
||||
/** Parse a hash string into a state patch (all fields nullable). */
|
||||
function parseHash(hash) {
|
||||
const out = {
|
||||
page: null,
|
||||
heroKey: null,
|
||||
detailTab: null,
|
||||
itemKey: null,
|
||||
patchVersion: null,
|
||||
rankingRegion: null,
|
||||
trendsBracket: null,
|
||||
trendsSort: null,
|
||||
mechanicEffect: null,
|
||||
tags: null,
|
||||
query: null,
|
||||
itemQuery: null,
|
||||
};
|
||||
let raw = hash || "";
|
||||
if (raw.startsWith("#")) raw = raw.slice(1);
|
||||
if (!raw.startsWith("/")) raw = "/" + raw;
|
||||
const qIdx = raw.indexOf("?");
|
||||
const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
|
||||
const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : "";
|
||||
const segs = pathPart.split("/").filter(Boolean);
|
||||
if (!segs.length) return out;
|
||||
const page = segs[0];
|
||||
if (!VALID_PAGES.includes(page)) return out;
|
||||
out.page = page;
|
||||
if (page === "heroes") {
|
||||
if (segs[1]) out.heroKey = safeDecode(segs[1]);
|
||||
if (segs[2]) out.detailTab = safeDecode(segs[2]);
|
||||
} else if (page === "rankings") {
|
||||
// Legacy #/rankings/meta[/bracket] → Immortal players board (default region).
|
||||
if (segs[1] && segs[1] !== "meta") {
|
||||
out.rankingRegion = safeDecode(segs[1]);
|
||||
}
|
||||
} else if (page === "trends") {
|
||||
if (segs[1]) out.trendsBracket = safeDecode(segs[1]);
|
||||
} else if (page === "mechanics") {
|
||||
if (segs[1]) out.mechanicEffect = safeDecode(segs[1]);
|
||||
} else if (page === "items") {
|
||||
if (segs[1]) out.itemKey = safeDecode(segs[1]);
|
||||
} else if (page === "patches") {
|
||||
if (segs[1]) out.patchVersion = safeDecode(segs[1]);
|
||||
}
|
||||
const params = new URLSearchParams(queryPart);
|
||||
const tagsParam = params.get("tags");
|
||||
if (tagsParam) {
|
||||
out.tags = new Set(
|
||||
tagsParam.split(",").map((s) => safeDecode(s)).filter(Boolean)
|
||||
);
|
||||
}
|
||||
const qParam = params.get("q");
|
||||
if (qParam != null) {
|
||||
if (page === "items") out.itemQuery = qParam;
|
||||
else out.query = qParam;
|
||||
}
|
||||
if (page === "trends") {
|
||||
const sortParam = params.get("sort");
|
||||
if (sortParam && TRENDS_SORT_FROM_URL[sortParam]) {
|
||||
out.trendsSort = TRENDS_SORT_FROM_URL[sortParam];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeDecode(s) {
|
||||
try {
|
||||
return decodeURIComponent(s);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize current app state into a hash string (with leading '#'). */
|
||||
function serializeHash(state) {
|
||||
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
|
||||
let hash = "#/" + state.page;
|
||||
if (state.page === "heroes") {
|
||||
if (state.selectedKey) {
|
||||
hash += "/" + encodeURIComponent(state.selectedKey);
|
||||
let tab = state.detailTab;
|
||||
if (tab === "stats") tab = "trends"; // normalize legacy alias in URL
|
||||
if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") {
|
||||
hash += "/" + encodeURIComponent(tab);
|
||||
}
|
||||
}
|
||||
} else if (state.page === "rankings") {
|
||||
// Omit region when it is the default (china) — bare #/rankings means China.
|
||||
const region = state.rankingRegion || DEFAULT_RANKING_REGION;
|
||||
if (region && region !== DEFAULT_RANKING_REGION) {
|
||||
hash += "/" + encodeURIComponent(region);
|
||||
}
|
||||
} else if (state.page === "trends") {
|
||||
// Omit bracket when it is the default (legend) — bare #/trends means legend.
|
||||
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
|
||||
if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) {
|
||||
hash += "/" + encodeURIComponent(bracket);
|
||||
}
|
||||
const sort = state.trendsSort || DEFAULT_TRENDS_SORT;
|
||||
if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) {
|
||||
hash += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort);
|
||||
}
|
||||
} else if (state.page === "mechanics") {
|
||||
const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT;
|
||||
if (effect && effect !== DEFAULT_MECHANIC_EFFECT) {
|
||||
hash += "/" + encodeURIComponent(effect);
|
||||
}
|
||||
} else if (state.page === "items") {
|
||||
if (state.selectedItemKey) {
|
||||
hash += "/" + encodeURIComponent(state.selectedItemKey);
|
||||
}
|
||||
} else if (state.page === "patches") {
|
||||
// Omit version when it is the latest — bare #/patches means "latest".
|
||||
if (state.selectedPatch) {
|
||||
const patches = (state.data && state.data.patches) || [];
|
||||
const isLatest =
|
||||
patches.length > 0 && patches[0].version === state.selectedPatch;
|
||||
if (!isLatest) {
|
||||
hash += "/" + encodeURIComponent(state.selectedPatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.page === "heroes") {
|
||||
const qs = new URLSearchParams();
|
||||
if (state.tagFilters && state.tagFilters.size) {
|
||||
qs.set("tags", Array.from(state.tagFilters).join(","));
|
||||
}
|
||||
if (state.query) qs.set("q", state.query);
|
||||
const s = qs.toString();
|
||||
if (s) hash += "?" + s;
|
||||
} else if (state.page === "items") {
|
||||
const qs = new URLSearchParams();
|
||||
if (state.itemQuery) qs.set("q", state.itemQuery);
|
||||
const s = qs.toString();
|
||||
if (s) hash += "?" + s;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/** Write current state into the URL. Use replace:true for high-frequency
|
||||
* updates (search typing, tag toggles) to avoid history-stack spam. */
|
||||
function syncStateToUrl({ replace = false } = {}) {
|
||||
if (!_deps) return;
|
||||
const hash = serializeHash(_deps.getState());
|
||||
if (hash === window.location.hash) return; // no-op when state matches URL
|
||||
if (replace) {
|
||||
history.replaceState(null, "", hash);
|
||||
} else {
|
||||
history.pushState(null, "", hash);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse current location.hash, hand it to app for validation+merge, render.
|
||||
* Invoked on hashchange (back/forward / direct location.hash writes) and
|
||||
* once at startup for deep-link support. */
|
||||
function applyUrlToState() {
|
||||
if (!_deps) return;
|
||||
const patch = parseHash(window.location.hash);
|
||||
_deps.applyPatch(patch);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user