news 2026/9/6 3:56:03

DOTA2赛事数据可视化分析实战:从OpenDota API到Python图表

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DOTA2赛事数据可视化分析实战:从OpenDota API到Python图表

从 TI15 小组赛 VG 让一追二说起:DOTA2 赛事数据可视化分析实战教程

TI15 国际邀请赛小组赛阶段,VG 对阵 GL 的这场 BO3 打得相当过瘾。VG 首局失利后没有崩心态,第二局开始调整节奏,最终让一追二成功晋级 2-1 组。尤其是 XM 操刀的滚滚(石鳞剑士),几波游走节奏直接把 GL 的阵容拉扯得支离破碎,这种“游龙”级别的表现,赛后自然成为弹幕和论坛的讨论焦点。

不过作为一名关注比赛的开发老哥,除了看比赛、刷集锦之外,我更好奇一个事:能不能把比赛里的关键数据抓下来,做成可视化分析,看看 XM 的滚滚到底“游”在哪一路、“滚”出了多少团队收益?

这篇文章就围绕“DOTA2 比赛数据可视化分析”这个主题展开。我会结合 VG vs GL 这场 TI15 小组赛的真实背景,带大家从比赛 ID 获取、数据分析、关键事件提取到可视化呈现,完整走一遍流程。无论你是刚接触 DOTA2 API 的新手,还是想写一个赛事复盘小工具的进阶玩家,这篇文章都能给你一个可落地的思路。

1. 比赛数据分析能做什么

1.1 数据回放的价值

观赛体验通常停留在“看个热闹”的层面,比如谁拿了人头、谁推了塔、哪波团战赢了。但比赛数据能告诉我们更多:

  • XM 的滚滚在游戏时间 12 分钟到 20 分钟之间做了多少次有效游走?
  • 每次游走是否成功逼出对方核心的 BKB 或者传送门?
  • VG 让一追二的关键转折点,在数据层面有什么异常波动?
  • 团队经济差、经验差的曲线,在哪一分钟出现“剪刀差”?

这些问题的答案都藏在比赛的详细数据里。通过赛后数据分析,我们可以把一场 BO3 拆解成时间轴 + 地图事件 + 经济曲线的组合,从而更客观地还原比赛进程。

1.2 应用场景

比赛数据可视化分析并不仅限于职业比赛复盘。它可以延伸到:

  • 自己开黑后的录像分析,看看哪个时间点掉点最严重;
  • 自定义地图或练习模式的效率评估;
  • 构建战队数据库,长期追踪选手英雄池与胜率关系;
  • 做赛事内容运营时,快速生成数据截图和动态图表。

与其在各种数据网站上等别人做好图,不如自己动手写一套流程,想分析哪场就分析哪场。

2. 环境准备与工具链

2.1 本教程使用的核心工具

为了完成这次分析,我建议按下面的工具链来搭建环境:

  • Python 3.9+:用于数据获取、清洗与分析。
  • Requests:调用 DOTA2 数据接口。
  • Pandas:处理表格化数据,尤其是英雄经济和经验曲线。
  • Matplotlib / Seaborn:绘制经济曲线、击杀时间轴等静态图。
  • Plotly:如果需要交互式的图表,可以做 HTML 版本。

版本方面不需要太纠结,Python 3.8 以上都能跑通。如果你用的是较新的 Python 3.11 或 3.12,只要 Pandas 和 Matplotlib 正常安装即可。

2.2 数据来源说明

DOTA2 比赛数据主要通过 Valve 的官方 API 和第三方数据平台暴露。常用的有两个:

  • OpenDota API:开源,能查比赛详情、选手信息、英雄数据,实际开发中用得非常多。
  • STRATZ API:基于 GraphQL,数据更丰富,需要 Token。

本文以 OpenDota 的公开接口为例来写代码,不需要 Token 就能查询大部分公开比赛数据,入门门槛较低。

如果你准备长期大量抓取数据,建议注册一个 OpenDota 账号并申请 API Key,速率限制会更宽松。

