yt-dlp 下不了爱奇艺?用 Playwright 拦截 dash API 提取 m3u8 一样能搞定

2026-06-15

最近遇到一个需求:朋友发了一个爱奇艺分享链接,要把视频下载下来存档。

以前这种事 yt-dlp 一行命令搞定,这次折腾了两小时才发现——常用的几条路全堵死了。最后靠 Playwright 浏览器拦截才拿到视频流地址。把整个过程记下来,给遇到同样问题的人省点时间。

先说结论

截至 2026 年 6 月,爱奇艺视频下载的现状:

方案 状态 说明
yt-dlp iqiyi 提取器 ❌ 失效 报 “Can’t find any video”
直接 curl dash API ❌ 失效 返回 “Time expired”,缺浏览器认证参数
mixer API ❌ 失效 404 Not Found
Playwright 拦截 dash 响应 ✅ 可用 让浏览器帮你带认证,拦截响应拿 m3u8

多种方案对比:三条路走不通,只剩 Playwright 拦截一条路

下面按时间顺序讲踩坑过程,着急的可以直接跳到”最终方案”那节。

第一坑:yt-dlp 爱奇艺提取器失效

拿到分享链接后第一反应是 yt-dlp:

yt-dlp -F "https://m.iqiyi.com/mp/sharePlay.html?tvid=8518700492290400&..."
# ERROR: [iqiyi] Can't find any video

从分享链接里提取 tvid,拼标准 URL 也试了:

yt-dlp -F "http://www.iqiyi.com/v_2bql2y1y6fg.html"
# 同样报错

升级 yt-dlp 到最新版(2026.06.09),结果一样。查了一下源码,yt-dlp 的 iqiyi.py 提取器依赖的页面元素和 API 结构已经变了,短期内不太可能修复。

第二坑:curl 直调 dash API

换思路,直接调爱奇艺的播放流 API。先用 baseinfo 接口拿到视频元信息(这个接口是公开的,不需要认证):

curl -s "https://pcw-api.iqiyi.com/video/video/baseinfo/8518700492290400"

返回里有 tvid、vid、播放地址等关键字段。拿到 vid 后试调 dash 接口:

curl -s "https://cache.video.iqiyi.com/dash?tvid=8518700492290400&vid=844482a856d1c5fbec039430383d02dc&..." \
  -H "User-Agent: Mozilla/5.0 ..."

结果:

{"msg": "Time expired", "code": "A00020"}

即使用当前时间戳替换 tm 参数也一样。原因是 dash API 还需要 k_uid(浏览器指纹)、dfp(设备指纹)、authKey(签名)这几个参数,全由爱奇艺播放器在浏览器里动态生成,纯 HTTP 请求伪造不了。

试了 mixer API(mixer.video.iqiyi.com),直接 404。

最终方案:Playwright 拦截

思路很简单——既然 dash API 需要浏览器环境里的认证参数,那就让浏览器来发起请求,我们只负责从浏览器的网络请求里”截获”响应数据。

原理

爱奇艺播放器加载时,会自动发起 cache.video.iqiyi.com/dash 请求。浏览器自带完整的认证参数(k_uid、dfp、authKey),服务器返回的响应里包含完整的 HLS m3u8 playlist——每个 TS 分片的 URL 都带好了签名。

Playwright 的 page.on("response", ...) 可以监听所有网络响应,包括跨域请求。我们要做的就是:加载页面 → 等播放器发请求 → 捕获 dash 响应 → 解析 m3u8。

浏览器拦截 dash API 响应的原理:浏览器带认证发请求,Playwright 截获响应数据

步骤 1:拦截 dash 响应

import asyncio, json
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        dash_responses = []

        async def on_response(response):
            if 'cache.video.iqiyi.com/dash' in response.url:
                try:
                    body = await response.json()
                    dash_responses.append(body)
                except:
                    pass

        page.on("response", on_response)

        # 用短视频页面触发播放器加载
        await page.goto(
            "https://www.iqiyi.com/shortvideo/?tvid=8518700492290400",
            timeout=20000, wait_until="domcontentloaded"
        )
        await asyncio.sleep(8)  # 等 dash 请求完成
        await browser.close()

    for i, resp in enumerate(dash_responses):
        with open(f'/tmp/iqiyi_dash_{i}.json', 'w') as f:
            json.dump(resp, f, ensure_ascii=False, indent=2)

    print(f"Captured {len(dash_responses)} dash responses")

asyncio.run(main())

几个要点:

  • URL 构造https://www.iqiyi.com/shortvideo/?tvid=<TVID>,分享链接会重定向到这个地址
  • headless=True 就行:不需要有头浏览器
  • 等待 8 秒:播放器加载和 dash 请求需要时间,太短会抓不到
  • dash 可能返回两次:第一次可能只有空 vid,第二次才是完整响应。脚本用数组全部捕获,后面选有效那个

步骤 2:解析响应,提取 m3u8

dash 响应的结构:

data
├── ctl.bid: 500 (最高可用画质)
└── program.video[]
    ├── [0] bid=500, 720x1272 竖屏, ~72MB, m3u8="..."
    ├── [1] bid=300, 较低画质, m3u8="" (空)
    └── [2] bid=200, ~17.5MB

选最高画质(bid 最大)的流,提取 m3u8 字段:

with open('/tmp/iqiyi_dash_0.json') as f:
    data = json.load(f)

videos = data['data']['program']['video']

# 取最高画质,跳过 m3u8 为空的流
best = max(
    (v for v in videos if v.get('m3u8')),
    key=lambda v: v.get('bid', 0)
)

print(f"Resolution: {best['scrsz']}")   # 720x1272
print(f"Size: {best['mp4Size'] / 1024 / 1024:.1f} MB")  # 72.5 MB
print(f"Duration: {best['duration']}s")  # 452

