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
+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