yt-dlp 网络排障速查
> 由真实桌面下载器开发踩坑沉淀整理,按【坑/现象 → 根因 → 解法 → 通用教训】组织。核心表象统一为「解析卡住、几分钟不报错、看不出死活」。
坑 1:写死的「强制直连」策略,环境翻转后静默失效
【症状】界面停在「解析中」,回退链 19 步走满、每步都超时,共烧约 5 分钟。
【根因】代码里写死 'proxy': ''(当年实测代理半死、直连可用,故强制直连)。但环境翻转后:直连目标站超时、走本机代理 1.9s 拿到 200。yt-dlp 第一步就超时 → 回退链每步再超时一次。这不是引擎坏了,是网络路径选错了。 界面上那句「系统自动代理异常」提示只是状态栏文案,与下载选路无关,容易被误当成根因。
【解法】不再写死,改成按目标站点运行时实测选路:
- 候选顺序:上次成功的路径 → 直连 → 各本机代理(env 里的 + 常见端口中真开着的);
- 每条候选发一次真实 HTTPS 请求,拿到任意 HTTP 响应(含 4xx)即算通——这能同时识破「端口通但 TLS 转发坏」的半死代理;
- 结果按 host 缓存 10 分钟;
- 网络类失败(timed out / connection refused / reset / name resolution / eof)识别后清缓存重试一次,仍不通就 break,不把回退链空跑一遍。
【通用教训】「当年实测过所以写死」的策略,会随环境变化静默失效,失效表现是「卡住/超时」这种最不像 bug 的样子。网络路径、镜像源、代理端口这类外部可变条件一律「运行时实测 + 缓存 + 快速止损」。
坑 2:「永远为假」的代理健康探针,把好组件判死并驱动错误策略
【症状】GUI 启动就提示「检测到系统代理异常,你的直连已实测可用」——与事实完全相反(直连不通、代理才通)。
【根因】探针在 CONNECT 建立的 TLS 隧道里发明文 HTTP 请求:
s.sendall(f"CONNECT {target}:443 HTTP/1.1\r\nHost: {target}:443\r\n\r\n".encode())
resp = s.recv(300) # CONNECT 200,隧道已建立(这只是 TLS 隧道)
s.sendall(f"GET / HTTP/1.1\r\nHost: {target}\r\nConnection: close\r\n\r\n".encode())
# ↑ 隧道里必须跑 TLS 握手,发明文必被当 TLS 记录解析
head = data.decode(...)[:40]
return head.startswith("HTTP/1.1 2") or ... # ← 永远拿不到 2xx/3xx
实测健康代理经此探针也 100% 判「半死」→ 探针错了 → 驱动出写死的直连策略 → 坑 1 的「解析好久没结果」。
【解法】隧道内做真正的 TLS 请求(ssl.wrap_socket 或直接用 urllib 走代理),拿到任意 HTTP 响应即算健康;措辞上先确认「直连是否可用」再给建议。
【通用教训】写健康探针先问一句:「这个探针有没有可能返回真?」——任何情况下都返回假的探针不是探针,是把好组件判死的开关。判据要用正面标志(能拿到响应/完成握手),且必须自测一遍「健康时它真的返回真」。
坑 3:常驻 PO Token 服务被父进程代理环境变量带歪
【症状】修完选路后仍「不动」:sample 采主线程栈显示永久阻塞在等一行 HTTP 响应(1550/1550 采样全在 sock_recv_into → poll),进程挂着到本机代理的长连接,.part 文件一个没产生。
【排查三件套】sample 抓主线程栈定位「卡在哪类系统调用」+ 逐行 verbose 日志定位「最后一句是什么」+ lsof -p 看「它在连谁」。一次就抓到真凶:PO Token 服务收到 /get_pot 后 20 秒不给任何响应,且它在反复往一个不是用户配置的代理端口建连。
【根因】两个坑叠加:
1. 拉起常驻服务时没传 env=,完整继承父进程环境;PO Token 服务在请求体没带 proxy 时会回落读自己的 HTTPS_PROXY/HTTP_PROXY/ALL_PROXY 环境变量。服务是常驻的——一旦被「带代理环境变量的父进程」(如 AI 工具沙箱、自动化测试环境)拉起过,错误代理跟着它活一整天,之后所有出站请求全挂死,且下次启动不会自愈(服务还在跑,被复用了)。
2. 健康检查只看 /ping:/ping 只回 uptime/version,不碰网络。被污染的实例照样秒回 200,一直冒充健康。
【解法(可直接复制)】
# 1) 拉起时显式传干净 env
_PROXY_KEYS = ("HTTP_PROXY","HTTPS_PROXY","http_proxy","https_proxy","ALL_PROXY","all_proxy")
env = os.environ.copy()
for k in _PROXY_KEYS: env.pop(k, None)
subprocess.Popen([node, main_js], env=env, start_new_session=True, ...)
# 2) 真·端到端自测:真的 POST 一次 /get_pot 并校验返回里有 poToken
# (附带好处:启动自测预热 BotGuard VM,用户第一次解析少等十几秒)
# 3) 复用旧实例前四条全中才复用:状态文件可信 + PID 存活 + /ping 通 + 自测通过
# 4) 回环探测一律用 ProxyHandler({}) 空代理打开器,防 127.0.0.1 被系统/环境代理截胡
# 5) 失败冷却 300s;启动自测挪到后台线程(实测选路 ~9s,主线程跑会冻住 UI)
【修复中另踩的两个小坑】
- 受限环境里 ps 对进程报 operation not permitted → 判 PID 身份失败。退路:lsof -p PID 里找本项目 node_modules 下的动态库指纹(node 跑 BotGuard 必然加载 canvas),照常可用;
- 冷启动第一次 /get_pot 要现场加载 BotGuard VM,实测可超 12s → 自测超时 12s 会把「刚起来还没热」误判成失败。放宽到 30s 并重试 3 次。
【通用教训】拉起任何常驻后台服务必须显式传 env=;健康检查必须覆盖「它真正要干的活」——本地 /ping、端口在听、进程活着都不等于能干活。永远返回真的探针和永远返回假的探针一样没用。
坑 4:间歇性客户端错误被当成视频本身的结论(误判死)
【症状】3 个视频里 1 个 36 秒后报「视频不可用(可能已删除或需登录)」判死,另 2 个无结果。但手工复现这三个 URL 全部正常解析、能拿 2160p。
【根因】致命错误签名列表含 "video unavailable" 等;回退循环命中致命签名就无条件 break → 第一个客户端失败 = 整条回退链当场放弃,后面 6 个客户端连试都不试。而实测同一 URL、同一秒内两次运行结果不同:前两个客户端被拒、第三个成功——YouTube 会间歇性拒掉一部分客户端请求。
【解法】
1. 回退链重排为「先稳后偏」:稳定客户端在前,降级兜底在后;
2. 给致命判定加门控:记录客户端变体在链中的结束下标 _client_end,还没试完客户端时不 break,只提示「该客户端不可用,自动切换下一个」;客户端全试完仍致命才放弃;
3. ⚠️ 多分支共用一个后定义的局部变量,必须在分支之前先给默认值(否则别的分支命中时 NameError)。
【验证姿势】逻辑层 monkeypatch 构造「第 1 步致命、后面可用」/「全部致命」/「非该平台」三场景;端到端用用户报障的原 URL 原样复跑——换 URL 复现成功不算修好。
【通用教训】「某个下游变体报的错」≠「对象的结论」——多客户端/多端点/多镜像场景必须穷尽变体才有资格下结论,判死类逻辑一律加「还有没有没试过的变体」门控。同一错误在两次采样里结果不同 ⇒ 它是间歇性的,正确动作是提高重试/回退覆盖度,而不是「定位并换掉某个坏组件」。
坑 5:选路返回值语义歧义,退化成最差路径
【症状】探测那一刻代理繁忙(超时 4s 没回)而直连也不通时,函数返回 None → 下游用直连 → 挂死。
【根因】None 既表示「直连」又用作「所有候选路径都没探测通」的兜底返回值——一个返回值承担两种含义,把「最坏情况」伪装成了「正常情况」:
chosen = None # 默认 = 直连
for p in cands:
if _probe_https(host, p):
chosen = p
break
return chosen # ← 探不通时也返回 None,被下游理解成「直连可用」
【解法】
1. 用独立布尔量 found 区分「实测通过」与「全都不通」;全都不通时优先交给代理(可用则成功、不可用则较快失败),绝不退化成会无限挂住的直连;
2. 记住死路:本会话内实测失败的路径记入黑名单不再优先选;
3. 全程心跳:每 30s 推一条「仍在解析/下载中…(已 Ns)」,让用户能分辨「在干活」还是「死了」。
【通用教训】「哨兵值」必须和「真实取值」可区分。 写完一个「选择/探测」函数,问自己:返回值能不能唯一确定调用方该做什么? 不能,就加显式的 found/ok 标志位。
附:测试环境代理变量污染(终端能跑通 ≠ GUI 能跑通)
AI 工具沙箱/自动化环境常注入 HTTP_PROXY/HTTPS_PROXY 指向自己的内部代理(端口确实开着,但对目标站点不通)。后果:代理探测逻辑在测试环境得出与真实 GUI 完全相反的结论。测任何「代理自动探测 / 直连 vs 代理」逻辑前,先清掉这些变量模拟真实 GUI:
for k in ("HTTP_PROXY","HTTPS_PROXY","http_proxy","https_proxy","ALL_PROXY","all_proxy"):
os.environ.pop(k, None)
「终端能跑通 ≠ GUI 能跑通」反复出现的根因,就是两套环境的代理变量不是一套。
坑 7:平台 web 接口签名 403——喂 cookie 也救不了,此路已废
【症状】Failed to download web detail JSON: HTTP Error 403。把浏览器里导出的 cookie 全量喂给 yt-dlp,照 403。
【根因】部分平台(如抖音)的 web 详情接口要求动态签名参数(X-Bogus 等),签名在前端 JS 里实时计算。cookie 只证明「你是谁」,不提供「请求是否经过合法签名」。缺签名 → 403,与登录态无关。
【解法】放弃 yt-dlp 直连该接口,改走浏览器路径:
- 用 Patchright/Playwright 打开真实页面,监听网络响应,捕获播放地址(形如
/aweme/v1/play/?file_id=...,会 302 → CDN;或douyinvod.com域的 206 直取地址)。 - 用浏览器自身的 request context 下载——自动带全部 cookie,无需手工构造 header:
resp = await context.request.get(cdn_url)
data = await resp.body()
- 注意:页面
<video>的 src 是blob:协议,直接取没用;要的是监听到的真实网络地址。
【通用教训】「路走不通」要留硬证据:明确写下「此接口因签名要求不可用,别再试」,比留下「下次换个姿势再试一次」的空间更有价值。否则下次还会烧一轮 cookie 导出 + 参数调试的时间。
坑 8:导出 cookie 的 expires=-1 被 yt-dlp 判无效静默跳过
【症状】明明导出了登录 cookie,yt-dlp 加载后却像没带一样,接口全部按未登录响应。无报错,只是静默不生效。
【根因】浏览器会话 cookie 的 expires 常为 -1(会话级)。写进 Netscape cookie 文件后,yt-dlp 按「已过期」判无效,静默跳过不告警。
【解法】写 Netscape 文件前统一替换成远期时间戳:
def fix_cookie_expiry(netscape_line: str) -> str:
parts = netscape_line.split("\t")
if len(parts) >= 5 and parts[4].strip() in ("-1", "0"):
parts[4] = "2145916800" # 远期时间戳
return "\t".join(parts)
【通用教训】「带了 cookie 但没生效」先查文件里 expires 字段——负值/零会被静默丢弃。凡导入型配置,加载端不报错不等于加载成功。
> 作者:阿光 · 婚恋活动策划 × 摄影摄像 8 年实战。技能定制 / 业务合作:微信 13011137113