Path URLs, crawlable hero/mechanics pages, and sitemap make the static site indexable while keeping SPA hydration. Co-authored-by: Cursor <cursoragent@cursor.com>
456 lines
16 KiB
Python
456 lines
16 KiB
Python
"""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
|