2.3 目录结构

建议先创建一个干净的工程目录,后面所有脚本都放在里面:

dota2-analysis/ ├── main.py # 主流程入口 ├── data/ │ ├── match_data.json # 原始比赛数据 │ └── processed.csv # 处理后的分析数据 └── output/ └── analysis_report/ ├── economy_curve.png ├── event_timeline.png └── xm_roll_track.png

在动手写代码之前,先确认一下你的 Python 环境里已经装好依赖:

pip install requests pandas matplotlib plotly

装完以后,就可以开始写数据获取脚本了。

3. DOTA2 比赛数据接口的调用方法

3.1 OpenDota API 基本结构

OpenDota 提供很多接口,但我们的分析主要依赖两个:

https://api.opendota.com/api/matches/{match_id}

这个接口会返回一场比赛的完整数据,包括:

  • players:每个选手的详细数据
  • objectives:建筑物摧毁事件
  • teamfights:团战数据
  • radiant_win:天辉是否获胜
  • duration:比赛时长(秒)

match_id 在比赛结束后可在客户端、网页端或者各种数据站中找到。对于已经打过的 TI15 小组赛,你可以通过 OpenDota 的搜索接口找到 VG 和 GL 的交手记录。

3.2 调用示例

先看一个最简单的请求代码,用来测试网络连通性和接口返回格式。

import requests import json def fetch_match(match_id: int): url = f"https://api.opendota.com/api/matches/{match_id}" headers = { "User-Agent": "Mozilla/5.0 (compatible; dota2-analysis/1.0)" } resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() return resp.json() if __name__ == "__main__": # 这里替换成你要分析的比赛 ID test_id = 1234567890 data = fetch_match(test_id) print("比赛时长:", data.get("duration"), "秒") print("天辉胜利:", data.get("radiant_win")) print(json.dumps(data, ensure_ascii=False)[:500])

这里有一个重要提醒:比赛 ID 不要乱猜,请用真实存在的比赛 ID。你可以先在 OpenDota 官网搜索 VG 或 GL 的战队名,找到对应比赛后再填入。

3.3 搜索战队比赛记录

如果你不知道 VG 和 GL 在 TI15 的具体比赛 ID,可以先通过战队搜索接口进行查询。

OpenDota 的战队信息接口如下:

https://api.opendota.com/api/teams/{team_id}/matches

不过战队 ID 也需要现查。更直接的办法是用“选手比赛记录”接口:

https://api.opendota.com/api/players/{account_id}/recentMatches

这个接口会返回某位选手最近的比赛记录,里面包含 match_id。你可以先找到 XM 的账号 ID,然后拉取他的近期比赛,过滤出当天对阵 GL 的那一场。

import requests def get_recent_matches(account_id: int, limit: int = 5): url = f"https://api.opendota.com/api/players/{account_id}/recentMatches" params = {"limit": limit} resp = requests.get(url, params=params, timeout=30) resp.raise_for_status() return resp.json() if __name__ == "__main__": # 示例账号 ID,请替换成 XM 的实际数字 ID account_id = 123456789 matches = get_recent_matches(account_id) for m in matches[:3]: print(m["match_id"], m["hero_id"], m.get("kills"), m.get("deaths"), m.get("assists"))

这里要注意:如果接口返回 429 状态码,说明请求频率超过了限制。这时需要增加 sleep 间隔或使用 API Key。

4. 比赛关键数据提取与处理

4.1 选手数据解构

拿到比赛 JSON 后,第一步是把 players 数组里的数据提取成结构化表格。OpenDota 的每个玩家对象包含几十个字段,我们重点关注以下内容:

  • account_id:玩家 ID
  • hero_id:使用的英雄 ID
  • kills / deaths / assists:KDA
  • gold_per_min:每分钟金钱
  • xp_per_min:每分钟经验
  • hero_damage:英雄伤害
  • tower_damage:建筑伤害
  • last_hits:补刀数
  • denies:反补数
  • item_0 到 item_5:装备槽位

