news 2026/10/14 1:11:37

B站视频Python爬虫下载实战:DASH流解析、Cookie鉴权与ffmpeg合并

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
B站视频Python爬虫下载实战:DASH流解析、Cookie鉴权与ffmpeg合并

简介:面向需要批量获取B站视频的Python学习者与开发者,这是一份可直接运行的爬虫脚本资源。通过修改video_bvid变量并传入视频链接中的BV号,即可自动解析并下载对应视频,免去手动寻找接口的麻烦。资源包内仅含1个py脚本,体积约2KB,结构精简,适合作为爬虫入门练习或快速取用工具。脚本核心逻辑包括视频信息解析、请求头构造与下载流程控制,代码量小易于阅读修改,可在此基础上扩展多P视频、音视频分离等进阶功能。当前已有3295人学习/下载,适合有一定Python基础、希望了解B站视频抓取原理的读者。下载解压后即可查看源码,按需修改video_bvid参数运行,能够快速获得下载结果,节省逐一搜寻教程与调试验证的时间。资源虽小而精,能帮助用户掌握B站视频爬虫的基本实现思路。

1. 先别急着写爬虫:B站视频python爬虫下载的真实难度在哪

很多人第一次把“B站视频python爬虫下载”这几个字搜进搜索引擎时,脑子里想的是:一个 Python 脚本拿到 MP4 直链保存到本地,完事。真动手半小时后会开始骂人——页面源码里搜不到任何.mp4,接口返回的是一堆音频视频分离的.m4s,请求不带Referer直接 403,不登录顶多给你 480P。B站的视频下载不是“找到直链一把梭”,而是一套 ID 体系、接口鉴权和音视频分离编码的组合流程。这篇文章按一线最常用的方案拆开讲:先确认视频 ID 与接口结构,再写一个能复用的下载器,最后把登录态、防盗链、风控、断点续传这些坑挨个说清楚。适合已经会用requests、想自己维护一个稳定下载脚本的人,而不是只想跑别人打包好的现成工具。

2. 摸清B站视频的 ID 体系与接口结构:下载前必须理解的三层关系

B站视频下载和普通图片爬虫最大的区别,是它有一套“视频页 → 接口 → 流地址”的分层关系。你在浏览器里看到的 URL 是一个 BV 号,但这个号不能直接换到下载链接;真正决定下载内容的是另一个叫 cid 的参数。如果跳过这一层直接搜 MP4,你连门都入不了。

2.1 bvid、avid 与 cid:一个视频页背后藏着三个 ID,分别用来做什么

打开任意一个B站视频页,地址栏里是一串以BV开头的字符,这就是 bvid,全站对外的主键。早些年用的是纯数字的 avid,现在两种 ID 在接口里同时存在,但页面推广的是 bvid。

真正影响下载流程的是第三个 ID:cid。cid 对应的是“分 P”级别。一个视频页可能只有一个视频,也可能有多个分 P,比如合集、课程、多集番剧,每一 P 都有独立的 cid。播放器请求视频流的时候,接口认的是 bvid + cid 的组合,而不是光凭一个 BV 号。

获取 cid 的公开接口是 view 接口,返回的数据里同时包含标题、分 P 列表、封面和第一 P 的 cid。分 P 列表在data.pages数组里,每个元素都有cid和part字段。一个常见的误区是只拿顶层data.cid,那只是第一 P 的 cid;你要下载第二 P,就必须去pages数组里按序号取对应cid。

实际写代码时,我会把 view 接口返回的整个对象缓存下来,同时保留标题和分 P 列表。这样后续无论是做单 P 下载还是批量下载,都只需要一次请求,不用反复拉取视频信息。

2.2 playurl 接口与 DASH 格式:为什么返回的不是 MP4 而是一堆带 baseUrl 的 JSON

拿到 cid 之后,下一步是请求 playurl 接口换取真实流地址。这个接口的返回结构对新手来说很容易懵:它不是给你一个 MP4 直链,而是返回一个 JSON,里面包含data.dash.video和data.dash.audio两个数组。