with open('/tmp/iqiyi_playlist.m3u8', 'w') as f:
    f.write(best['m3u8'])

m3u8 内容是一个标准 HLS playlist,包含 97 个 TS 分片,每个约 5 秒,分片 URL 带认证参数(qd_tmqd_pqd_kqd_sc)。

dash 响应结构解析:多画质流分支,选最优路径提取 m3u8

步骤 3:用 N_m3u8DL-RE 下载

拿到 m3u8 文件后,用 N_m3u8DL-RE 下载(也可以用 ffmpeg,但 N_m3u8DL-RE 支持多线程,快很多):

N_m3u8DL-RE "/tmp/iqiyi_playlist.m3u8" \
  --save-dir ./downloads \
  --save-name "iqiyi_video" \
  --thread-count 8 \
  -H "Referer: https://www.iqiyi.com/" \
  -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
  --del-after-done

实测 97 个分片,8 线程约 3 秒下完,输出 72.5 MB 的 mp4 文件。

验证下载结果

ffprobe -v error -show_entries format=duration,size,bit_rate \
  -show_entries stream=width,height,codec_name \
  -of default=noprint_wrappers=1 output.mp4

确认视频完整:H.264 + AAC,720×1272 竖屏,452 秒,码率 1,345 kbps。

一体化脚本

把上面三步合成一个脚本,接受 tvid 参数,一步到位:

#!/usr/bin/env python3
"""爱奇艺视频下载:Playwright 拦截 dash API → 提取 m3u8 → 下载"""
import asyncio, json, subprocess, sys
from playwright.async_api import async_playwright

async def capture_m3u8(tvid: str) -> str:
    """用 Playwright 拦截 dash 响应,返回 m3u8 内容"""
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        dash_data = []

        async def on_response(response):
            if 'cache.video.iqiyi.com/dash' in response.url:
                try:
                    dash_data.append(await response.json())
                except:
                    pass

        page.on("response", on_response)
        await page.goto(
            f"https://www.iqiyi.com/shortvideo/?tvid={tvid}",
            timeout=20000, wait_until="domcontentloaded"
        )
        await asyncio.sleep(8)
        await browser.close()

    for resp in dash_data:
        try:
            videos = resp['data']['program']['video']
            best = max(
                (v for v in videos if v.get('m3u8')),
                key=lambda v: v.get('bid', 0)
            )
            return best['m3u8']
        except (KeyError, ValueError):
            continue
    raise RuntimeError("未捕获到有效的 dash 响应")

def download_m3u8(m3u8_content: str, save_dir: str, save_name: str):
    """用 N_m3u8DL-RE 下载 m3u8"""
    import tempfile, os
    m3u8_path = os.path.join(save_dir, f"{save_name}.m3u8")
    with open(m3u8_path, 'w') as f:
        f.write(m3u8_content)

    subprocess.run([
        "N_m3u8DL-RE", m3u8_path,
        "--save-dir", save_dir,
        "--save-name", save_name,
        "--thread-count", "8",
        "-H", "Referer: https://www.iqiyi.com/",
        "-H", "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "--del-after-done"
    ], check=True)

async def main():
    tvid = sys.argv[1] if len(sys.argv) > 1 else "8518700492290400"
    print(f"正在拦截 tvid={tvid} 的 dash 响应...")
    m3u8 = await capture_m3u8(tvid)
    print(f"获取到 m3u8,长度 {len(m3u8)} 字节")

    save_dir = "./downloads"
    import os
    os.makedirs(save_dir, exist_ok=True)
    print("开始下载...")
    download_m3u8(m3u8, save_dir, f"iqiyi_{tvid}")
    print("下载完成")

asyncio.run(main())

使用方式:

# 需要先安装 Playwright 浏览器
pip install playwright && playwright install chromium

# 下载指定 tvid 的视频
python3 iqiyi_download.py 8518700492290400

踩坑备忘

整理几个容易踩的坑:

1. VIP 视频需要登录态

上面的方法只能下载免费内容。如果视频是 VIP 专属,Playwright 需要先注入爱奇艺的登录 cookie。可以手动在浏览器里登录后导出 cookie,然后用 page.context.add_cookies() 注入。

2. 分享链接不能直接访问

爱奇艺的分享页面(m.iqiyi.com/mp/sharePlay.html)检测 User-Agent,非微信浏览器返回 404。所以不能直接用 Playwright 访问分享链接,需要先通过 baseinfo API 拿到 tvid,再构造短视频页 URL。

3. m3u8 中的 TS 分片 URL 有时效性

带签名的分片链接过一段时间会失效。拿到 m3u8 后要尽快下载,不要隔太久。

4. 画质取决于源文件

UGC(用户上传)视频的最高画质通常只有 720p,这是爱奇艺对非专业内容的限制,不是工具的问题。

5. N_m3u8DL-RE 不在 PATH 里

如果你用 Docker 容器运行,N_m3u8DL-RE 可能没装或者不在 PATH 里。要么把二进制放到 /usr/local/bin/,要么在脚本里用绝对路径。

总结

爱奇艺视频下载在 2026 年基本只剩 Playwright 拦截这一条路。核心思路就是”浏览器帮你带认证”——你不需要知道 k_uid、dfp 这些参数怎么生成,让播放器自己请求,你只管截获响应里的 m3u8。

整套方案依赖两个工具:Playwright(截获 dash 响应)和 N_m3u8DL-RE(下载 m3u8)。前者 pip install 就行,后者在 GitHub Release 页面下载对应平台的二进制。

如果你也遇到 yt-dlp 失效的情况,不妨试试这个方案。