如果我们要分析 XM 的滚滚表现,可以这样处理:

import pandas as pd def extract_players(match_data): players = [] for player in match_data.get("players", []): row = { "account_id": player.get("account_id"), "hero_id": player.get("hero_id"), "kills": player.get("kills"), "deaths": player.get("deaths"), "assists": player.get("assists"), "gold_per_min": player.get("gold_per_min"), "xp_per_min": player.get("xp_per_min"), "hero_damage": player.get("hero_damage"), "tower_damage": player.get("tower_damage"), "last_hits": player.get("last_hits"), "denies": player.get("denies"), } rows.append(row) return pd.DataFrame(rows) df_players = extract_players(match_data) print(df_players)

处理后你会看到一个 10 行左右的表格,每个英雄一行,后面的分析就可以基于这个结构进行。

4.2 经济经验曲线提取

经济曲线是复盘 BO3 最直观的数据维度。OpenDota 的比赛详情中有一个字段叫“radiant_gold_adv”,它记录的是“天辉经济领先值”。如果这个值大于 0,说明天辉领先;小于 0,则夜魇领先。

我们可以用这个字段画出全场比赛的经济差走势。

import matplotlib.pyplot as plt def plot_gold_advantage(match_data): gold_adv = match_data.get("radiant_gold_adv", []) xp_adv = match_data.get("radiant_xp_adv", []) minutes = [i / 60 for i in range(len(gold_adv))] plt.figure(figsize=(12, 5)) plt.subplot(1, 2, 1) plt.plot(minutes, gold_adv, label="经济领先", color="#d62728") plt.axhline(0, color="gray", linestyle="--", linewidth=0.8) plt.xlabel("分钟") plt.ylabel("天辉 - 夜魇 经济差") plt.title("团队经济走势") plt.grid(True, alpha=0.3) plt.subplot(1, 2, 2) plt.plot(minutes, xp_adv, label="经验领先", color="#1f77b4") plt.axhline(0, color="gray", linestyle="--", linewidth=0.8) plt.xlabel("分钟") plt.ylabel("天辉 - 夜魇 经验差") plt.title("团队经验走势") plt.grid(True, alpha=0.3) plt.tight_layout() plt.savefig("output/economy_curve.png", dpi=160) plt.show()

这段代码会自动生成两张子图。如果 VG 是在后期完成翻盘,你会在经济曲线上看到一个明显的 V 形反转,那是判断节奏拐点的核心依据。

4.3 事件时间轴提取

另一个有价值的数据字段是 objectives,它记录了推塔、打肉山等关键事件。我们可以把这些事件投影到时间轴上,标注出团队节奏变化。

