v0.5.115: add History routing and SEO prerender for Climperor Web.

Path URLs, crawlable hero/mechanics pages, and sitemap make the static site indexable while keeping SPA hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 04:55:33 +08:00
co-authored by Cursor
parent 38f46ad2ea
commit 544ea42d40
16 changed files with 838 additions and 81 deletions
+46 -5
View File
@@ -2,12 +2,15 @@
Usage:
python export_relations_site.py [--out dist/relations] [--with-videos]
[--ability-video-base URL] [--static-asset-base URL]
[--ability-video-base URL] [--static-asset-base URL] [--site-origin URL]
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
Copies web/frontend/ + a snapshot of the /api/data payload (data.json) +
the referenced image assets into one directory, ready for any static host
(GitHub Pages, Cloudflare Pages, nginx, ...).
Also runs seo_prerender: crawlable HTML for heroes/mechanics/top pages,
plus sitemap.xml / llms.txt / robots.txt / _redirects (History SPA).
Notes:
- Only already-cached assets are exported. For full ability-icon coverage
run `python fetch_hero_abilities.py --icons-only` first.
@@ -50,9 +53,10 @@ from shared.paths import (
WEB_DIST,
)
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.114"
SITE_VERSION = "0.5.115"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
@@ -133,14 +137,17 @@ def write_config_js(
ability_video_base: str,
static_asset_base: str,
site_version: str,
site_origin: str,
) -> None:
"""Write config.js consumed by app.js."""
video = (ability_video_base or "").strip().rstrip("/")
static = (static_asset_base or "").strip().rstrip("/")
ver = (site_version or "").strip()
origin = (site_origin or "").strip().rstrip("/")
(out / "config.js").write_text(
"/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
f"var SITE_ORIGIN = {json.dumps(origin, ensure_ascii=False)};\n"
f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n"
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n",
encoding="utf-8",
@@ -170,6 +177,12 @@ def main() -> None:
"when set, image dirs are not copied into dist; "
"falls back to STATIC_ASSET_BASE env, else empty",
)
ap.add_argument(
"--site-origin",
default=None,
help="canonical site origin for SEO (sitemap / og / config SITE_ORIGIN); "
"falls back to SITE_ORIGIN env, else https://dota2.refining.dev",
)
args = ap.parse_args()
video_base = (
@@ -182,6 +195,11 @@ def main() -> None:
if args.static_asset_base is not None
else os.environ.get("STATIC_ASSET_BASE", "")
)
site_origin = (
args.site_origin
if args.site_origin is not None
else os.environ.get("SITE_ORIGIN", DEFAULT_SITE_ORIGIN)
)
out = Path(args.out).resolve()
if out == ROOT.resolve() or out.parent == out:
@@ -190,7 +208,16 @@ def main() -> None:
shutil.rmtree(out)
out.mkdir(parents=True)
for name in ("index.html", "router.js", "app.js", "style.css", "mobile-gate.js", "_headers"):
for name in (
"index.html",
"router.js",
"app.js",
"style.css",
"mobile-gate.js",
"_headers",
"_redirects",
"robots.txt",
):
src = WEB_DIR / name
if src.is_file():
shutil.copy2(src, out / name)
@@ -205,6 +232,7 @@ def main() -> None:
ability_video_base=video_base,
static_asset_base=static_base,
site_version=SITE_VERSION,
site_origin=site_origin,
)
payload = build_payload()
@@ -217,6 +245,14 @@ def main() -> None:
separators=(",", ":"),
)
template_html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
seo_counts = write_seo_bundle(
out,
template_html,
payload,
site_origin=site_origin,
)
if static_base:
counts = {
name: 0
@@ -244,12 +280,17 @@ def main() -> None:
print(f"exported static site -> {out}")
for name, n in counts.items():
print(f" {name}/: {n} files")
print(
f" seo prerender: top={seo_counts['top']} "
f"heroes={seo_counts['heroes']} mechanics={seo_counts['mechanics']} "
f"(+ sitemap.xml / llms.txt / robots.txt)"
)
if n_functions:
print(f" functions/: {n_functions} files (Pages Functions)")
if args.with_videos:
print(f" ability-video/: {n_videos} files")
print(
f" config.js SITE_VERSION={SITE_VERSION!r} "
f" config.js SITE_VERSION={SITE_VERSION!r} SITE_ORIGIN={site_origin!r} "
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r}"
)
print(f" total: {total / 1e6:.1f} MB")
+3
View File
@@ -0,0 +1,3 @@
# Cloudflare Pages: static files win; these cover History deep links without a file.
/heroes/:key/:tab /heroes/:key/index.html 200
/* /index.html 200
+137 -2
View File
@@ -1,4 +1,4 @@
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION */
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION, SITE_ORIGIN */
function trimBase(raw) {
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
@@ -76,6 +76,138 @@ function siteVersionLabel() {
return v ? `v${v}` : "";
}
function siteOrigin() {
const configured = typeof SITE_ORIGIN === "string" ? SITE_ORIGIN.trim().replace(/\/+$/, "") : "";
if (configured) return configured;
if (typeof location !== "undefined" && location.origin) return location.origin;
return "";
}
const PAGE_SEO_LABELS = {
heroes: "英雄克制与搭档",
rankings: "Immortal 排行",
streamers: "主播",
matches: "明星比赛",
trends: "近 8 周走势",
mechanics: "机制查询",
items: "物品商店",
patches: "版本更新",
};
const DETAIL_TAB_SEO_LABELS = {
skills: "技能",
core: "核心装",
fears: "怕的装备",
trends: "走势",
matchups: "对位",
matches: "近期比赛",
streamers: "主播",
patches: "版本改动",
};
function setMetaByKey(attr, key, content) {
if (!content) return;
let el = document.querySelector(`meta[${attr}="${key}"]`);
if (!el) {
el = document.createElement("meta");
el.setAttribute(attr, key);
document.head.appendChild(el);
}
el.setAttribute("content", content);
}
function describeStateForSeo(st) {
const brand = "上分帝";
const page = st.page || "heroes";
let title = `${PAGE_SEO_LABELS[page] || "DOTA2"}${brand}`;
let description =
"Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。";
let path = "/" + page;
try {
if (typeof serializeHash === "function") path = serializeHash(st) || "/heroes";
} catch (_) {
/* keep path */
}
if (!st.data) return { title, description, path };
if (page === "heroes" && st.selectedKey) {
const hero = heroByKey(st.selectedKey);
const name = (hero && hero.name_loc) || st.selectedKey;
const aliases = (hero && hero.aliases) || [];
const tab = DETAIL_TAB_SEO_LABELS[st.detailTab] || "详情";
title = `${name} ${tab} / 克制搭档 — ${brand}`;
const aliasBit = aliases.length ? `${aliases.slice(0, 3).join("、")}` : "";
description = `${name}${aliasBit}的 Dota 2 机制克制、被克制与搭档参考,以及技能、出装、走势与对位数据。`;
} else if (page === "mechanics") {
const mq = st.data.mechanic_query || {};
const labels = mq.labels || {};
const effect = st.mechanicEffect || "basic_dispel";
const label = labels[effect] || effect;
title = `${label} — 机制查询 — ${brand}`;
const blurb = (mq.blurbs && mq.blurbs[effect]) || "";
description = blurb || `查询 Dota 2 中施加「${label}」的技能与物品。`;
} else if (page === "items" && st.selectedItemKey) {
const meta = shopItem(st.selectedItemKey);
const name = (meta && (meta.name_loc || meta.dname)) || st.selectedItemKey;
title = `${name} — 物品 — ${brand}`;
description = `${name} 的合成、描述与机制标签(上分帝物品页)。`;
} else if (page === "patches") {
const ver =
st.selectedPatch ||
((st.data.patches && st.data.patches[0]) || {}).version;
if (ver) {
title = `版本 ${ver}${brand}`;
description = `Dota 2 ${ver} 游戏性更新摘要(上分帝版本页)。`;
}
} else if (page === "trends") {
title = `近 8 周走势 — ${brand}`;
description = "各勋章段位近 8 周英雄胜率与上场率走势榜。";
} else if (page === "rankings") {
title = `Immortal 排行榜 — ${brand}`;
description = "Valve Immortal 四区 Top100 选手榜。";
} else if (page === "streamers") {
title = `Dota 2 主播 — ${brand}`;
description = "精选 Dota 2 主播目录与高光(抖音 / B 站 / 斗鱼)。";
} else if (page === "matches") {
title = `明星比赛 — ${brand}`;
description = "明星选手近期职业与国服对局、终局出装与加点。";
} else if (page === "heroes") {
title = `英雄克制与搭档 — ${brand}`;
description =
"按英雄浏览定性克制 / 被克制 / 搭档理由,以及技能、核心装、走势与对位。";
}
return { title, description, path };
}
function updateDocumentMeta() {
if (typeof document === "undefined" || !document.title) return;
const { title, description, path } = describeStateForSeo(state);
document.title = title;
setMetaByKey("name", "description", description);
setMetaByKey("property", "og:title", title);
setMetaByKey("property", "og:description", description);
setMetaByKey("name", "twitter:title", title);
setMetaByKey("name", "twitter:description", description);
const origin = siteOrigin();
if (origin) {
const url = origin + (path.startsWith("/") ? path : `/${path}`);
setMetaByKey("property", "og:url", url);
let link = document.querySelector('link[rel="canonical"]');
if (!link) {
link = document.createElement("link");
link.setAttribute("rel", "canonical");
document.head.appendChild(link);
}
link.setAttribute("href", url);
}
}
function clearSeoPrerender() {
const el = document.getElementById("seo-prerender");
if (el) el.remove();
}
function renderPatchesSiteVersion() {
const el = $("#patches-site-version");
if (!el) return;
@@ -5468,6 +5600,7 @@ function render() {
} else {
renderPatches();
}
updateDocumentMeta();
}
// Search-box URL sync timers (debounced replace so typing does not spam history).
@@ -5698,7 +5831,8 @@ async function main() {
if (brandLogo) brandLogo.src = assetUrl("/ui-icon/dota2_logo_wordmark.png");
const favicon = document.querySelector('link[rel="icon"]');
if (favicon) favicon.href = assetUrl("/ui-icon/dota2_logo.png");
const res = await fetch("data.json");
// Absolute path: History routes like /heroes/axe must not resolve data.json relatively.
const res = await fetch("/data.json");
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
state.data = await res.json();
state.data.relations = state.data.relations || { counters: [], synergies: [] };
@@ -5755,6 +5889,7 @@ async function main() {
_innateAbilityKeys = null;
installRouter({ getState: () => state, applyPatch });
applyUrlToState();
clearSeoPrerender();
refreshStreamerLiveStatus();
}
+2 -1
View File
@@ -1,5 +1,6 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.114";
var SITE_VERSION = "0.5.115";
var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+60 -6
View File
@@ -3,13 +3,67 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title>
<title>DOTA2 上分帝 — 英雄克制 / 搭档 / 走势 / 机制</title>
<meta
name="description"
content="上分帝(Climperor):Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。定性关系理由 + 公开数据走势,助你上分。"
/>
<meta name="keywords" content="DOTA2,上分帝,Climperor,英雄克制,搭档,胜率走势,机制查询,驱散,版本更新" />
<link rel="canonical" href="https://dota2.refining.dev/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="上分帝" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:title" content="DOTA2 上分帝 — 英雄克制 / 搭档 / 走势 / 机制" />
<meta
property="og:description"
content="Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。"
/>
<meta property="og:url" content="https://dota2.refining.dev/" />
<meta property="og:image" content="https://climperor.oss-cn-shanghai.aliyuncs.com/ui-icon/dota2_logo_wordmark.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="DOTA2 上分帝" />
<meta
name="twitter:description"
content="Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。"
/>
<script type="application/ld+json" id="seo-jsonld">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "上分帝",
"alternateName": ["Climperor", "DOTA2 上分帝"],
"url": "https://dota2.refining.dev/",
"inLanguage": "zh-CN",
"description": "Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。",
"potentialAction": {
"@type": "SearchAction",
"target": "https://dota2.refining.dev/heroes?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.114" />
<script src="/mobile-gate.js?v=0.5.114"></script>
<link rel="stylesheet" href="/style.css?v=0.5.115" />
<script src="/mobile-gate.js?v=0.5.115"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
<aside id="seo-prerender" class="seo-prerender">
<p>
上分帝(Climperor)是面向 Dota 2 的选将与机制参考站:浏览英雄克制与搭档理由、
段位走势、机制查询(驱散与控制)、物品商店与版本更新。请使用桌面浏览器访问完整交互界面。
</p>
<ul>
<li><a href="/heroes">英雄克制与搭档</a></li>
<li><a href="/mechanics">机制查询</a></li>
<li><a href="/trends">近 8 周走势</a></li>
<li><a href="/items">物品商店</a></li>
<li><a href="/patches">版本更新</a></li>
<li><a href="/rankings">Immortal 排行</a></li>
<li><a href="/streamers">主播</a></li>
<li><a href="/matches">明星比赛</a></li>
</ul>
</aside>
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
<div class="mobile-gate-card">
<div class="mobile-gate-brand" aria-hidden="true">
@@ -178,8 +232,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.114"></script>
<script src="/router.js?v=0.5.114"></script>
<script src="/app.js?v=0.5.114"></script>
<script src="/config.js?v=0.5.115"></script>
<script src="/router.js?v=0.5.115"></script>
<script src="/app.js?v=0.5.115"></script>
</body>
</html>
+13
View File
@@ -2,8 +2,16 @@
* Early mobile client gate (loaded from <head>).
* Sets html.mobile-client before paint; wires the demand button after DOM ready.
* Exposes window.__CLIMPEROR_MOBILE__ for app.js to skip the desktop boot path.
* Search / AI crawlers skip the gate so prerendered HTML stays indexable.
*/
(function () {
function isCrawler() {
var ua = navigator.userAgent || "";
return /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora link preview|pinterest|redditbot|linkedinbot|twitterbot|whatsapp|google-inspectiontool|bytespider|baiduspider|yandex|duckduckbot|applebot|semrush|ahrefs|gptbot|claudebot|anthropic|perplexity|oai-searchbot|chatgpt/i.test(
ua
);
}
function isMobileClient() {
var ua = navigator.userAgent || "";
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
@@ -16,6 +24,11 @@
return false;
}
if (isCrawler()) {
window.__CLIMPEROR_MOBILE__ = false;
return;
}
var mobile = isMobileClient();
window.__CLIMPEROR_MOBILE__ = mobile;
if (!mobile) return;
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://dota2.refining.dev/sitemap.xml
+65 -44
View File
@@ -1,38 +1,43 @@
/* global window, history, location */
/**
* Hash-based router for the Climperor web site (web/relations).
* Path-based router for the Climperor web site (web/frontend).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches (top-level tab)
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches
* - 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)
* /rankings[/region] (legacy /rankings/meta[/bracket] players / china)
* - matches: star-player recent matches (pro_matches)
* #/matches[/account_id][?origin=pro|china][&page=N]
* /matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - streamers: curated Douyin streamer directory
* #/streamers
* - streamers: curated streamer directory
* /streamers
* - trends: medal bracket for the 8-week win/pick board
* #/trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted)
* /trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted)
* - mechanics: applies-effect query (dispel / CC)
* #/mechanics[/{effect}] (default effect basic_dispel omitted)
* /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)
*
* Legacy hash URLs (#/heroes/...) are migrated once to path URLs via replaceState.
*
* 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.
* (no popstate) so applyUrlToState is never re-entered.
* - Back/forward navigation fires popstate applyUrlToState parses
* the new path, hands a validated patch back to app, and app re-renders.
*
* API names parseHash / serializeHash are kept for call-site stability; both
* now operate on pathname + search (not location.hash).
*
* Depends on app.js for state shape and render(); installed via installRouter().
*/
const ROUTE_DEFAULT = "#/heroes";
const ROUTE_DEFAULT = "/heroes";
const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"];
const VALID_DETAIL_TABS = [
"skills",
@@ -85,11 +90,20 @@ let _deps = null;
*/
function installRouter(deps) {
_deps = deps;
window.addEventListener("hashchange", applyUrlToState);
migrateLegacyHashRoute();
window.addEventListener("popstate", applyUrlToState);
}
/** Parse a hash string into a state patch (all fields nullable). */
function parseHash(hash) {
/** One-shot: #/heroes/... → /heroes/... (bookmarks / shared links). */
function migrateLegacyHashRoute() {
const hash = window.location.hash || "";
if (!hash.startsWith("#/")) return;
const next = hash.slice(1) || ROUTE_DEFAULT;
history.replaceState(null, "", next);
}
/** Parse a path (+ optional query) into a state patch (all fields nullable). */
function parseHash(hashOrPath) {
const out = {
page: null,
heroKey: null,
@@ -107,14 +121,17 @@ function parseHash(hash) {
query: null,
itemQuery: null,
};
let raw = hash || "";
let raw = hashOrPath || "";
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;
if (!segs.length) {
out.page = "heroes";
return out;
}
const page = segs[0];
if (!VALID_PAGES.includes(page)) return out;
out.page = page;
@@ -122,7 +139,7 @@ function parseHash(hash) {
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).
// Legacy /rankings/meta[/bracket] → Immortal players board (default region).
if (segs[1] && segs[1] !== "meta") {
out.rankingRegion = safeDecode(segs[1]);
}
@@ -179,56 +196,56 @@ function safeDecode(s) {
}
}
/** Serialize current app state into a hash string (with leading '#'). */
/** Serialize current app state into a path string (leading '/', optional ?query). */
function serializeHash(state) {
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
let hash = "#/" + state.page;
let path = "/" + state.page;
if (state.page === "heroes") {
if (state.selectedKey) {
hash += "/" + encodeURIComponent(state.selectedKey);
path += "/" + 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);
path += "/" + encodeURIComponent(tab);
}
}
} else if (state.page === "rankings") {
// Omit region when it is the default (china) — bare #/rankings means China.
// 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);
path += "/" + encodeURIComponent(region);
}
} else if (state.page === "matches") {
if (state.matchesPlayerId) {
hash += "/" + encodeURIComponent(String(state.matchesPlayerId));
path += "/" + encodeURIComponent(String(state.matchesPlayerId));
}
} else if (state.page === "trends") {
// Omit bracket when it is the default (legend) — bare #/trends means legend.
// 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);
path += "/" + 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);
path += "?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);
path += "/" + encodeURIComponent(effect);
}
} else if (state.page === "items") {
if (state.selectedItemKey) {
hash += "/" + encodeURIComponent(state.selectedItemKey);
path += "/" + encodeURIComponent(state.selectedItemKey);
}
} else if (state.page === "patches") {
// Omit version when it is the latest — bare #/patches means "latest".
// 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);
path += "/" + encodeURIComponent(state.selectedPatch);
}
}
}
@@ -239,12 +256,12 @@ function serializeHash(state) {
}
if (state.query) qs.set("q", state.query);
const s = qs.toString();
if (s) hash += "?" + s;
if (s) path += "?" + 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;
if (s) path += "?" + s;
} else if (state.page === "matches") {
const qs = new URLSearchParams();
const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN;
@@ -254,29 +271,33 @@ function serializeHash(state) {
const pageNum = Number(state.matchesPage) || 1;
if (pageNum > 1) qs.set("page", String(pageNum));
const s = qs.toString();
if (s) hash += "?" + s;
if (s) path += "?" + s;
}
return hash;
return path;
}
function currentRoutePath() {
return (window.location.pathname || "/") + (window.location.search || "");
}
/** 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
const path = serializeHash(_deps.getState());
if (path === currentRoutePath()) return; // no-op when state matches URL
if (replace) {
history.replaceState(null, "", hash);
history.replaceState(null, "", path);
} else {
history.pushState(null, "", hash);
history.pushState(null, "", path);
}
}
/** 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. */
/** Parse current location path+search, hand it to app for validation+merge.
* Invoked on popstate (back/forward) and once at startup for deep-link support. */
function applyUrlToState() {
if (!_deps) return;
const patch = parseHash(window.location.hash);
const path = currentRoutePath();
const patch = parseHash(path === "/" ? "/heroes" : path);
_deps.applyPatch(patch);
}
+2 -1
View File
@@ -135,7 +135,8 @@ body {
color: var(--muted);
line-height: 1.4;
}
.sr-only {
.sr-only,
.seo-prerender {
position: absolute;
width: 1px;
height: 1px;
+2
View File
@@ -65,6 +65,8 @@ INPUT_WATCH = [
WEB_FRONTEND / "mobile-gate.js",
WEB_FRONTEND / "config.js",
WEB_FRONTEND / "_headers",
WEB_FRONTEND / "_redirects",
WEB_FRONTEND / "robots.txt",
]
FRONTEND_FUNCTIONS_DIR = WEB_FRONTEND / "functions"
ASSET_DIRS = [
+455
View File
@@ -0,0 +1,455 @@
"""Build SEO prerender HTML, sitemap, and llms.txt for Climperor Web export.
Writes crawlable path pages under dist (heroes / mechanics / top-level tabs)
while keeping the same SPA shell for hydration.
"""
from __future__ import annotations
import html
import json
import re
from pathlib import Path
from xml.sax.saxutils import escape as xml_escape
DEFAULT_SITE_ORIGIN = "https://dota2.refining.dev"
TOP_PAGES: list[tuple[str, str, str]] = [
("/", "英雄克制与搭档", "按英雄浏览定性克制、被克制与搭档理由,以及技能与走势。"),
("/heroes", "英雄克制与搭档", "Dota 2 英雄机制克制与搭档目录。"),
("/mechanics", "机制查询", "查询施加驱散与控制等效果的技能与物品。"),
("/trends", "近 8 周走势", "各勋章段位近 8 周英雄胜率与上场率走势榜。"),
("/items", "物品商店", "基础与合成分类物品目录。"),
("/patches", "版本更新", "近一年游戏性更新摘要。"),
("/rankings", "Immortal 排行", "Valve Immortal 四区 Top100。"),
("/streamers", "主播", "精选 Dota 2 主播目录。"),
("/matches", "明星比赛", "明星选手近期职业与国服对局。"),
]
_TITLE_RE = re.compile(r"<title>[^<]*</title>", re.I)
_DESC_RE = re.compile(
r'<meta\s+name="description"\s+content="[^"]*"\s*/?>',
re.I,
)
_CANONICAL_RE = re.compile(
r'<link\s+rel="canonical"\s+href="[^"]*"\s*/?>',
re.I,
)
_OG_TITLE_RE = re.compile(
r'<meta\s+property="og:title"\s+content="[^"]*"\s*/?>',
re.I,
)
_OG_DESC_RE = re.compile(
r'<meta\s+property="og:description"\s+content="[^"]*"\s*/?>',
re.I,
)
_OG_URL_RE = re.compile(
r'<meta\s+property="og:url"\s+content="[^"]*"\s*/?>',
re.I,
)
_TW_TITLE_RE = re.compile(
r'<meta\s+name="twitter:title"\s+content="[^"]*"\s*/?>',
re.I,
)
_TW_DESC_RE = re.compile(
r'<meta\s+name="twitter:description"\s+content="[^"]*"\s*/?>',
re.I,
)
_JSONLD_RE = re.compile(
r'<script type="application/ld\+json" id="seo-jsonld">.*?</script>',
re.I | re.S,
)
_SEO_ASIDE_RE = re.compile(
r'<aside id="seo-prerender" class="seo-prerender">.*?</aside>',
re.I | re.S,
)
def _esc(s: object) -> str:
return html.escape("" if s is None else str(s), quote=True)
def _abs(origin: str, path: str) -> str:
base = (origin or DEFAULT_SITE_ORIGIN).rstrip("/")
if not path.startswith("/"):
path = "/" + path
return base + path
def _hero_name_map(payload: dict) -> dict[str, str]:
out: dict[str, str] = {}
for h in payload.get("heroes") or []:
if not isinstance(h, dict):
continue
key = h.get("key")
if not key:
continue
out[str(key)] = str(h.get("name_loc") or key)
return out
def _relation_lists(payload: dict, hero_key: str) -> tuple[list[str], list[str], list[str]]:
names = _hero_name_map(payload)
rel = payload.get("relations") or {}
counters_out: list[str] = []
countered_out: list[str] = []
syn_out: list[str] = []
for edge in rel.get("counters") or []:
if not isinstance(edge, dict):
continue
a, b = edge.get("a"), edge.get("b")
reason = (edge.get("reason") or "").strip()
if a == hero_key and b:
label = names.get(str(b), str(b))
counters_out.append(f"{label}" + (f"{reason}" if reason else ""))
elif b == hero_key and a:
label = names.get(str(a), str(a))
countered_out.append(f"{label}" + (f"{reason}" if reason else ""))
for edge in rel.get("synergies") or []:
if not isinstance(edge, dict):
continue
a, b = edge.get("a"), edge.get("b")
reason = (edge.get("reason") or "").strip()
peer = None
if a == hero_key and b:
peer = str(b)
elif b == hero_key and a:
peer = str(a)
if peer:
label = names.get(peer, peer)
syn_out.append(f"{label}" + (f"{reason}" if reason else ""))
return counters_out[:12], countered_out[:12], syn_out[:12]
def _ul(items: list[str]) -> str:
if not items:
return "<p>暂无条目</p>"
lis = "".join(f"<li>{_esc(x)}</li>" for x in items)
return f"<ul>{lis}</ul>"
def _hero_seo_body(hero: dict, payload: dict) -> str:
key = str(hero.get("key") or "")
name = str(hero.get("name_loc") or key)
aliases = [str(a) for a in (hero.get("aliases") or []) if a]
tags = [str(t) for t in (hero.get("tags") or []) if t]
counters, countered, syns = _relation_lists(payload, key)
alias_bit = f"(别名:{''.join(_esc(a) for a in aliases)}" if aliases else ""
tag_bit = f"<p>定位:{''.join(_esc(t) for t in tags)}</p>" if tags else ""
return (
f"<article>"
f"<h1>{_esc(name)} — 克制与搭档</h1>"
f"<p>{_esc(name)}{alias_bit}的 Dota 2 机制克制、被克制与搭档参考(上分帝定性关系,非胜率因果)。</p>"
f"{tag_bit}"
f"<h2>克制</h2>{_ul(counters)}"
f"<h2>被克制</h2>{_ul(countered)}"
f"<h2>搭档</h2>{_ul(syns)}"
f"<p><a href=\"/heroes\">返回英雄目录</a> · "
f"<a href=\"/mechanics\">机制查询</a></p>"
f"</article>"
)
def _mechanic_seo_body(effect: str, payload: dict) -> str:
mq = payload.get("mechanic_query") or {}
labels = mq.get("labels") or {}
blurbs = mq.get("blurbs") or {}
label = labels.get(effect) or effect
blurb = blurbs.get(effect) or f"列出施加「{label}」的技能与物品。"
names = _hero_name_map(payload)
abil_lines: list[str] = []
by_hero = ((payload.get("hero_abilities") or {}).get("by_hero")) or {}
for hkey, cell in by_hero.items():
if not isinstance(cell, dict):
continue
hname = names.get(str(hkey), str(hkey))
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
if effect not in (ab.get("tags") or []):
continue
aname = ab.get("name_loc") or ab.get("key") or ""
abil_lines.append(f"{hname} · {aname}")
abil_lines = sorted(set(abil_lines), key=lambda s: s)[:80]
item_lines: list[str] = []
for row in (payload.get("items_meta") or {}).values():
if not isinstance(row, dict):
continue
if effect not in (row.get("tags") or []):
continue
item_lines.append(str(row.get("name_loc") or row.get("key") or ""))
item_lines = sorted({x for x in item_lines if x})[:40]
return (
f"<article>"
f"<h1>{_esc(label)} — 机制查询</h1>"
f"<p>{_esc(blurb)}</p>"
f"<h2>技能({len(abil_lines)}</h2>{_ul(abil_lines)}"
f"<h2>物品({len(item_lines)}</h2>{_ul(item_lines)}"
f"<p><a href=\"/mechanics\">全部机制</a> · <a href=\"/heroes\">英雄</a></p>"
f"</article>"
)
def _top_seo_body(path: str, title: str, description: str, payload: dict) -> str:
hero_links = []
for h in (payload.get("heroes") or [])[:40]:
if not isinstance(h, dict) or not h.get("key"):
continue
key = str(h["key"])
name = str(h.get("name_loc") or key)
hero_links.append(f'<li><a href="/heroes/{_esc(key)}">{_esc(name)}</a></li>')
mq = payload.get("mechanic_query") or {}
labels = mq.get("labels") or {}
mech_links = []
for effect in mq.get("order") or []:
label = labels.get(effect) or effect
href = "/mechanics" if effect == "basic_dispel" else f"/mechanics/{effect}"
mech_links.append(f'<li><a href="{_esc(href)}">{_esc(label)}</a></li>')
extra = ""
if path in ("/", "/heroes"):
extra = f"<h2>英雄目录(部分)</h2><ul>{''.join(hero_links)}</ul>"
if path in ("/", "/mechanics"):
extra += f"<h2>机制效果</h2><ul>{''.join(mech_links)}</ul>"
return (
f"<article>"
f"<h1>{_esc(title)} — 上分帝</h1>"
f"<p>{_esc(description)}</p>"
f"{extra}"
f"</article>"
)
def _jsonld_website(origin: str, title: str, description: str, url: str) -> str:
payload = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"name": "上分帝",
"alternateName": ["Climperor", "DOTA2 上分帝"],
"url": origin.rstrip("/") + "/",
"inLanguage": "zh-CN",
"description": "Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。",
"potentialAction": {
"@type": "SearchAction",
"target": origin.rstrip("/") + "/heroes?q={search_term_string}",
"query-input": "required name=search_term_string",
},
},
{
"@type": "WebPage",
"name": title,
"description": description,
"url": url,
"isPartOf": {"@type": "WebSite", "name": "上分帝", "url": origin.rstrip("/") + "/"},
"inLanguage": "zh-CN",
},
],
}
body = json.dumps(payload, ensure_ascii=False, indent=2)
return f'<script type="application/ld+json" id="seo-jsonld">\n{body}\n </script>'
def inject_seo(
template: str,
*,
title: str,
description: str,
canonical: str,
seo_body_html: str,
origin: str,
) -> str:
"""Replace head SEO tags and #seo-prerender body in the SPA shell."""
out = template
out = _TITLE_RE.sub(f"<title>{_esc(title)}</title>", out, count=1)
out = _DESC_RE.sub(
f'<meta\n name="description"\n content="{_esc(description)}"\n />',
out,
count=1,
)
out = _CANONICAL_RE.sub(
f'<link rel="canonical" href="{_esc(canonical)}" />',
out,
count=1,
)
out = _OG_TITLE_RE.sub(
f'<meta property="og:title" content="{_esc(title)}" />',
out,
count=1,
)
out = _OG_DESC_RE.sub(
f'<meta\n property="og:description"\n content="{_esc(description)}"\n />',
out,
count=1,
)
out = _OG_URL_RE.sub(
f'<meta property="og:url" content="{_esc(canonical)}" />',
out,
count=1,
)
out = _TW_TITLE_RE.sub(
f'<meta name="twitter:title" content="{_esc(title)}" />',
out,
count=1,
)
out = _TW_DESC_RE.sub(
f'<meta\n name="twitter:description"\n content="{_esc(description)}"\n />',
out,
count=1,
)
out = _JSONLD_RE.sub(
_jsonld_website(origin, title, description, canonical),
out,
count=1,
)
aside = (
f'<aside id="seo-prerender" class="seo-prerender">\n'
f" {seo_body_html}\n"
f" </aside>"
)
out = _SEO_ASIDE_RE.sub(aside, out, count=1)
return out
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
def build_sitemap(urls: list[str], origin: str) -> str:
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
]
for path in urls:
loc = xml_escape(_abs(origin, path))
lines.append(" <url>")
lines.append(f" <loc>{loc}</loc>")
lines.append(" </url>")
lines.append("</urlset>")
lines.append("")
return "\n".join(lines)
def build_llms_txt(urls: list[tuple[str, str]], origin: str) -> str:
lines = [
"# 上分帝 (Climperor)",
"",
"> Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。",
"",
f"站点:{_abs(origin, '/')}",
"",
"## 主要页面",
"",
]
for path, title in urls:
lines.append(f"- [{title}]({_abs(origin, path)})")
lines.append("")
lines.append("## 说明")
lines.append("")
lines.append("- 克制/搭档为定性机制边(含理由),不是胜率因果结论。")
lines.append("- 走势/对位数据来自公开统计源,页面会标注窗口与段位。")
lines.append("- 完整交互界面面向桌面浏览器。")
lines.append("")
return "\n".join(lines)
def write_seo_bundle(
out: Path,
template_html: str,
payload: dict,
*,
site_origin: str = DEFAULT_SITE_ORIGIN,
) -> dict[str, int]:
"""Write prerendered pages + sitemap.xml + llms.txt into ``out``.
Root ``index.html`` is rewritten in place with home SEO. Nested pages
are written as ``heroes/<key>/index.html`` and ``mechanics/<effect>/index.html``.
"""
origin = (site_origin or DEFAULT_SITE_ORIGIN).rstrip("/")
sitemap_paths: list[str] = []
llms_entries: list[tuple[str, str]] = []
counts = {"top": 0, "heroes": 0, "mechanics": 0}
for path, title, desc in TOP_PAGES:
full_title = f"{title} — 上分帝"
body = _top_seo_body(path, title, desc, payload)
html_doc = inject_seo(
template_html,
title=full_title,
description=desc,
canonical=_abs(origin, path if path != "/" else "/"),
seo_body_html=body,
origin=origin,
)
if path == "/":
write_text(out / "index.html", html_doc)
else:
# /heroes → heroes/index.html etc.
rel = path.strip("/")
write_text(out / rel / "index.html", html_doc)
sitemap_paths.append(path if path != "/" else "/")
llms_entries.append((path if path != "/" else "/", title))
counts["top"] += 1
for hero in payload.get("heroes") or []:
if not isinstance(hero, dict):
continue
key = hero.get("key")
if not key:
continue
key = str(key)
name = str(hero.get("name_loc") or key)
aliases = [str(a) for a in (hero.get("aliases") or []) if a]
alias_bit = f"{''.join(aliases[:3])}" if aliases else ""
title = f"{name} 克制与搭档 — 上分帝"
desc = (
f"{name}{alias_bit}的 Dota 2 机制克制、被克制与搭档参考,"
f"以及技能、出装与走势(上分帝)。"
)
path = f"/heroes/{key}"
html_doc = inject_seo(
template_html,
title=title,
description=desc,
canonical=_abs(origin, path),
seo_body_html=_hero_seo_body(hero, payload),
origin=origin,
)
write_text(out / "heroes" / key / "index.html", html_doc)
sitemap_paths.append(path)
llms_entries.append((path, f"{name} 克制与搭档"))
counts["heroes"] += 1
mq = payload.get("mechanic_query") or {}
labels = mq.get("labels") or {}
for effect in mq.get("order") or []:
effect = str(effect)
label = labels.get(effect) or effect
# Default effect is bare /mechanics (already written as top page).
if effect == "basic_dispel":
continue
path = f"/mechanics/{effect}"
blurb = (mq.get("blurbs") or {}).get(effect) or f"查询施加「{label}」的技能与物品。"
title = f"{label} — 机制查询 — 上分帝"
html_doc = inject_seo(
template_html,
title=title,
description=str(blurb),
canonical=_abs(origin, path),
seo_body_html=_mechanic_seo_body(effect, payload),
origin=origin,
)
write_text(out / "mechanics" / effect / "index.html", html_doc)
sitemap_paths.append(path)
llms_entries.append((path, f"{label}(机制)"))
counts["mechanics"] += 1
# Prefer stable order: tops first, then heroes, then mechanics (already).
write_text(out / "sitemap.xml", build_sitemap(sitemap_paths, origin))
# Keep llms.txt focused: tops + sample of heroes would be huge; include all
# tops + mechanics + first-line note that hero URLs follow /heroes/{key}.
llms_compact = [(p, t) for p, t in llms_entries if not p.startswith("/heroes/")]
llms_compact.append(("/heroes/{key}", "各英雄克制/搭档页(key 为英雄英文键)"))
write_text(out / "llms.txt", build_llms_txt(llms_compact, origin))
return counts
+22 -4
View File
@@ -1115,11 +1115,29 @@ class Handler(BaseHTTPRequestHandler):
return
rel = path.lstrip("/")
candidate = (WEB_DIR / rel).resolve()
if not str(candidate).startswith(str(WEB_DIR.resolve())) or not candidate.is_file():
self.send_error(404)
web_root = WEB_DIR.resolve()
if str(candidate).startswith(str(web_root)) and candidate.is_file():
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
self._send(200, candidate.read_bytes(), ctype)
return
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
self._send(200, candidate.read_bytes(), ctype)
# History SPA fallback: /heroes/axe → index.html (client router).
spa_pages = {
"heroes",
"rankings",
"matches",
"streamers",
"trends",
"mechanics",
"items",
"patches",
}
first = path.strip("/").split("/", 1)[0] if path.strip("/") else ""
if first in spa_pages:
index = WEB_DIR / "index.html"
if index.is_file():
self._send(200, index.read_bytes(), "text/html; charset=utf-8")
return
self.send_error(404)
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path