原因是B站现在的 PC 播放策略采用了 DASH 分轨方案:视频轨和音频轨分开编码、分开存储。视频轨里可能有多种编码,常见的是 avc(H.264)、hevc(H.265)和 av1;音频轨一般是 AAC。播放器在网页端通过 JavaScript 把它们合成播放,我们下载脚本要做的则是把两个流分别下回来,再用 ffmpeg 合并成一个 MP4。

请求 playurl 时有两个关键参数。fnval=4048表示返回 DASH 格式而不是老的 flv 直链;qn表示期望清晰度,比如 80 对应 1080P、64 对应 720P。注意是“期望”,实际给什么取决于登录态和你账号的权限。返回的data.accept_description会明确列出当前请求实际可用的清晰度列表,dash.video里的每个元素则包含id(对应清晰度数值)、codecs(编码格式)和baseUrl(带鉴权参数的流地址)。

baseUrl是最终要下载的地址,但它有时效性,通常在一到两小时内过期;过期后重新请求一次 playurl 即可。

2.3 先跑通一次最小探测:用 requests 拿 cid 并请求真实流地址

在写完整下载器之前,我建议先做一个 20 行以内的最小探测,验证你的 Cookie、请求头、ID 链路是否已经打通。下面这段代码只做两件事:拿 cid,拿流地址,不写文件。

import requests session = requests.Session() headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", "Referer": "https://www.bilibili.com/", "Cookie": "你的浏览器Cookie,推荐包含SESSDATA字段" } bvid = "BV1AbCdEfGHi" # 替换成你要下载的视频BV号 # 1. 通过view接口拿cid和分P列表 view_resp = session.get( "https://api.bilibili.com/x/web-interface/view", params={"bvid": bvid}, headers=headers, timeout=10 ).json() if view_resp["code"] != 0: raise RuntimeError(f"view接口失败: {view_resp['message']}") data = view_resp["data"] cid = data["cid"] print(f"视频标题: {data['title']}, 共 {data['videos']} 个分P") # 2. 通过playurl接口拿DASH流地址 play_resp = session.get( "https://api.bilibili.com/x/player/playurl", params={ "bvid": bvid, "cid": cid, "fnval": 4048, "qn": 80 }, headers=headers, timeout=10 ).json() if play_resp["code"] != 0: raise RuntimeError(f"playurl接口失败: {play_resp['message']}") play_data = play_resp["data"] print(f"当前可用清晰度: {play_data['accept_description']}") video_streams = play_data["dash"]["video"] audio_streams = play_data["dash"]["audio"] print(f"视频轨数量: {len(video_streams)}, 音频轨数量: {len(audio_streams)}") print(f"清晰度最高的视频流地址: {video_streams[0]['baseUrl']}")

代码逻辑很简单:先请求 view 接口拿到 cid 和视频元信息,再带 cid 请求 playurl 拿到 DASH 流列表。这里我把headers里的Cookie直接写在了请求头中,这是因为B站 web 接口多数需要登录态才能返回高清晰度。

参数说明:qn=80是期望清晰度,返回时可用的清晰度以accept_description为准;fnval=4048确保返回 DASH 结构。video_streams[0]只是偷懒取了第一个元素,实际选流要考虑编码优先级,后面第 3 章会专门处理。

如果这段代码跑到 playurl 那一步返回code=-403,先不要怀疑代码写错,把浏览器里完整的 Cookie 复制过来再试一次。这一步通过,说明 ID 链路已经通了,可以进入下一步的完整下载器。

3. 写一个可复用的B站视频下载器:从单视频到参数化脚本

最小探测跑通之后,把它扩展成一个完整的下载器就是体力活了。重点有三个:会话与 Cookie 的初始化、清晰度选流逻辑、音视频合并。我会把脚本拆成三个部分讲,每部分都对应一个可以独立理解和修改的函数。

3.1 脚本骨架:为什么用带 Cookie 的会话而不是每次裸请求