def extract_objectives(match_data): objectives = [] for obj in match_data.get("objectives", []): objectives.append({ "time": obj.get("time"), "type": obj.get("type"), "key": obj.get("key"), "team": obj.get("team") # 0 或 1,对应天辉/夜魇 }) return pd.DataFrame(objectives) df_obj = extract_objectives(match_data) print(df_obj.head(20))

这里要注意,objectives 并不包含击杀事件,只包含建筑和 Roshan 事件。如果你想看击杀时间点,需要解析每个玩家的 kill 事件列表,这部分数据相对复杂,后面会介绍一个更简单的方法。

5. 可视化呈现:把数据变成“剪辑版”

5.1 击杀时间线图

比赛里的击杀事件可以通过遍历玩家数据中的“kills_log”字段来获取。每个玩家的 kills_log 都记录了击杀时间、被杀目标、是否助攻等信息。把这些数据聚合起来,就能得到全场比赛的击杀时间轴。

from collections import defaultdict def build_kill_timeline(match_data): kill_events = defaultdict(list) for player in match_data.get("players", []): is_radiant = player.get("player_slot") < 128 for kill in player.get("kills_log", []): minute = int(kill["time"] // 60) kill_events[minute].append({ "team": "天辉" if is_radiant else "夜魇", "killer": player.get("name") or player.get("account_id"), "target": kill.get("target"), "time": kill["time"] }) return kill_events timeline = build_kill_timeline(match_data) for minute in sorted(timeline.keys()): print(f"第{minute}分钟: {len(timeline[minute])}个击杀")

这里有一个关键细节:player_slot 小于 128 表示天辉,大于等于 128 表示夜魇。这个判断标准是 OpenDota 的固定规则,不要搞反。

5.2 绘制团队击杀对比柱状图

我们可以把击杀时间轴画成按分钟分组的柱状图,直观对比天辉和夜魇的节奏。

def plot_kill_comparison(timeline, duration_minutes): radiant_kills = [0] * (duration_minutes + 1) dire_kills = [0] * (duration_minutes + 1) for minute, events in timeline.items(): for ev in events: if ev["team"] == "天辉": radiant_kills[minute] += 1 else: dire_kills[minute] += 1 minutes = list(range(duration_minutes + 1)) plt.figure(figsize=(14, 5)) plt.bar(minutes, radiant_kills, label="天辉击杀", color="#2ca02c", alpha=0.7) plt.bar(minutes, dire_kills, label="夜魇击杀", color="#d62728", alpha=0.7, bottom=radiant_kills) plt.xlabel("分钟") plt.ylabel("击杀数") plt.title("按分钟击杀分布") plt.legend() plt.grid(True, axis="y", alpha=0.3) plt.savefig("output/kill_timeline.png", dpi=160) plt.show()

这种方式虽然简单,但在复盘时非常实用。比如 XM 的滚滚在 12 分钟、16 分钟、22 分钟各带了一波节奏,你会在柱状图上看到对应分钟区间内天辉击杀柱突然变高。

5.3 选手游走路径还原(基础版)

要真正做“游走路线”的可视化,需要用到比赛内的 position 数据,也就是玩家在地图上的坐标。OpenDota 接口不直接提供完整的逐秒坐标,但有一部分字段可以通过match_data["players"][i]["times"]["positions"]拿到。

这些字段不一定在每次接口调用中都返回,如果缺失,我们可以用团战数据来近似还原 XM 的主要活动区域。

def extract_tf_positions(match_data, account_id): positions = [] for tf in match_data.get("teamfights", []): for player in tf.get("players", []): if player.get("account_id") == account_id: positions.append({ "time": tf.get("start"), "x": player.get("pos_x"), "y": player.get("pos_y"), "deaths": player.get("deaths"), "kills": player.get("kills") }) return pd.DataFrame(positions)

拿到这些位置后,就可以在 DOTA2 地图底图上叠加散点,分析 XM 在关键团战发生时的站位。

如果你有更强的算法基础,还可以进一步做热力图,分析他的高频活动区域,但作为一篇入门实战教程,先做到散点标注已经足够。

6. 完整实战:VG vs GL 比赛分析脚本

6.1 准备比赛 ID

假设你已经从 OpenDota 上找到了 VG 对阵 GL 的那场小组赛 match_id。下面我们写一个综合脚本,把前面几个功能整合成一条命令执行。

完整的代码结构如下:

import requests import pandas as pd import matplotlib.pyplot as plt from collections import defaultdict # ---------- 数据获取 ---------- def fetch_match(match_id): url = f"https://api.opendota.com/api/matches/{match_id}" resp = requests.get(url, timeout=30) resp.raise_for_status() return resp.json() # ---------- 玩家数据提取 ---------- def extract_players(match_data): players = [] for p in match_data.get("players", []): players.append({ "玩家ID": p.get("account_id"), "英雄ID": p.get("hero_id"), "击杀": p.get("kills"), "死亡": p.get("deaths"), "助攻": p.get("assists"), "GPM": p.get("gold_per_min"), "XPM": p.get("xp_per_min"), "英雄伤害": p.get("hero_damage"), "建筑伤害": p.get("tower_damage"), "补刀": p.get("last_hits"), "反补": p.get("denies"), }) return pd.DataFrame(players) # ---------- 经济曲线 ---------- def plot_economy(match_data): gold_adv = match_data.get("radiant_gold_adv", []) xp_adv = match_data.get("radiant_xp_adv", []) minute = [i / 60 for i in range(len(gold_adv))] fig, axes = plt.subplots(2, 1, figsize=(12, 8)) axes[0].plot(minute, gold_adv, color="#d62728") axes[0].axhline(0, color="gray", linestyle="--", linewidth=0.8) axes[0].set_title("经济领先走势") axes[0].set_ylabel("经济差") axes[0].grid(True, alpha=0.3) axes[1].plot(minute, xp_adv, color="#1f77b4") axes[1].axhline(0, color="gray", linestyle="--", linewidth=0.8) axes[1].set_title("经验领先走势") axes[1].set_ylabel("经验差") axes[1].set_xlabel("比赛分钟") axes[1].grid(True, alpha=0.3) plt.tight_layout() plt.savefig("output/economy_curve.png", dpi=160) print("经济曲线图已保存到 output/economy_curve.png") # ---------- 击杀时间轴 ---------- def build_timeline(match_data): timeline = defaultdict(list) for p in match_data.get("players", []): team = "天辉" if p.get("player_slot") < 128 else "夜魇" for kill in p.get("kills_log", []): minute = int(kill["time"] // 60) timeline[minute].append({"team": team, "time": kill["time"]}) return timeline # ---------- 主流程 ---------- if __name__ == "__main__": MATCH_ID = 1234567890 # 替换成真实比赛 ID match_data = fetch_match(MATCH_ID) df_players = extract_players(match_data) print("=== 选手数据 ===") print(df_players) plot_economy(match_data) timeline = build_timeline(match_data) print("=== 按分钟击杀统计(前10分钟) ===") for minute in sorted(timeline.keys())[:10]: print(f"第{minute}分钟: {len(timeline[minute])}个击杀")

6.2 运行验证

在命令行运行:

python main.py

如果网络正常,你会看到按玩家列出的 KDA、GPM、XPM 表格,同时在 output 目录下生成经济曲线图。整体运行时间取决于接口响应速度和比赛数据量,一般 10 秒到半分钟不等。

6.3 预期输出说明

虽然每场比赛的数据不一样,但结构是固定的。以 VG 让一追二那场为例,如果第二局是翻盘局,你会看到经济差曲线从 20 分钟开始持续走低,30 分钟后突然拉升,最终在 40 分钟附近由负转正。这说明 VG 在中后期通过几波关键团战打了回来。

如果你同时对比 XM 的 kills_log,发现他在经济曲线拐点前后出现了多次击杀参与,那就基本可以判断:滚滚在中期带动了团队节奏,是翻盘的重要支点。

7. 常见问题与排查思路

7.1 接口请求报 403 或 429

这是 OpenDota 最典型的限流问题。403 通常是因为没有带 User-Agent 或者触发了防护策略,429 则明确表示请求过于频繁。

解决思路是降低请求频率,或者申请 API Key。建议在代码里加上简单的重试机制。

import time def fetch_match_with_retry(match_id, retries=3): for i in range(retries): try: return fetch_match(match_id) except requests.exceptions.HTTPError as e: if e.response.status_code == 429: wait_time = 5 * (i + 1) print(f"触发限流,等待 {wait_time}s 后重试") time.sleep(wait_time) else: raise

7.2 比赛数据中没有 players 字段

通常是因为比赛类型问题。OpenDota 对部分非排位或 bot 比赛的数据覆盖不完整。建议优先选择完整的职业比赛,因为职业比赛的详情解析度最高。

7.3 坐标数据为空

正如前面所说,position 数据不一定每次都能拿到。如果坐标为空,不要硬画路线图,可以退化为团队经济和击杀时间轴分析,效果同样有说服力。

7.4 比赛 ID 无效

比赛 ID 是 DOTA2 客户端的展示 ID,注意不要从网页源码中直接复制拼接特殊字符。建议通过 OpenDota 的搜索接口确认后再使用。

问题现象常见原因解决思路
403 Forbidden缺少 User-Agent 或被防火墙拦截添加请求头,降低请求频率
429 Too Many Requests无 API Key,请求频率过高申请 API Key 或增加 sleep
数据缺少 positions该比赛未保存完整坐标改用经济差 + 击杀时间轴
比赛 ID 找不到ID 输入错误或比赛类型不支持用选手 recentMatches 接口验证
绘图中文乱码Matplotlib 默认字体不支持中文配置中文字体,例如 SimHei

8. 工程化与最佳实践

8.1 把比赛 ID 做成参数

不要每次改代码里的 match_id。建议用命令行参数或配置文件来管理。

python main.py --match-id 1234567890 --output ./output

这样做的好处是,你可以把同一个脚本复用到所有比赛分析中,不必保存多个副本。

8.2 数据缓存

OpenDota 的接口虽然免费,但请求量过大会影响可用性。建议把每次请求到的原始 JSON 保存到本地,后续分析直接读取本地文件。

import json import os def save_raw_data(match_data, path="data/match_data.json"): os.makedirs("data", exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(match_data, f, ensure_ascii=False)

后续如果代码调试出错,只需读取文件,不需要再次请求接口。

8.3 代码分层

把“数据获取”“数据处理”“可视化”拆成独立模块,是工程化分析脚本的关键。这样即使将来换用 STRATZ API,也只需要替换数据获取层,不需要重写可视化逻辑。

8.4 注意合理使用与版权边界

分析公开比赛数据用于学习交流没有问题,但如果要发布商业化报告,请注意数据平台的条款要求。同时,不要在程序里加入任何自动获取账号权限、绕过限制的逻辑。

9. 总结与下一步拓展

本文从 TI15 小组赛 VG 让一追二 GL 的比赛切入,完整展示了基于 OpenDota API 的 DOTA2 比赛数据可视化分析流程。包括比赛数据获取、KDA 与 GPM 等指标提取、团队经济差走势绘制、按分钟击杀时间轴可视化等内容。

你可以把这套思路扩展到任意一场比赛,不一定是 TI 小组赛,也可以是路人局的录像复盘。只要拿到 match_id,脚本就能直接复跑。

接下来可以考虑几个方向:

  • 把多场比赛数据整合到 SQLite,建立战队长期数据库;
  • 引入 STRATZ GraphQL 接口,获取更精细的插眼、游走、补刀差异数据;
  • 使用 Plotly 生成交互式 HTML 报告,方便在浏览器里点选查看每一分钟的事件;
  • 加上选手历史英雄池分析,观察对手在特定英雄上的胜率变化。

建议你先拿 VG 对阵 GL 的比赛跑通一次,再找另一场 BO3 验证脚本的通用性。数据分析和看比赛一样,多复盘几场,才能发现真正有意思的规律。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/6 3:53:08

国产MCU替代STM32实战:5个隐藏坑与排查方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 3:52:55

AI电子手办实战:多模态大模型端侧部署与交互系统搭建

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 3:52:11

LDO的概述与原理

在电子电路设计中&#xff0c;线性稳压电路扮演着至关重要的角色&#xff0c;它能够为电路提供稳定可靠的直流电压&#xff0c;确保各种电子元件在适宜的电压条件下正常工作。其中&#xff0c;三端稳压器以其简单易用、性能稳定的特点&#xff0c;成为了线性稳压电路中的佼佼者…

作者头像 李华
网站建设 2026/9/6 3:49:22

机器人主控板选型五大痛点与RK3588、RK3576、RK3568方案深度解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 3:47:51

有了Docker为什么还要学K8s?一文讲透容器编排核心价值

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 3:47:26

AI模型部署测试指南:从环境配置到性能验证全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华