"""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", "明星比赛", "明星选手近期职业与国服对局。"),
("/players", "玩家战绩", "PC 上分帝赛后生成的玩家主页与比赛详情。"),
]
_TITLE_RE = re.compile(r"
[^<]*", re.I)
_DESC_RE = re.compile(
r'',
re.I,
)
_CANONICAL_RE = re.compile(
r'',
re.I,
)
_OG_TITLE_RE = re.compile(
r'',
re.I,
)
_OG_DESC_RE = re.compile(
r'',
re.I,
)
_OG_URL_RE = re.compile(
r'',
re.I,
)
_TW_TITLE_RE = re.compile(
r'',
re.I,
)
_TW_DESC_RE = re.compile(
r'',
re.I,
)
_JSONLD_RE = re.compile(
r'',
re.I | re.S,
)
_SEO_ASIDE_RE = re.compile(
r'',
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 "
暂无条目
"
lis = "".join(f"
{_esc(x)}
" for x in items)
return f"
{lis}
"
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"
"
f""
)
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""
f"
"
f""
)
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'
')
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'