我习惯把下载器封装成一个类,用requests.Session()保持连接复用。这样 view、playurl、下载流地址都走同一个会话,请求头只需要设置一次。另一个原因是 Cookie 统一挂在 headers 里,后续如果发现某个请求需要单独调整 Header,改一处即可。

import argparse import re import shutil import subprocess import time from pathlib import Path import requests class BiliDownloader: def __init__(self, cookie=None): self.session = requests.Session() self.headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", "Referer": "https://www.bilibili.com/", } if cookie: self.headers["Cookie"] = cookie

参数说明:cookie建议从浏览器开发者工具里复制完整 Cookie,而不是只拼一个SESSDATA=xxx。B站有些接口还会校验其他字段,比如buvid3,缺了可能触发风控。Referer必须保留,很多流地址下载时会校验来源,去掉它直接 403。

3.2 解析视频信息与清晰度选流:view、playurl 与 accept_description 的配合

下载器内部设计两个方法:get_detail负责 view 接口,get_stream负责 playurl 接口。选流时不直接取第一个视频轨,而是按清晰度 id 降序、编码兼容性优先的方式排序。我个人偏向优先选 avc 编码,因为它的兼容性最好,hevc 和 av1 在部分播放器里会出现花屏或无法硬解的问题。

def get_detail(self, bvid): url = "https://api.bilibili.com/x/web-interface/view" resp = self.session.get(url, params={"bvid": bvid}, headers=self.headers, timeout=10) data = resp.json() if data["code"] != 0: raise RuntimeError(f"view接口失败: {data['message']}") return data["data"] def get_stream(self, bvid, cid, qn=127): url = "https://api.bilibili.com/x/player/playurl" params = {"bvid": bvid, "cid": cid, "fnval": 4048, "qn": qn} resp = self.session.get(url, params=params, headers=self.headers, timeout=10) data = resp.json() if data["code"] != 0: raise RuntimeError( f"playurl接口失败: code={data['code']} msg={data['message']}\n" f"常见原因: 未带登录Cookie、Cookie过期、或触发了风控" ) return data["data"] @staticmethod def pick_video_stream(video_list): # 清晰度 id 越大画质越高;同清晰度下优先 avc 编码 def score(stream): codec = stream.get("codecs", "") return (stream["id"], 0 if codec.startswith("avc") else 1) return max(video_list, key=score)

选流逻辑说明:score函数返回一个元组,第一个值是清晰度 id,第二个值是编码优先级。max会比较元组,先比清晰度,再比编码。这样即使同一个清晰度下同时存在 avc、hevc、av1 三种码流,也能稳定选到兼容性最好的 avc。

3.3 流式下载与 ffmpeg 合并:把分离的 m4s 合成可播放文件

下载流地址时用stream=True按块写入,避免一次性把整个视频载入内存。合并前检查 ffmpeg 是否存在,合并后删除中间的 m4s 文件。文件名要先做非法字符清洗,Windows 下如果标题里有\/:*?"<>|,保存时会直接报错。

def download(self, url, path, retry=3): path = Path(path) for i in range(retry): try: resp = self.session.get(url, headers=self.headers, timeout=(5, 30), stream=True) resp.raise_for_status() with open(path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024 * 1024): f.write(chunk) return except Exception as exc: print(f"下载失败,第 {i + 1} 次重试: {exc}") if i < retry - 1: time.sleep(2 * (i + 1)) raise RuntimeError(f"下载失败: {path}") @staticmethod def sanitize_filename(name): return re.sub(r'[\\/:*?"<>|]', "_", name) def merge(self, video_path, audio_path, output_path): if shutil.which("ffmpeg") is None: raise RuntimeError("未找到ffmpeg,请先安装并加入PATH") cmd = ["ffmpeg", "-y", "-i", str(video_path), "-i", str(audio_path), "-c", "copy", str(output_path)] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: print(result.stderr) raise RuntimeError("ffmpeg合并失败,可加上 -loglevel debug 排查") video_path.unlink(missing_ok=True) audio_path.unlink(missing_ok=True)

