Files
climperor/web/seo_prerender.py
vosonandCursor 4a61aeeb26 Add PC post-match player pages with opt-in public OSS sync.
Generate /players/{account_id}[/{match_id}] locally after POST_GAME via OpenDota; publish to OSS only when public_share is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 11:01:32 +08:00

457 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"<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