Claude Code这个命令行编程助手,最近在我手里干了一件挺省心的活:定位一个藏在播放器背后的真实视频地址,再把整段视频拉回本地。起因是我调一个在线教学页面的样式时,发现视频资源走的是分段流,页面上根本不存在一个能直接下载的完整MP4直链。F12一开,请求列表密密麻麻,看着像视频的接口不少,但真正能用来下载的地址一个都找不到。
以前碰到这种问题,要么手动翻源码翻到眼瞎,要么把几百个JS文件一个个保存下来慢慢读。现在不用了,我可以直接把整份HTML和相关的JS片段交给Claude Code,让它替我分析播放逻辑,告诉我真实地址从哪来,再让它把下载脚本生成好。这篇文章我就把完整的思路和代码记录下来,包括我踩过的坑和判断链接有效性的经验。
适合看这篇文章的人:手头有公开页面需要离线保存视频,但发现页面上没有直链;或者单纯想搞清楚网页播放器的工作原理;又或者你已经在用Claude Code这类编程助手,想看看它在这种网络任务里到底能帮上多少忙。先说清楚,这里讲的技术只面向你明确有使用权的内容——比如自己的课程、公开演示素材、无版权争议的开放资源。付费墙、DRM这类东西不在讨论范围内。
1. 先搞明白:你在页面上看到的“视频地址”为什么是假的
1.1 四种常见的“假地址”形态
很多人以为在浏览器里看到视频能播,就一定能找到某个以.mp4结尾的直链。但现代网页播放器的实现方式早就不是这样了。我随手列一下最常见的几种“假地址”:
| 你看到的东西 | 实际状态 | 能不能直接下载 |
|---|---|---|
<video src="blob:https://..."> | 浏览器内存里的数据流引用 | 不能,这只是个指向内存的占位符 |
Network 面板里一堆video/MP2T请求 | 一个个 TS 分片 | 单个分片没有意义,播放器拿到的是顺序 |
以.m3u8结尾的清单 | 播放预案 | 可以,但要看清单里写的是直链还是子清单 |
| 接口返回的一串加密参数 | 播放器运行时再解析 | 不能,得模拟完整解析过程 |
我调试的页面属于第二种加第三种:播放器用的是 HLS 协议,视频被切成几十个甚至几百个小分片,播放器拿到一张.m3u8清单之后,才按顺序挨个请求分片。所以你即使看到一个.ts分片的地址,单独把它下载下来也没有意义——它只是整段视频里两秒钟的画面。
注意:如果你在元素面板里选中视频标签,看到
src="blob:https://..."这种地址,不用怀疑自己找错地方,这是正常的。播放器把所有分片拼成了流,再喂给浏览器的<video>标签,这个 blob 地址只在当前标签页存活,换个页面就失效。
1.2 不理解HLS,后面全白搭
HLS 全称是 HTTP Live Streaming,苹果公司提出的流媒体协议。它的核心思路可以用一个生活化类比解释:你不是直接拿到一个完整的汉堡,而是拿到一份“食材分装包”——m3u8 是打包菜单,上面写了分几步、每步用哪块食材(分片);ts 文件是食材包,本身可能是两秒的一段画面。播放器拿到菜单后,按顺序把食材一个一个拆开、组装、递给观众。
这个协议的好处是方便做多码率切换和网络自适应。比如带宽差的设备只请求低码率分片,带宽好的设备请求高码率分片。这就解释了为什么有些 m3u8 清单里不会直接写.ts地址,而是继续写了几个子清单——每个子清单对应一种清晰度。
#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360 360p.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720 720p.m3u8这种“主清单再指向子清单”的结构,是最容易让人懵圈的地方。我第一次处理这种链接时,脚本只做了第一层解析,结果拉到一堆文字列表而不是视频分片,白跑了一趟。后面我把判断分支补齐:如果清单里出现#EXT-X-STREAM-INF,说明拿到的是主清单,还得再请求一次子清单才能看到真正的分片地址。
1.3 定位真实地址的关键思路
明白了协议结构,“隐藏链接”其实就不神秘了。所谓隐藏,只是播放器把地址从静态 HTML 转移到了运行时的 JavaScript 逻辑里。常见的存放点有三种:
- 页面的某个 JS 文件里有写死的接口路径,比如
/api/video/config?id=123 - 接口返回的 JSON 里带有一个带签名参数的 m3u8 地址
- 播放器库(如 hls.js)的初始化参数里直接传入了视频清单地址
我的思路很简单:与其猜,不如直接看这个页面运行时到底发了什么请求。Chrome 的 Network 面板里勾选“保留日志”,刷新页面,然后输入m3u8过滤,通常几秒钟就能看到真正的清单地址。这个方法不需要什么高深技巧,但它能帮我们绕过所有页面源码的干扰,直奔结果。
2. 为什么这类活适合让Claude Code来干
2.1 我的人工分析流程 vs AI辅助流程
在说 Claude Code 之前,先说清楚我手工做这件事要花多少步,这样才能理解为什么后来我会依赖它节省时间。
传统流程大概是:打开页面,用开发者工具翻半天请求;看到一个疑似接口,复制到终端拼curl命令;遇到 403、token 过期,再回页面找生成逻辑;写下载脚本时还要处理分片列表解析、并发下载、合并转封装。全部走完,顺利的话一两个小时,不顺利的话一下午。
用 Claude Code 辅助之后,流程变成了这样:
| 任务环节 | 手工做法 | Claude Code 辅助 |
|---|---|---|
| 分析页面播放逻辑 | 手动读几百上千行 JS,寻找接口线索 | 喂页面源码,直接问“播放地址从哪来”,几秒出分析 |
| 请求接口测试 | 手拼 curl,反复改 header 试错 | 让 AI 生成带默认请求头的命令,一条条验证 |
| 编写下载脚本 | 从零处理分片、并发、异常重试 | 描述需求后 AI 生成完整可运行脚本,我只需要检查改动 |
| 排查报错 | 搜社区、读文档、自己猜 | 多轮追问“为什么报错”,AI 能结合上下文给方向 |
这里面最值钱的部分不是“让 AI 替你写代码”,而是“让 AI 替你读代码”。页面里的 JS 可能压缩过,也可能互相引用,人眼去追线索非常累。Claude Code 作为命令行工具,能直接读取项目目录里的文件,我只需要把 HTML 和 JS 源码保存到当前目录,然后像聊天一样问它。
2.2 动手前的环境准备和能力边界
要用 Claude Code,条件其实很简单:一个能跑命令行的终端,按照工具官方说明完成登录配置,然后进入一个目录,敲claude就能开始对话。它会自动读取目录里能访问的文件,也允许我指定某个文件让它分析。
我自己还会提前装好三样东西:
- Python 3 和
requests库,用来写下载脚本 - FFmpeg,用来合并分片和转封装
- 一个文本编辑器,用来临时保存抓下来的源码片段
装 FFmpeg 的一句话命令是brew install ffmpeg(macOS)或者去官网下载对应平台版本,Windows 用户也可以用winget install ffmpeg。只要不涉及重新编码,合并分片时 FFmpeg 速度极快。
但要注意边界:Claude Code 不会替我们突破访问权限,它既不能读取付费接口背后的加密逻辑,也不应该被要求去生成破解 DRM 的代码。如果某个请求返回 401,那说明用户身份验证不过关,正确的做法是回到页面确认自己有没有权限,而不是让 AI 绕过鉴权。这是整个流程的安全底线。
3. 实战:从页面源码到本地MP4的完整链路
3.1 先让Claude Code替我读页面,找线索
我把页面保存下来后,目录结构大致是这样的:
project/ ├── page.html ├── app.js └── player.js然后在终端里进入这个目录,启动 Claude Code:
claude它的工作方式是读取当前项目的上下文。我给它下的第一个指令是:
请阅读 page.html 和 player.js,找出页面里视频播放相关的所有信息。 重点说明:播放器初始化时传入了什么地址?是否有请求额外接口的逻辑?Claude Code 的返回通常不是直接给我一个视频地址,而是一份“侦察结论”:
初步分析: 1. player.js 中引入了 hls.js,属于 HLS 播放方案。 2. 页面使用了 #videoContainer 作为挂载节点。 3. 播放器初始化时没有直接给 m3u8 地址,而是先请求了 /api/lesson/12/config 这个接口。 4. config 接口返回的 JSON 中应该包含 videoUrl 字段, 猜测是最终的 m3u8 地址。看到第 3 条我就确定了:真实地址藏在那个 config 接口里。这一步如果让我自己读player.js,虽然也能找到,但至少要花几分钟去追踪几个变量之间的赋值链。AI 帮我跳过了最无聊的部分。
3.2 用手工请求验证候选地址是否有效
拿到线索后,我先让 Claude Code 生成一条curl命令来测试接口:
curl -s "https://example.com/api/lesson/12/config" \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)" \ -H "Referer: https://example.com/lesson/12"接口返回的 JSON 大概长这样:
{ "code": 0, "data": { "duration": 1832, "videoUrl": "https://cdn.example.com/videos/lesson12/index.m3u8?token=abc123&expires=1700000000" } }我把这个 videoUrl 复制到浏览器里直接打开,看到的内容不是二进制,而是这样的清单文本:
#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:10.000, segments/00001.ts #EXTINF:10.000, segments/00002.ts ...这说明链接有效,真实地址就是它。如果打开是乱码或者 403 页面,那就要回到 config 接口的生成逻辑里排查,看是不是需要额外的请求头。
注意:请求接口时务必带上
Referer头。很多视频服务端会校验这个字段,不带 Referer 的请求很容易被直接拒绝。这个细节我在第一次实战中踩过,后面专门整理成了排查表。
3.3 完整下载脚本:分片拉取、并发、重试一次搞定
拿到了 m3u8 地址,接下来就是下载。我有两个选择:直接用 FFmpeg 一条命令拉流,或者写 Python 脚本手动分片下载。大多数情况下我会选后者,因为脚本可控性更强,能处理断点续传、并发限制,也能清楚看到每个分片的下载情况。
先看最省事的方案,一句话就能完成:
ffmpeg -i "https://cdn.example.com/videos/lesson12/index.m3u8?token=abc123&expires=1700000000" -c copy lesson12.mp4FFmpeg 会自己读清单、按顺序请求所有分片并写入 mp4,非常方便。但缺点也很明显:如果中间某个分片因为网络问题下载失败,整个任务就失败了,重来一遍成本高。所以我会写一个 Python 脚本,把分片一个个拉下来,配合并发和重试,稳很多。
下面是基于这次实战整理的脚本,你替换成自己的 m3u8 地址和目录就能用:
import os import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed SEGMENT_DIR = "lesson_segments" os.makedirs(SEGMENT_DIR, exist_ok=True) M3U8_URL = "https://cdn.example.com/videos/lesson12/index.m3u8?token=abc123&expires=1700000000" BASE = "https://cdn.example.com/videos/lesson12/" HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "Referer": "https://example.com/lesson/12", "Origin": "https://example.com", } def fetch_m3u8(url: str) -> str: resp = requests.get(url, headers=HEADERS, timeout=15) resp.raise_for_status() return resp.text def resolve_segments(text: str) -> list[str]: urls = [] for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue if line.startswith("http"): urls.append(line) else: urls.append(BASE + line) return urls def download_one(seg_url: str, index: int) -> bool: for attempt in range(3): try: resp = requests.get(seg_url, headers=HEADERS, timeout=20) resp.raise_for_status() filepath = os.path.join(SEGMENT_DIR, f"{index:05d}.ts") with open(filepath, "wb") as f: f.write(resp.content) return True except Exception as e: print(f"[retry {attempt + 1}] segment {index}: {e}") time.sleep(1) return False def main(): text = fetch_m3u8(M3U8_URL) segments = resolve_segments(text) print(f"共发现 {len(segments)} 个分片") with ThreadPoolExecutor(max_workers=8) as pool: future_map = { pool.submit(download_one, seg, i): i for i, seg in enumerate(segments) } for future in as_completed(future_map): index = future_map[future] if not future.result(): print(f"分片 {index} 最终失败") print("下载结束,可以进入合并步骤") if __name__ == "__main__": main()脚本的逻辑很直接:先请求 m3u8 清单,解析出所有分片地址,再用 8 个线程并发下载。每个分片如果失败会重试 3 次,避免偶发网络抖动导致整个任务失败。文件名用了%05d格式,也就是从00000.ts到00120.ts这样排序,合并时不会乱序。
可能你会问:为什么用 8 个线程?这个数字是我试过几个并发档位之后折中的结果。太高(比如 32)容易被服务器识别成异常流量,导致部分请求被拒;太低(比如 2)下载几百个分片要等很久。8 是一个在多数场景下稳定又够快的值。
3.4 多码率清单的处理分支
我这次实战遇到的 m3u8 是直接指向分片的,所以比较简单。但前面提到过,有些清单是“主清单”,里面写的不是分片地址,而是多个子清单。这种清单需要在下载前先判断一下:
if "#EXT-X-STREAM-INF" in text: lines = text.splitlines() for i, line in enumerate(lines): if line.startswith("#EXT-X-STREAM-INF"): sub_url = lines[i + 1].strip() if not sub_url.startswith("http"): sub_url = BASE + sub_url text = fetch_m3u8(sub_url) break segments = resolve_segments(text)这个分支通常放在fetch_m3u8得到文本之后、resolve_segments之前。加了之后,脚本面对多码率清单也能自动挑到第一种子清晰度去下载。如果你想下载固定清晰度,可以改成匹配关键词,比如选择分辨率里包含720的那个子清单。
3.5 合并分片与转封装收尾
分片下载完成后,目录里是一堆.ts文件,得把它们合并成一个完整视频。Linux 和 macOS 下可以直接拼接:
cat lesson_segments/*.ts > combine.ts ffmpeg -i combine.ts -c copy lesson12.mp4Windows 下的命令稍有不同:
copy /b lesson_segments\*.ts combine.ts ffmpeg -i combine.ts -c copy lesson12.mp4-c copy的含义是不重新编码,直接复制流,所以速度极快,也不会损失画质。如果你打算把这个视频放到网页上做流式播放,建议最后再加一步优化:
ffmpeg -i lesson12.mp4 -c copy -movflags +faststart lesson12_fast.mp4faststart会把视频文件的元数据从文件尾挪到文件头,播放器开始播放时不用等整个文件的索引读完,体验会好很多。这一步不是必须的,但如果你保存视频的目的是后续复用,值得多做一下。
4. 实战血泪:6个高频坑和对应排查清单
4.1 常见错误速查表
这个表是我在这类任务里反复遇到、也反复帮其他开发者解决过的问题汇总,这次一并整理出来:
| 症状 | 根本原因 | 解决办法 |
|---|---|---|
| 请求 m3u8 返回 403 | 服务端校验 Referer 或 User-Agent | 在请求头里补上页面地址和完整浏览器 UA |
| 请求返回 401 | 接口鉴权失败,token 过期 | 脚本里先向页面登录接口刷新 token,再拼视频地址 |
| 分片下载了一部分后开始失败 | 签名参数带时间限制,过期了 | 检查 m3u8 里的expires,确认是否要重新获取清单 |
| 合并后的视频黑屏或花屏 | 分片下载不完整,或顺序错乱 | 检查文件名是否按零填充编号,重试失败分片 |
| 合并后没有声音 | 视频流和音频流是分开的两条清单 | 看主清单里是否还有二级音频清单,需要一并下载 |
| 脚本解析出来一堆子清单而不是分片 | 没处理多码率分支 | 在代码里增加#EXT-X-STREAM-INF判断,取子清单位置 |
其中“合并后没有声音”这个坑最隐蔽。我第一次遇到时,下载、合并都很顺利,播放画面也正常,就是没有声音。后来重新翻主清单,发现里面除了视频子清单,还有一个单独的音频子清单。这种设计通常用于多语言音轨切换,播放器会同时请求一条视频流和一条音频流,再把它们合成到一起呈现给用户。如果下载脚本只处理了视频分片,自然就没有声音。
解决思路是在解析主清单时把所有#EXT-X-MEDIA类型的音轨地址也找出来,分别下载视频分片和音频分片,最后用 FFmpeg 合并两个流。或者更省事的办法:这种情况直接用 FFmpeg 一步到位,让它自己处理多流的组装逻辑。
4.2 判断链接还有没有效的三个手感
写脚本之前,先用 30 秒判断链接有效性,可以省下大量调试时间。我总结出三个动作:
- 看 m3u8 地址里的签名参数。常见的参数名有
token、expires、signature、auth_key。如果expires时间戳已经过去了,那这个链接基本废了,需要重新回到页面刷新拿新地址。 - 用
curl发一个 HEAD 或 GET 请求,看返回状态码。200 说明可访问,403 通常是防盗链,404 才是真正的地址失效。 - 先下载前三个分片,用播放器试播。如果能正常播放,说明整个清单是健康的,再全量下载。
第 3 个手感很重要,也最容易忽略。我写过太多次“全量下载跑完才发现清单里某些分片已经 404”的脚本,等到播放时卡在一半才发现问题。现在我会在任何全量下载前先做一个“抽样试播”,几秒钟就能确认链路是否畅通。
4.3 一个值得保留的“半自动”习惯
用 Claude Code 这类工具久了,我会刻意保持一种“半自动”的协作节奏:让 AI 做侦察和初稿,但每一步关键结论我都自己验证,而不是直接全盘接受。
具体做法是,让 AI 输出结果后,我会接着追问一句:
请把你得出结论的依据,对应到具体代码行或具体请求上。如果 AI 能把答案落到“第几行代码里某个函数请求了某个接口”,我才会信任它的结论。如果它含糊其辞,那我宁可自己去页面上看一遍。这不是不信任工具,而是避免把时间浪费在一条错误的链路上。
5. 边界感:这段技术能做的事,和该做的事
5.1 判断“该不该下载”的三个问题
这类视频下载技术本身是中性的,就像一把螺丝刀,可以用来修书架,也可以用来拆别人的东西。区别在于使用者的目的和对象。我给自己设了一个简单的检查标准,做任何下载前先回答三个问题:
- 我是否有权访问这段视频的内容?
- 视频的作者或平台是否明确禁止下载?
- 下载后的用途是否局限于个人学习、备份或二次创作等合法场景?
如果三个问题都能给出正面的答案,那么技术操作至少站在合理的一侧。如果任何一条不确定,我就不会继续。特别是那些需要登录、付费、或者播放器里明确标注“禁止下载”的内容,默认不要碰。
5.2 关于DRM和加密的一句话说清
如果分片清单里出现了#EXT-X-KEY字段,说明视频分片带了一层 AES-128 加密,播放器需要先拿解密密钥才能播放。这种做法的目的是保护内容,和 DRM 属于同类思路。
从技术角度讲,这种加密确实有办法处理,但我不建议把它写进日常操作里。原因很简单:当内容方已经展示出保护意图时,继续尝试绕过就进入了明确的侵权区间。所以我的处理方式非常简单——遇到#EXT-X-KEY就停下,先确认自己有没有授权。公开课程、自有素材、演示页面,基本都不会走到这一步。
最后说个真实感受。这类“捕获隐藏视频链接”的任务,本质上是理解网页播放器原理、熟悉网络请求过程、再写脚本处理流媒体分片三个能力的组合。Claude Code 在其中扮演的不是魔法师,而是一个高效的搭档:它替我快速完成重复性分析,帮我少走弯路,但最终的判断和边界感,始终得由我自己把关。
还有一个小技巧想分享。很多页面的 m3u8 地址虽然带签名参数,但签名的过期时间通常和服务器时间绑定,而不是你的本地时间。我第一次自己生成下载参数时,一直用本地时间拼地址,结果服务器反复报过期。后来我在脚本里加了一步:先向服务器某个普通接口发一次请求,拿响应头里的日期时间做校准,再计算签名,问题就彻底消失了。这个坑不太常见,但遇到一次就知道值多少钱。