"""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"[^<]*", 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"

    定位:{'、'.join(_esc(t) for t in tags)}

    " if tags else "" return ( f"
    " f"

    {_esc(name)} — 克制与搭档

    " f"

    {_esc(name)}{alias_bit}的 Dota 2 机制克制、被克制与搭档参考(上分帝定性关系,非胜率因果)。

    " f"{tag_bit}" f"

    克制

    {_ul(counters)}" f"

    被克制

    {_ul(countered)}" f"

    搭档

    {_ul(syns)}" f"

    返回英雄目录 · " 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"

    {_esc(label)} — 机制查询

    " f"

    {_esc(blurb)}

    " f"

    技能({len(abil_lines)})

    {_ul(abil_lines)}" f"

    物品({len(item_lines)})

    {_ul(item_lines)}" 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'
  • {_esc(name)}
  • ') 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'
  • {_esc(label)}
  • ') extra = "" if path in ("/", "/heroes"): extra = f"

    英雄目录(部分)

      {''.join(hero_links)}
    " if path in ("/", "/mechanics"): extra += f"

    机制效果

      {''.join(mech_links)}
    " return ( f"
    " f"

    {_esc(title)} — 上分帝

    " f"

    {_esc(description)}

    " f"{extra}" f"
    " ) 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'' 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"{_esc(title)}", out, count=1) out = _DESC_RE.sub( f'', out, count=1, ) out = _CANONICAL_RE.sub( f'', out, count=1, ) out = _OG_TITLE_RE.sub( f'', out, count=1, ) out = _OG_DESC_RE.sub( f'', out, count=1, ) out = _OG_URL_RE.sub( f'', out, count=1, ) out = _TW_TITLE_RE.sub( f'', out, count=1, ) out = _TW_DESC_RE.sub( f'', out, count=1, ) out = _JSONLD_RE.sub( _jsonld_website(origin, title, description, canonical), out, count=1, ) aside = ( f'" ) 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 = [ '', '', ] for path in urls: loc = xml_escape(_abs(origin, path)) lines.append(" ") lines.append(f" {loc}") lines.append(" ") lines.append("") 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//index.html`` and ``mechanics//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