下载方法的两个细节:timeout=(5, 30)里第一个值是连接超时,第二个是读超时;读超时设成 30 秒是为了避免下载大文件时连接空闲过久被服务端掐断。retry=3是简单重试,真正追求稳定可以配合第 4 章讲到的 Range 续传。

合并方法里-c copy是流复制,不重新编码,速度快且不损失画质。前提是视频流和音频流本身都是可直拷的格式,B站的 m4s 直接拷没问题。如果出现音画不同步,优先检查两个文件是不是来自同一次 playurl 响应。

主流程把前面几个方法串起来:先拿标题和分 P,再取第一个分 P 的 cid,请求流地址,分别下载,最后合并。

def run(self, bvid, qn=127, outdir="."): detail = self.get_detail(bvid) title = self.sanitize_filename(detail["title"]) pages = detail["pages"] print(f"《{title}》共 {detail['videos']} 个分P") page = pages[0] cid = page["cid"] stream = self.get_stream(bvid, cid, qn) print(f"可用清晰度: {stream['accept_description']}") dash = stream.get("dash") if not dash: raise RuntimeError("该视频没有返回DASH流,可能是不支持分轨的旧格式") video_stream = self.pick_video_stream(dash["video"]) audio_stream = max(dash["audio"], key=lambda x: x["id"]) print(f"视频轨: id={video_stream['id']} codecs={video_stream.get('codecs')}") print(f"音频轨: id={audio_stream['id']} codecs={audio_stream.get('codecs')}") outdir = Path(outdir) outdir.mkdir(parents=True, exist_ok=True) video_path = outdir / f"{title}_video.m4s" audio_path = outdir / f"{title}_audio.m4s" output_path = outdir / f"{title}.mp4" self.download(video_stream["baseUrl"], video_path) self.download(audio_stream["baseUrl"], audio_path) self.merge(video_path, audio_path, output_path) print(f"完成: {output_path}")

最后加一个命令行入口,把 BV 号、Cookie、清晰度、输出目录都变成参数。这样脚本就不只是自己玩的小工具,别人拿到也能直接用。

if __name__ == "__main__": parser = argparse.ArgumentParser(description="B站视频下载器") parser.add_argument("bvid", help="视频BV号,例如 BV1AbCdEfGHi") parser.add_argument("--cookie", default="", help="浏览器Cookie") parser.add_argument("--qn", type=int, default=127, help="期望清晰度: 127=8K, 120=4K, 116=1080P60, 80=1080P, 64=720P") parser.add_argument("--outdir", default=".", help="输出目录") args = parser.parse_args() downloader = BiliDownloader(cookie=args.cookie) downloader.run(args.bvid, qn=args.qn, outdir=args.outdir)

到这里,一个能用的下载器已经成型了。但真实环境不是照着这段代码就能一路顺畅跑完的,第 4 章才是真正的血泪经验区。

4. B站视频爬虫下载的避坑笔记:登录态、防盗链与合并失败

这一章记录的是我自己在维护下载脚本过程中反复踩过的五类问题,每一条都按“现象 → 原因 → 解决”的结构写。这些坑不会同时出现,但只要你把脚本给别人用,早晚会遇到其中一两个。

4.1 下载到一半连接断开:超时、重试与 Range 续传

现象:下载到 30% 左右连接被断开,requests抛ConnectionResetError或ReadTimeoutError,并且每次断的位置不一样,看起来完全是玄学。

原因:B站流地址服务器对长时间空闲连接不太友好,本地网络稍有抖动就会断;另一个常见原因是代码里没设timeout,断网后客户端不会主动报错,而是无限等下去,表现就是“卡死”。

解决:下载必须用stream=True分块写文件,同时设置连接超时和读超时。更稳的方案是支持断点续传:本地已有文件时,用Range头从断点继续请求。

