视频下载失败?403、404、签名过期、超时——我踩过的每一个坑和排查思路
搞视频下载的人,有一半时间其实不是在写代码,而是在看报错。你信心满满地把链接丢进下载器,结果蹦出来一个 HTTP 403,或者进度条卡在 99% 不动,再或者下载完了发现文件只有 3KB 打开是乱码。每个错误码背后都有一套因果链,摸透了,排查就是几分钟的事。这篇文章是我在生产环境里被各种下载失败折磨出来的经验总结,把常见的失败场景按现象归类,给出直接的排查步骤和修复方案。
TL;DR:403 通常是鉴权问题(缺 Cookie/Referer/UA),404 分两种——真的资源不存在和 URL 签名过期,超时有网络、CDN、限速三种原因,文件损坏多半是 M3U8 分片缺失或合并顺序错乱。排查黄金法则:先用 curl 复现 → 对比浏览器请求头 → 逐个删除请求头找出最小必需集合 → 在代码中复现。
目录
- 一、403 Forbidden:最头疼但也最套路
- 二、404 Not Found:资源真的不存在还是签名过期
- 三、超时与连接中断:网络问题还是被限流了
- 四、下载完成但文件打不开:校验与合并的暗坑
- 五、M3U8 下载专项问题
- 六、排查工具与调试技巧
- 七、防御性下载:把错误扼杀在摇篮里
- 八、合规与温馨提示
一、403 Forbidden:最头疼但也最套路
403 是视频下载里最常见的拦路虎。服务器收到了你的请求,也理解了你要什么,但它决定"不给"。这跟 401(未认证,需要登录)不一样——403 的意思是"我知道你是谁,但你没权限"。
1.1 快速定位:对比法
# 第一步:用 curl 模拟浏览器请求
curl -v "https://cdn.example.com/video/12345.mp4" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
-H "Referer: https://www.example.com/video/12345" \
-H "Cookie: session_id=xxx" \
-o /dev/null
如果 curl 能下载,说明参数没问题,是代码实现的问题。如果 curl 也 403,逐个删参数:
# 删 Cookie → 还 403?
curl ... -H "Cookie: ..." # 去掉这行
# 删 Referer → 还 403?
curl ... -H "Referer: ..." # 去掉这行
# 删 UA → 还 403?
curl ... -H "User-Agent: ..." # 去掉这行
删到哪个参数后变成其他错误码(比如 302 跳转登录),就说明那个参数是必需的。
1.2 常见 403 原因及修复
| 现象 | 可能原因 | 排查步骤 | 修复方案 |
|---|---|---|---|
| 浏览器能播,curl 403 | 缺 Cookie | 从浏览器 DevTools 复制完整 Cookie 试试 | 导出 cookies.txt 注入请求 |
| 带 Cookie 还 403 | Cookie 过期或域名不对 | 检查 Cookie 的 Domain 和 Expires | 重新登录获取 |
| 有时 403 有时 200 | IP 被间歇性限流 | 换个 IP 试试,检查请求频率 | 降低并发数,加请求间隔 |
| 加了 Referer 后 200 | CDN 防盗链 | 确认 Referer 值是否与播放页一致 | 设置正确的 Referer |
| 加什么都不行 | WAF/风控拦截 | 检查响应体中是否有验证码页面 | 需要模拟浏览器或更换出口 IP |
1.3 一个真实案例
某天突然所有 B 站视频下载返回 403。排查过程:
1. curl 测试 → 403
2. 加上 Cookie → 还是 403
3. 加上 Referer: https://www.bilibili.com → 200!
结论:B 站 CDN 开启了 Referer 校验,以前不需要,某次更新后加上了。
修复:在下载器里全局添加 Referer 头,问题解决。
这类"昨天还能用今天就不行了"的情况,十有八九是平台偷偷改了防盗链策略。写个监控脚本定期跑几个测试链接,能第一时间发现。
1.4 代码里的防御
import requests
def download_with_auth_fallback(url, page_url, cookies=None):
"""逐级加强鉴权的下载函数"""
session = requests.Session()
# 级别 0:裸请求(先试试)
resp = session.get(url, stream=True, timeout=10)
if resp.status_code == 200:
return resp
# 级别 1:加 UA
session.headers['User-Agent'] = (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36'
)
resp = session.get(url, stream=True)
if resp.status_code == 200:
return resp
# 级别 2:加 Referer
session.headers['Referer'] = page_url
resp = session.get(url, stream=True)
if resp.status_code == 200:
return resp
# 级别 3:注入 Cookie
if cookies:
for name, value in cookies.items():
session.cookies.set(name, value)
resp = session.get(url, stream=True)
if resp.status_code == 200:
return resp
# 全部失败,报告详细信息
raise PermissionError(
f"下载失败(HTTP {resp.status_code})。\n"
f"已尝试:裸请求 → +UA → +Referer → +Cookie\n"
f"URL: {url}\n"
f"响应头: {dict(resp.headers)}"
)
二、404 Not Found:资源真的不存在还是签名过期
2.1 两种 404 的区别
类型 A:资源真的不存在
→ 视频被删除/下架/私有化
→ 任何时候请求都 404
→ 特征:响应体通常很短,可能是 nginx 默认 404 页面
类型 B:URL 签名过期
→ 视频还在,但 URL 里的 token/expires 参数过期了
→ 刷新页面拿到新 URL 后就能下载
→ 特征:URL 里通常有 ?expire=xxx&sign=xxx 这样的参数
2.2 签名过期的典型 URL
https://cdn.example.com/video/12345.mp4?
expire=1735689600& ← 过期时间戳,已经过了
token=abc123& ← 临时访问凭证
sign=md5hash ← 签名,校验 token+expire 的合法性
YouTube、B 站、腾讯视频等几乎所有大平台的视频直链都有时效性。一般有效期从几分钟到几小时不等。所以拿到 URL 后要尽快下载,不要缓存起来等半天再用。
2.3 处理策略
import time
from urllib.parse import urlparse, parse_qs
def check_url_expiry(url):
"""检查 URL 中的过期时间"""
parsed = urlparse(url)
params = parse_qs(parsed.query)
expire = params.get('expire', params.get('expires', [None]))[0]
if expire:
expire_time = int(expire)
remaining = expire_time - int(time.time())
if remaining < 0:
print(f"警告:URL 已过期 {abs(remaining)} 秒")
return False
elif remaining < 300: # 不足 5 分钟
print(f"警告:URL 将在 {remaining} 秒后过期,请尽快下载")
else:
print(f"URL 有效期剩余:{remaining} 秒")
return True
def download_with_retry_on_expiry(parser_func, page_url):
"""签名过期时自动重新解析"""
for attempt in range(3):
video_url = parser_func(page_url) # 重新调用解析器获取新 URL
if check_url_expiry(video_url):
return download(video_url)
print(f"URL 过期,重新解析(第 {attempt + 1} 次)")
time.sleep(1)
raise RuntimeError("多次重新解析后 URL 仍然过期,可能是解析器问题")
三、超时与连接中断:网络问题还是被限流了
3.1 超时的三种典型场景
场景一:连接超时(Connection Timeout)
TCP 三次握手没完成 → 服务器不可达或防火墙拦截
排查:ping 目标 IP,检查是否通
场景二:读取超时(Read Timeout)
TCP 连接建立成功,但服务器迟迟不返回数据
排查:检查是否被限速、服务器是否过载
场景三:传输中断(Connection Reset / Broken Pipe)
下载到一半连接断了
排查:CDN 是否有传输大小限制、是否触发了反爬
3.2 超时配置的最佳实践
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def create_resilient_session():
"""创建一个有弹性重试机制的 session"""
session = requests.Session()
# 连接池配置
adapter = HTTPAdapter(
pool_connections=10,
pool_maxsize=20,
max_retries=Retry(
total=5, # 总共重试 5 次
backoff_factor=0.5, # 退避因子:0.5s → 1s → 2s → 4s → 8s
status_forcelist=[500, 502, 503, 504], # 这些状态码才重试
allowed_methods=['GET'], # 只重试 GET 请求
)
)
session.mount('https://', adapter)
session.mount('http://', adapter)
# 超时设置:连接超时 15s,读取超时 60s
session.timeout = (15, 60)
return session
3.3 被限流的特征与应对
限流的表现不是直接 403,而是更隐蔽的:
- 下载速度从 5MB/s 慢慢降到 500KB/s
- 下载到某个百分比(比如 30%)就断开
- 前几个分片很快,后面的越来越慢
- 某些分片返回空内容但状态码是 200
应对策略:
import asyncio
import random
class AdaptiveDownloader:
"""自适应下载器:检测到限流自动降速"""
def __init__(self, initial_concurrency=20, min_concurrency=2):
self.concurrency = initial_concurrency
self.min_concurrency = min_concurrency
self.consecutive_failures = 0
def on_success(self):
"""成功后逐步恢复并发数"""
self.consecutive_failures = 0
self.concurrency = min(self.concurrency + 1, 20)
def on_failure(self):
"""失败后降低并发数"""
self.consecutive_failures += 1
if self.consecutive_failures >= 3:
self.concurrency = max(self.concurrency // 2, self.min_concurrency)
self.consecutive_failures = 0
print(f"检测到连续失败,降低并发至 {self.concurrency}")
# 随机等待一段时间再继续
wait = random.uniform(5, 15)
print(f"等待 {wait:.0f} 秒后继续...")
return wait
return 1
四、下载完成但文件打不开:校验与合并的暗坑
4.1 症状分类
| 症状 | 可能原因 | 验证方法 |
|---|---|---|
| 文件只有几 KB | 下载到的是错误页面(HTML)而不是视频 | file 命令或文本编辑器打开看看 |
| 能播放但卡顿/花屏 | 部分 TS 分片下载失败但被跳过了 | 检查分片完整性 |
| 播放器提示"格式不支持" | 合并时编码参数不匹配 | ffprobe 检查编码信息 |
| 音频视频不同步 | 合并时分片顺序错乱 | 对比 M3U8 中的分片列表 |
| 只有画面没声音(或反过来) | DASH 模式下只下载了一个流 | 检查是否同时下载了视频流和音频流 |
4.2 下载后自动校验
import hashlib
import subprocess
import os
def validate_downloaded_video(filepath):
"""下载完成后校验文件"""
checks = []
# 1. 检查文件大小
size = os.path.getsize(filepath)
checks.append(('文件大小', size > 1024, f'{size} bytes'))
# 2. 用 ffprobe 检查是否有效的视频文件
try:
result = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries',
'stream=codec_type', '-of', 'csv=p=0', filepath],
capture_output=True, text=True, timeout=30
)
streams = result.stdout.strip().split('\n')
has_video = 'video' in streams
has_audio = 'audio' in streams
checks.append(('视频流', has_video, '有' if has_video else '无'))
checks.append(('音频流', has_audio, '有' if has_audio else '无'))
except Exception as e:
checks.append(('ffprobe 检查', False, str(e)))
# 3. 检查是不是 HTML 页面(被重定向到错误页)
with open(filepath, 'rb') as f:
header = f.read(100)
if header.startswith(b'<!DOCTYPE') or header.startswith(b'<html'):
checks.append(('内容类型', False, '文件是 HTML 页面,不是视频'))
else:
checks.append(('内容类型', True, '二进制文件'))
# 汇总结果
passed = all(c[1] for c in checks)
if not passed:
failed = [c for c in checks if not c[1]]
print(f"校验失败!{filepath}")
for name, _, detail in failed:
print(f" - {name}: {detail}")
return passed
4.3 分片完整性校验
M3U8 下载最容易出的问题:500 个分片,下了 498 个,少了 2 个,合并出来的视频在某些时间点会卡住或跳过。
def verify_m3u8_segments(segments, download_dir):
"""检查所有分片是否下载完整"""
missing = []
corrupted = []
for seg in segments:
filename = os.path.join(download_dir, f"seg_{seg['seq']}.ts")
if not os.path.exists(filename):
missing.append(seg['seq'])
continue
# 检查分片大小是否合理(不应为 0 或过小)
size = os.path.getsize(filename)
if size == 0:
corrupted.append((seg['seq'], '空文件'))
elif size < 1000: # 小于 1KB,很可能是错误页面
corrupted.append((seg['seq'], f'异常大小: {size} bytes'))
if missing:
print(f"缺失分片: {missing}")
if corrupted:
print(f"异常分片: {corrupted}")
return len(missing) == 0 and len(corrupted) == 0
五、M3U8 下载专项问题
5.1 Master Playlist 选择了错误的码率
有些视频的 Master M3U8 里,最高码率的子 M3U8 可能指向了音频轨道而不是视频轨道。下载器如果只看 BANDWIDTH 而不检查 CODECS,就会选错。
def filter_video_streams(variants):
"""过滤出真正的视频流(排除纯音频)"""
video_streams = []
for v in variants:
codecs = v.get('CODECS', '')
if not codecs:
continue
# 包含视频编码(avc1/hevc/vp9/av01)的才是视频流
if any(c in codecs for c in ['avc1', 'hvc1', 'hev1', 'vp9', 'vp09', 'av01', 'avc3']):
video_streams.append(v)
return video_streams
5.2 分片下载不完整
最隐蔽的坑:服务器返回了 200 状态码,但 Content-Length 和实际收到的字节数不一致。这种情况需要显式校验:
def download_segment_with_verification(session, url, expected_size=None):
"""下载分片并校验完整性"""
resp = session.get(url)
data = resp.content
actual_size = len(data)
if expected_size and actual_size != expected_size:
print(f"分片大小不匹配:期望 {expected_size},实际 {actual_size}")
return None # 触发重试
# 进一步:检查 TS 分片的同步字节(0x47)
if actual_size > 0 and data[0] != 0x47:
print("警告:分片不是有效的 TS 格式(同步字节错误)")
# 可能是加密分片,需要解密后再检查
return data
六、排查工具与调试技巧
6.1 一行命令对比浏览器和脚本的差异
# 在浏览器 DevTools → Network → 右键请求 → Copy as cURL
# 把 curl 命令粘贴到终端,确认能下载
# 然后在你的 Python 代码里打印等效的 curl 命令
def debug_as_curl(url, headers, cookies):
"""把请求转成 curl 命令,方便对比调试"""
parts = [f"curl -v '{url}'"]
for k, v in headers.items():
parts.append(f"-H '{k}: {v}'")
for name, value in cookies.items():
parts.append(f"--cookie '{name}={value}'")
print('\n'.join(parts))
6.2 抓包对比
浏览器和下载器发出去的请求到底有什么不同?用 Wireshark 或者 mitmproxy 抓包对比是最直接的办法。
# 启动 mitmproxy
mitmproxy -p 8888
# 让下载器走代理
export HTTPS_PROXY=http://127.0.0.1:8888
python downloader.py
在 mitmproxy 里可以逐字节对比浏览器请求和下载器请求的差异。
6.3 日志分级记录
import logging
# 给下载器配一套分级日志
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
handlers=[
logging.FileHandler('downloader.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger('downloader')
# 使用
logger.info(f"开始下载: {url}")
logger.debug(f"请求头: {headers}")
logger.warning(f"收到 403,尝试加强鉴权")
logger.error(f"下载失败,已重试 {retry_count} 次")
七、防御性下载:把错误扼杀在摇篮里
一个健壮的下载器,应该在下载前就做好这些检查:
class DefensiveDownloader:
"""防御性下载器——下载前先预检"""
def preflight_check(self, url, page_url):
"""下载前的预检清单"""
warnings = []
# 1. 检查 URL 可达性(HEAD 请求)
resp = requests.head(url, timeout=10,
headers={'Referer': page_url})
if resp.status_code != 200:
warnings.append(f"HEAD 请求返回 {resp.status_code}")
# 2. 检查 Content-Type
content_type = resp.headers.get('content-type', '')
if 'html' in content_type.lower():
warnings.append(f"Content-Type 是 {content_type},可能不是视频文件")
# 3. 检查文件大小
content_length = resp.headers.get('content-length')
if content_length and int(content_length) < 1024:
warnings.append(f"文件过小 ({content_length} bytes)")
# 4. 检查是否支持 Range 请求
accept_ranges = resp.headers.get('accept-ranges', '')
if accept_ranges != 'bytes':
warnings.append("不支持断点续传(Accept-Ranges 缺失)")
# 5. 检查 URL 过期时间
check_url_expiry(url)
if warnings:
print("预检警告:")
for w in warnings:
print(f" - {w}")
return False
print("预检通过")
return True
八、合规与温馨提示
- 本文讨论的技术排查手段仅适用于个人学习研究和合法授权的下载场景
- 频繁重试和绕过反爬机制可能违反平台服务条款,请合理控制请求频率
- 下载他人版权内容用于再分发属于侵权行为
- 更多法律边界讨论见 下载视频算侵权吗?聊聊个人备份与版权的那条线
排查下载问题的过程其实就一句话:用 curl 复现、和浏览器对比、逐项排除。掌握了这个思路,大部分问题十分钟内都能定位到根因。
本文由 VidDown 技术博客原创发布。VidDown 是一个多平台视频解析下载工具,支持 B 站、抖音、快手、小红书、YouTube 等 30+ 平台。如果你厌倦了手动排查各种下载报错,可以试试 VidDown——我们把上述所有防御性下载策略都内置在了解析引擎里。