
简介面向 Python 爬虫初学者及需要快速验证 B 站视频下载流程的开发者这份压缩包提供一个围绕哔哩哔哩视频抓取的小型示例脚本使用前按视频链接修改 video_bvid 即可匹配目标内容适合练习请求参数定位、链接解析与文件保存等基础环节。包内共 1 个文件主体为 1 个 py 脚本zip 压缩后约 2KB体量轻便脚本结构简洁便于直接阅读、运行和按需改写。已有 3299 人学习下载具备一定的入门参考热度。读者可据此获得一个可落地的下载样例并在此基础上扩展批量任务、异常重试、下载进度提示或结果校验等思路用作爬虫小项目练手、课程演示与二次开发都较为合适。1. 从一条链接到本地 MP4这套流程到底卡在哪收藏夹里攒了几十条链接想批量搬到本地做素材库第一反应基本都是写个 python 爬虫 把视频下载下来。真动手就发现不对劲播放页源码里翻不到 mp4抓到的地址过两小时就打不开下完的文件拖进播放器只有画面没有声音。这套脚本解决的就是这条链路上的三处断裂——取流地址怎么拿、音视频怎么合、批量跑怎么不被中途掐断。它适合手里有一批链接要归档的从业者也适合想把现代视频站播放链路弄明白的人。分P、清晰度、DASH 分离这几个词自己走一遍比看十篇科普都管用。下面的内容按“先摸接口、再落盘、最后避坑”的顺序推每段都能直接抄成代码。2. 拆播放链路三个接口和一份 DASH 清单2.1 为什么直接抓播放页源码注定空手而归早些年做视频抓取思路是 requests 拿播放页 HTML正则匹配.flv或.mp4一把梭。现在这条路彻底堵死了播放页返回的 HTML 里只有一个初始状态 JSON真正的播放地址由前端脚本在页面加载完之后再发两到三次 XHR 才拿到而且这些请求带着签名参数和时间戳。你把 HTML 存下来慢慢找找到的要么是占位符要么是过期地址。正确的路径是把自己当成播放器先请求信息接口拿元数据再请求播放接口拿流地址最后拿着流地址去 CDN 拉数据。三个环节互相独立任何一个出错都会表现为“下不了”所以第一步不是写下载器是把这三段拆开单独验证。我一般会先用浏览器开发者工具的网络面板勾上 XHR 过滤点一次播放把请求按顺序抄下来哪个接口带视频号、哪个接口带清晰度参数、哪个接口带签名。抄完再用 curl 复现一遍确认去掉 Cookie、去掉 Referer 之后哪个先挂——这一步决定了后面请求头的最小集合能省掉大量试错。2.2 信息接口一次请求拿到标题、分P 与清晰度清单信息接口是整个流程的入口它返回的pages数组决定了一个视频要下几次。多P 视频不是一个大文件是 N 条独立的流每一条有自己的 cid。很多人第一次批量下载翻车就是因为只下了第一 P剩下的全丢了。import requests BASE https://站点域名 # 换成实际要抓的站点全文同此占位 HEADERS { User-Agent: (Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36), Referer: BASE /, # 防盗链校验的源头缺了大概率 403 Accept: application/json, text/plain, */*, } def fetch_meta(vid: str) - dict: vid 是视频号的字符串形态参数名按目标站点实际字段替换 url f{BASE}/api/video/info r requests.get(url, params{vid: vid}, headersHEADERS, timeout10) r.raise_for_status() payload r.json() if payload.get(code) ! 0: # 业务错误码不走 HTTP 状态必须单独判 raise RuntimeError(fmeta failed: {payload.get(code)} {payload.get(message)}) d payload[data] return { title: d[title], duration: d[duration], pages: [{cid: p[cid], index: p[page], part: p[part]} for p in d[pages]], }这段代码里三个地方值得盯。Referer必须指向站点自己的页面很多 CDN 会校验来源带了才给数据timeout10是单次请求超时不是总时长批量跑的时候一定要设否则一条死链能把整个队列挂住最关键是payload.get(code) ! 0这一行——这类站点的业务错误是用 JSON 里的 code 字段表达的HTTP 状态照样是 200你不判就等着后面拿空数据。返回结构里pages是下载任务的拆分依据cid是每一 P 的唯一标识后面请求播放地址时必须带它。有些站点的信息接口还支持一次传多个视频号做批量查询但这属于灰色地带我一般不用——批量并发请求更容易触发频率限制得不偿失。2.3 播放接口DASH 音视频分离与地址时效拿到 cid 之后请求播放接口返回的是一份 DASH 清单视频流和音频流是两个独立数组。这一点是新手上手时最容易被绊倒的地方你以为拿到的是一个 mp4 地址实际拿到的是两个 m4s一个只有画面一个只有声音。def fetch_streams(vid: str, cid: int, cookie: str ) - dict: url f{BASE}/api/video/playurl params { vid: vid, cid: cid, fnval: 4048, # 流格式位掩码开启 DASH 才会返回分离的音视频 fourk: 1, # 允许返回 4K 流实际能否拿到看账号权限 } headers dict(HEADERS) if cookie: headers[Cookie] cookie r requests.get(url, paramsparams, headersheaders, timeout10) r.raise_for_status() d r.json()[data] dash d.get(dash) or {} return { video: sorted(dash.get(video, []), keylambda x: x[id], reverseTrue), audio: sorted(dash.get(audio, []), keylambda x: x[bandwidth], reverseTrue), accept: d.get(accept_quality, []), # 服务端认可的可选清晰度 }fnval这个位掩码是整段代码的开关不传或者传错返回的要么是老的整段 flv要么直接报错。fourk1只是“允许”能不能真拿到取决于账号权限和视频本身的上传规格。accept_quality这个字段常被忽略它告诉你服务端到底愿意给哪些清晰度比你自己猜编号可靠得多。清单里的每条流都有id、bandwidth、baseUrl、backupUrl四个关键字段。最常见的清晰度编号体系大致如下不同站点会略有出入以accept_quality为准流编号常见含义典型码率备注16360P300 kbps 左右无登录态也能拿32480P500 kbps 左右多数接口的默认下限64720P1.2 Mbps 左右匿名访问的常见上限801080P2.5 Mbps 左右一般需要登录态1121080P 高码率4 Mbps 左右需要更高权限1204K8 Mbps 左右登录态 专门参数选流策略我一般这么定视频按id降序取第一个不超过账号权限上限的音频按bandwidth降序取第一个。音频别贪大有些高码率音轨是杜比或无损封装ffmpeg 合并时容易出兼容问题选常规 AAC 更稳。baseUrl和backupUrl都带签名参数有效期通常在一到两小时。这意味着你不能先批量拿几百条地址再慢慢下必须“拿一条、下一条”。真遇到大批量任务正确做法是把“取地址”和“下载”做成流水线每下完一个再取下一个地址而不是先攒一批。这个顺序错了后半程全是 403排查起来能耗掉一晚上。3. 落地下载请求头三件套、分段续传与 ffmpeg 合并3.1 请求头三件套UA、Referer 与 Cookie 的最小集合CDN 拉流这一步请求头和 API 请求头不是一个量级。API 那边少一个字段可能只是返回空数据CDN 这边少一个字段直接 403而且响应体经常是 XML 格式的错误页你不看响应内容会以为是网络问题。三个必需项里UA 负责绕过最基础的黑名单默认的python-requests/2.x是明牌Referer 负责防盗链值必须是站点自己的页面地址Cookie 决定你能拿到什么清晰度以及能不能连续拉几百兆不被掐。我一般把这三项写成一个独立的 header 构造函数API 和 CDN 各用一份别混用——API 的 Cookie 里有会话信息CDN 只需要其中几个键全带过去反而可能触发风控。CDN_HEADERS { User-Agent: HEADERS[User-Agent], Referer: BASE /, Origin: BASE, Accept: */*, Accept-Encoding: identity, # 关键关掉压缩否则拿不到真实字节数 } def cdn_headers(cookie: str) - dict: h dict(CDN_HEADERS) h[Cookie] cookie return hAccept-Encoding: identity这一行值得单独说。默认情况下 requests 会声明支持 gzip某些 CDN 会顺手把响应压缩返回的Content-Length是压缩后的大小你按这个值去校验文件完整性永远对不上最后表现为“下载成功但文件损坏”。关掉压缩之后字节流才是原样校验才有意义。3.2 分段下载与断点续传Range 头加 .part 临时文件一个 1080P 十分钟的视频音视频加起来两三百兆很常见。单次 requests 请求拉完中间任何一次网络抖动都前功尽弃。标准做法是分块加断点续传先写到.part临时文件全部完成后再原子重命名中途失败下次从已下载的字节数继续。import os import requests CHUNK 1024 * 512 # 512KB太小握手次数多太大单次超时难恢复 def download(url: str, path: str, headers: dict, retries: int 3) - str: part path .part total 0 for attempt in range(retries): done os.path.getsize(part) if os.path.exists(part) else 0 h dict(headers) if done: h[Range] fbytes{done}- # 续传的核心告诉服务端从哪开始 try: with requests.get(url, headersh, streamTrue, timeout(10, 60)) as r: if done and r.status_code 200: done 0 # 服务端不认 Range只能从头来 os.remove(part) r.raise_for_status() total int(r.headers.get(Content-Length, 0)) done mode ab if done else wb with open(part, mode) as f: for chunk in r.iter_content(CHUNK): if chunk: f.write(chunk) if total and os.path.getsize(part) ! total: raise IOError(fsize mismatch {os.path.getsize(part)}/{total}) os.replace(part, path) # 原子操作不会留下半截正式文件 return path except Exception as e: print(f[retry {attempt 1}/{retries}] {os.path.basename(path)}: {e}) raise RuntimeError(fgive up: {path})timeout(10, 60)是连接超时和读取超时的元组读取超时设 60 秒是因为大文件传输中间可能停顿设太短会误杀正常连接。r.status_code 200那个分支处理的是最坑的一种情况你带了 Range 头但服务端不理你直接从头返回整个文件。这时候如果不做判断会把新数据追加到旧数据后面得到一个大小翻倍、内容错乱的怪物文件。大小校验那行也不能省。Content-Length加上已经下载的字节数才是完整大小两者不符说明中途断了但没抛异常——这种情况在长连接上很常见不校验就会把残缺文件当成功品交付。os.replace在同一个文件系统内是原子操作批量跑的时候即使进程被强杀目录里也只会多出.part不会出现半截的正式文件。3.3 音视频合并ffmpeg 参数与失败回退两个流下完之后要合成一个 mp4。这里不需要重新编码-c copy直接做容器封装几百兆的文件一两秒就完事。重新编码慢十倍不止而且画质会掉除非目标容器不支持原编码格式否则没必要。import shutil import subprocess def merge(video: str, audio: str, out: str) - str: if not shutil.which(ffmpeg): raise EnvironmentError(ffmpeg 不在 PATH 里先装再跑) cmd [ ffmpeg, -y, # 覆盖同名输出批量跑时省掉交互 -i, video, -i, audio, -c, copy, # 不重编码直接封装 -movflags, faststart, # moov 前置播放器拖动进度条不卡 out, ] p subprocess.run(cmd, capture_outputTrue, textTrue) if p.returncode ! 0: raise RuntimeError(p.stderr[-800:]) # 错误信息都在 stderr 尾部 return out-y必须有否则批量跑到第二个同名文件时会卡在交互确认上看起来像“卡死”。faststart的影响在播放体验上不加的话 moov 原子在文件末尾网页播放器要下完整个文件才能开始播本地拖进度条也会有延迟。p.stderr[-800:]只截尾部是关键细节。ffmpeg 的报错信息在最后几行前面全是版本号和流信息全打印出来会把真正的错误淹掉。真合并失败时九成是编码格式和容器不匹配——比如音轨是某种特殊封装-c copy塞不进 mp4这时候把音频参数改成-c:a aac -b:a 192k单独转一次就行视频轨保持 copy 不动。顺序上有个小坑合并前先确认两个文件的时长差不超过一秒。差得太多说明其中一个流下残了这时候强行合并出来的成品会音画不同步而且很难看出是哪一步出的问题。我一般在下完之后立刻用 ffprobe 读一下时长对不上就重下别等到合并完再回头找。4. 常见问题排查五条踩坑记录与对应改法4.1 现象接口返回 200body 却是空的或一段奇怪的 HTML这是最常见的第一道坎。HTTP 状态正常r.json()直接抛解析异常或者解析出来是个不含data字段的空壳。原因基本只有两个请求头缺了 Referer被防盗链拦在了前置网关或者请求频率触发了风控服务端不返回错误码而是直接给你一个验证页面。先验证第一种。把headers里的 Referer 换成站点首页再发一次如果通了就是防盗链。如果还是不通把 UA 换成浏览器 UA 再试。两个都补齐还不行那就是风控——表现通常是前几次正常第 N 次开始全空。风控的解法不是换 IP 硬顶是把节奏降下来。我一般的参数是单线程、每个请求之间加 1 到 2 秒的随机 sleep、连续失败三次就停十分钟再续。用固定间隔的 sleep 反而更容易被识别加个random.uniform(1.2, 2.8)效果明显。另外遇到风控之后原来的 Cookie 大概率已经废了继续用只会一直失败得换一份。4.2 现象下载到 99% 卡住或文件大小比预期小一截进度条走到最后不动了或者脚本报成功但文件打不开。原因有两个一是iter_content在连接被中途断开时不一定会抛异常循环正常结束你以为下完了二是前面提到的Content-Length被压缩影响校验基准本身就是错的。排查顺序是这样的先看.part文件的实际大小再和服务端返回的Content-Length加已下载字节数对比。对不上就是断流。这时候.part文件是有效的重新跑一次会自动走 Range 续传不用从头开始。如果反复在同一个位置断多半是单次连接时长被服务端限制了。把CHUNK调小一档或者把timeout的读取值从 60 降到 30让 requests 更早感知到停滞并重连。还有一种隐蔽情况CDN 节点本身有问题这时候换backupUrl里的备用地址成功率立刻上去。4.3 现象合并后的文件没声音或者音画不同步没声音基本是两个原因音频流压根没下或者下错了对象。dash.audio是个数组直接取[0]有风险因为排序不保证取到的可能是杜比音轨-c copy封装进 mp4 之后部分播放器解不出来。正确做法是按bandwidth排序后取常规码率那一条。音画不同步则是时长不一致导致的。用 ffprobe 分别读两个流ffprobe -v error -show_entries formatduration -of csvp0 video.m4s ffprobe -v error -show_entries formatduration -of csvp0 audio.m4s两个值差超过一秒就是下载环节出了问题重下缺的那一个。差在几十毫秒内属于正常封装差异合并后播放器会自动对齐不用管。还有一种情况是合并成功但文件在某些播放器里卡顿。这通常是faststart没加moov 在文件尾播放器边下边解的时候反复 seek。重新封一次加上这个参数就好不用重下。4.4 现象批量跑到几十个之后所有任务全部失败前二十个跑得好好的第二十一个开始后面全是 403 或者空响应。这是典型的频率触发。很多人第一反应是加并发“抢在封之前下完”结果死得更快——并发上去之后单位时间请求数翻倍封禁来得更早。正确的处理是反过来把并发降到 1请求间隔拉长并且加一个失败退避机制。我的做法是维护一个失败计数单条任务连续失败三次就跳过并记进failed.txt整批跑完之后再单独重试这一批。重试的时候间隔翻倍还不行就换 Cookie。另一个容易忽略的点是 Cookie 的寿命。有些站点登录态的有效期和请求频率挂钩高频请求会提前让会话失效。批量任务开始前先单独测一次高清晰度请求确认能拿到 1080P跑到一半再测一次如果掉到 720P 以下就说明会话在退化该停下来歇一会了。4.5 现象清晰度永远只有 360P怎么改参数都没用参数改了一圈还是低清说明问题不在参数上。三个检查点按顺序排Cookie 有没有带、fnval有没有开 DASH、accept_quality里到底有没有高清编号。最常见的是 Cookie 没带或者带了但不完整。很多站点的清晰度权限是靠 Cookie 里的几个特定键判断的只带SESSDATA这类会话键不够还得有设备标识。最省事的办法是从浏览器里把整条 Cookie 复制出来直接用跑通之后再逐个删减找出最小集合。第二个检查点是accept_quality。这个字段是服务端告诉你“我愿意给什么”如果里面最高只有 64那你传 80 也没用服务端会静默降级。这时候要么换账号要么确认这个视频本身有没有上传高清源——老视频很多原始规格就是 480P参数再对也变不出 1080P。第三个是fourk这类权限位。有些站点的高清流需要额外的参数组合才返回缺一个就降级到默认档。这类参数没有文档只能从浏览器实抓的请求里对比出来把差异参数逐个加回去试。5. 批量调度收口一张校验表和两个压箱底技巧批量下载最容易出的问题不是“下不下来”是“下下来但不知道对不对”。几十个文件躺在目录里大小参差你不可能一个个点开看。我现在跑完一批一定先过一遍校验表检查项命令或方法通过标准文件大小ls -l对比流清单里的预期值偏差小于 1%时长一致ffprobe -show_entries formatduration与信息接口返回的 duration 差小于 1 秒流数量ffprobe -show_entries streamcodec_type恰好一视频一音频编码格式-show_entries streamcodec_name视频 h264/hevc音频 aac可播性ffmpeg -v error -i out.mp4 -f null -无输出即无错误最后一条是我的私藏技巧。-f null -表示解码到空输出不解码成文件整个过程只读不写速度很快。如果文件有任何损坏或封装错误ffmpeg 会把错误打到 stderr一跑便知。这一条比看大小、看时长都准因为它真的把每一帧都解了一遍。for f in *.mp4; do err$(ffmpeg -v error -i $f -f null - 21) [ -n $err ] echo BROKEN: $f $err done另一个技巧和命名有关。批量下载最乱的是重名和分P我的命名规则是标题前 40 字_视频号_P序号.mp4特殊字符统一替换成下划线标题过长直接截断而不是交给文件系统处理。这样做的直接好处是排序即分组分P 自然挨在一起后面找起来不用翻目录。说个我自己的教训。早期写这类脚本我总想着先把所有地址取完再统一开下觉得这样“效率高”。结果一次两百个的任务跑到后半段全挂排查了三个小时才发现是地址过期——baseUrl的签名有效期就一两个小时前面取的地址早废了。从那以后我每次写批量逻辑都强制走一遍“取一条、下一条、校验一条”的流水线地址在内存里存活的窗口不超过几分钟。慢是慢了点但半夜不用起来重跑。希望帮到你。本文还有配套的精品资源点击获取