def download_with_resume(session, url, path, headers): headers = headers.copy() path = Path(path) if path.exists() and path.stat().st_size > 0: headers["Range"] = f"bytes={path.stat().st_size}-" with session.get(url, headers=headers, stream=True, timeout=(5, 30)) as resp: if resp.status_code == 206: mode = "ab" # 服务端支持Range,从断点追加 elif resp.status_code == 200: mode = "wb" # 服务端忽略Range,只好从头覆盖 else: resp.raise_for_status() return with open(path, mode) as f: for chunk in resp.iter_content(chunk_size=1024 * 1024): f.write(chunk)

逻辑说明:先看本地文件大小,让服务端从断点开始返回。206表示服务端认可了 Range,继续追加写;200表示服务端不支持或不理会 Range,那就整文件重写,避免把新旧数据混在一起。这个函数可以替换第 3 章download方法里的核心下载逻辑。

4.2 没登录只能下到 480P:Cookie 与清晰度权限的关系

现象:qn 参数传了 120(4K),但accept_description返回的是[“480P 清晰度”, “720P 清晰度”],dash.video 里最高 id 只有 64,怎么调 qn 都没用。

原因:B站清晰度策略不是“你请求多少就给多少”,而是服务端根据账号状态分配。网页上你能看 1080P,是因为浏览器带着登录 Cookie;脚本里不带,就是个游客身份,自然往低清给。

解决:从浏览器开发者工具里复制完整 Cookie 传给脚本。判断 Cookie 是否生效,看accept_description的返回就行。下面这张表是常见的 qn 值,建议直接写进脚本的--help说明里:

qn 值对应清晰度通常需要的条件
1278K大会员 + 登录态
126杜比视界大会员 + 登录态
1204K大会员 + 登录态
1161080P60大会员 + 登录态
1121080P 高码率大会员 + 登录态
801080P登录态即可
64720P游客可用
32480P游客可用

注意表格里“通常需要的条件”是我长期使用下来的经验值,个别视频因为投稿时就没传高清源,即使到了权限也拿不到更高清晰度。判断标准始终以accept_description为准。

4.3 带上 Referer 仍然返回 403:UA 与 Cookie 不匹配

现象:view 接口正常,playurl 接口正常,偏偏下载baseUrl那一下返回 403 Forbidden,浏览器里打开同一个地址却能播放。

原因:流地址里的鉴权参数不只校验Referer,还和你获取地址时的User-Agent绑定。脚本里如果下载时用了默认的python-requestsUA,或者换了一个 UA,和之前申请流地址时的 UA 对不上,就被判定为盗链。

解决:全程使用同一个会话和同一组请求头,尤其是 UA 不能变。不要把下载逻辑写成独立的requests.get(url),那样很容易漏掉 Referer 和 Cookie。我把这个教训直接固化在代码结构里:所有请求都通过self.session.get方法发出,统一携带self.headers。

4.4 playurl 返回 code=-403:WBI 签名与风控提示怎么判断

现象:playurl 接口返回code=-403或code=-412,返回的消息里有“请先登录”“请求被拦截”等关键词,但同一套 Cookie 在浏览器里点开视频完全正常。

原因:B站的 web 接口不是纯裸参数就能随便调的,playurl 这类接口有额外的签名校验和风控逻辑。常见的触发条件是:Cookie 过期、UA 与浏览器不一致、请求频率过高。签名校验是另一道门槛,它要求请求参数里带上特定的签名结果,少一个参数接口就直接拒。

解决:先看返回消息区分是登录问题还是风控问题。提示“请先登录”就换 Cookie;提示“请求被拦截”就先降低请求频率,等几分钟再试,同时确认 UA 与 Cookie 来源一致。如果这两个都排除了,说明确实需要实现签名算法。我一般的做法不是自己硬写签名,而是把浏览器开发者工具里真正发出的请求复制成脚本可用的形式,观察它比脚本多了哪些请求头或参数,再补齐。直接裸调接口是很容易翻车的,这不是代码问题,是接口规则问题。

4.5 ffmpeg 合并后音画不同步:选流时的两个低级错误

现象:合并后文件能播放,但声音和画面对不上,或者画面卡顿明显;另一种情况是输出文件时长比网页面板显示的短一截。

原因:八成是选流时出了问题。第一种错误是视频流从一次 playurl 响应里取,音频流又从另一次响应里取,两次请求拿到的流地址权限可能一致,但码率和时间基可能不同,合并时就错位。第二种错误是视频轨选到了 av1 或 hevc,播放器兼容性差,表现成不同步或卡顿。还有一种是 m4s 文件没下载完整就去合并了,输出文件自然短。

解决:第一,视频轨和音频轨必须来自同一次data.dash响应。第二,视频轨优先选 avc 编码,这也是我在第 3 章写pick_video_stream时把编码优先级放进排序逻辑的原因。第三,合并前校验文件大小,下载期间记录Content-Length和实际写入字节数,不一致就不要合并,直接重新下载那段文件。

提示:如果你用第 4.1 节的断点续传,续传后也要校验最终文件大小和服务端声明是否一致。Range 续传最怕服务端返回了 200 而不自知,新旧数据混写导致文件损坏。

5. 进阶:批量分P、弹幕与字幕抓取,把下载器变成完整归档工具

单视频下载跑通之后,下一步通常是把整套流程扩展到批量场景。最常见的需求是把一个多 P 视频全部下载下来,顺便把弹幕和字幕也存一份,做一个完整的本地归档。

5.1 批量下载分P:把 run 从单 P 改成遍历 pages

第 3 章的run方法只取了pages[0],改成批量只需要在外层加一个循环,让每个分 P 走一遍“拿 cid → 请求流地址 → 下载 → 合并”的流程。我一般会加一个p_start和p_end参数,只下载指定区间的分 P,避免每次都要全量重下。

def run_pages(self, bvid, start=1, end=None, qn=127, outdir="."): detail = self.get_detail(bvid) pages = detail["pages"] if end is None: end = len(pages) for idx in range(start - 1, min(end, len(pages))): page = pages[idx] print(f"正在处理 P{idx + 1}: {page['part']}") self.run_single(bvid, page["cid"], page["part"], qn=qn, outdir=outdir)

注意run_single需要把第 3 章run里那套流程拆出来,让标题和 cid 作为参数传入,而不是重新走一遍get_detail。这样批量下载时 view 接口只需请求一次。

5.2 弹幕与字幕:从 cid 开始的衍生下载

弹幕是最容易抓的,B站提供了一个固定接口:把 cid 拼进 URL 就能拿到 XML 格式的全部弹幕。字幕稍微麻烦一点,需要先请求player/v2接口拿到字幕列表,再逐个下载。一次把这三样都存下来,本地看片体验和在线几乎没差别。

我自己维护下载脚本时有个习惯:每次改完选流逻辑,先用一个已知能下载的短视频从头到尾跑一遍,确认输出文件能正常播放,再批量跑。批量跑的时候不要开太多并发,B站对同一账号的高频请求很敏感,翻车了只能等风控解除。希望这篇笔记能帮你把B站视频python爬虫下载这条路上最隐蔽的几个坑提前趟平,少走几步弯路。

本文还有配套的精品资源,点击获取

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

易语言多线程大漠插件脚本:延时控制与线程管理实战

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

作者头像 李华
网站建设 2026/10/14 1:11:09

CSS边框内圆角实现方案:伪元素遮罩与clip-path精控

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

作者头像 李华
网站建设 2026/10/14 1:10:34

DMA初始化避坑指南:从寄存器配置到工程实践的完整拆解

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

作者头像 李华
网站建设 2026/10/14 1:10:11

迭代器模式深度解析:从集合框架源码到并发修改的坑

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

作者头像 李华
网站建设 2026/10/14 1:10:10

Arm技术文档全集合:从Cortex到AMBA的底层开发速查指南

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

作者头像 李华