1. 项目缘起:从“想下载”到“能下载”的鸿沟
最近在技术社群里,经常看到有朋友问:“这个视频网站的视频怎么下载下来?” 或者 “我用浏览器开发者工具找到了一个视频链接,但下载下来只有几KB,根本不是视频文件。” 这类问题背后,其实是一个普遍存在的需求:我们想保存一些有价值的在线视频用于学习、备份或离线观看,但网站通常不会提供直接的下载按钮。手动从浏览器缓存里翻找,不仅效率低下,而且对于经过技术处理的流媒体视频,往往无功而返。
这时候,Python爬虫就成为了一个非常有力的工具。但“用Python爬取视频”这句话,听起来简单,实际操作起来却是一步一个坎。最核心、也最困难的一步,就是找到那个真正的、可下载的视频源URL。这个URL不是你在网页播放器里右键“复制视频地址”就能得到的(那通常是播放器页面的地址),也不是你在网络请求里随便看到一个.mp4或.m3u8链接就能直接用的。它必须是视频数据本身的、未经播放器二次封装的、服务器直接响应的资源地址。
我之所以花时间研究这个,是因为之前需要批量处理一些公开课视频。我发现,很多教程要么只讲基础的HTML页面抓取,对动态加载的视频束手无策;要么给出的方法过于笼统,遇到稍微复杂一点的网站就失效了。所以,我决定把从分析网页结构、追踪网络请求,到最终定位并验证可下载视频源URL的完整链路,结合我踩过的坑和总结的技巧,系统地梳理出来。无论你是想下载单个视频,还是需要构建一个自动化的视频采集流程,理解这套方法都能让你事半功倍。
2. 核心挑战:视频源URL藏在哪里?
在动手写代码之前,我们必须先理解我们要找的目标是什么,以及它可能以哪些形式存在。这决定了我们后续的技术选型和排查路径。
2.1 视频传输的几种常见形式
现代视频网站为了平衡用户体验、带宽成本和版权保护,采用了多种视频传输技术,对应的源URL也各不相同:
- 直接文件链接(渐进式下载):这是最“古老”也最直接的方式。视频就是一个完整的
.mp4、.webm或.flv文件,嵌在网页的<video>标签的src属性里,或者通过一个简单的XHR/Fetch请求获取。你找到这个链接,用浏览器或下载工具就能直接下载完整文件。这种方式现在多见于一些小型网站或个人站点。 - 流媒体协议(HLS/DASH):这是目前主流视频网站(如B站、YouTube、爱奇艺等)最常用的技术。它把一个大视频文件切割成成百上千个小片段(TS/MP4片段),并通过一个“播放列表”文件(M3U8/MPD)来组织。你看到的视频源URL,往往就是这个
m3u8或mpd文件的地址。下载这类视频,需要先获取播放列表,再根据列表中的片段地址,将所有小文件下载下来,最后合并成一个完整的视频。 - 动态混淆与加密:为了防止简单的爬取,网站会对视频地址进行动态生成、添加时效性Token(如
expires、sign参数)、甚至对视频流本身进行加密(如AES-128加密)。这时,你直接拿到的m3u8文件里的片段链接可能是无效的,或者下载下来的片段是无法播放的密文。这就需要我们破解其地址生成逻辑或解密密钥。
2.2 寻找URL的三大主战场
我们的爬虫需要在这三个地方仔细搜寻线索:
- 网页源代码(HTML):使用
requests+BeautifulSoup或lxml进行初步抓取。重点检查<video>标签的src属性,以及带有video、mp4、m3u8等关键词的<script>标签。对于简单的网站,这一步可能就直接找到地址了。但更多时候,这里只有播放器的初始化代码,真正的视频地址是通过JavaScript动态加载的。 - 网络请求(Network):这是主战场。打开浏览器的开发者工具(F12),切换到Network(网络)面板,清空记录,然后刷新页面或开始播放视频。你会看到所有发生的网络请求。我们需要在其中筛选出
XHR、Fetch、Media类型的请求。视频数据或播放列表通常通过这些请求获取。关键技巧是:按文件类型排序,寻找.m3u8、.ts、.mp4、.m4s、.flv等后缀的请求;或者按请求路径中的关键词,如video、playurl、getsource等来过滤。 - JavaScript执行环境:当网络请求中的地址带有复杂的、看似随机的查询参数(如
&sign=xxxxx&token=yyyyy)时,这些参数很可能是在页面加载后,由前端的JavaScript代码计算生成的。这时,我们需要分析相关的JS文件,理解其加密或签名算法。这一步难度最大,可能需要使用PyExecJS、js2py库在Python中执行JS代码,或者更常用的是,通过逆向工程,找到生成关键参数的API接口,直接模拟调用这个接口来获取可用的地址。
3. 实战工具箱:从环境准备到请求模拟
工欲善其事,必先利其器。下面是我在长期爬取视频过程中,总结出的一套高效、稳定的工具组合和配置方法。
3.1 基础环境搭建与核心库选择
首先,确保你有一个Python环境(3.6以上版本均可)。我强烈建议使用虚拟环境(venv)来管理项目依赖,避免包冲突。
# 创建并激活虚拟环境(Windows) python -m venv venv venv\Scripts\activate # 创建并激活虚拟环境(macOS/Linux) python3 -m venv venv source venv/bin/activate接下来,安装核心库。不要一次性安装一大堆,按需索取,这里列出的是我认为的“黄金组合”:
pip install requests beautifulsoup4 lxmlrequests: HTTP请求的绝对核心,简单易用,功能强大。beautifulsoup4+lxml: HTML/XML解析黄金搭档。lxml解析速度更快,作为BeautifulSoup的解析器后端。
对于动态渲染的页面(即视频地址由JavaScript动态生成,在初始HTML中找不到),我们需要能执行JS的工具:
pip install selenium webdriver-managerselenium: 自动化浏览器工具,可以模拟真实用户操作,让JS代码完全执行。webdriver-manager: 自动管理浏览器驱动(如ChromeDriver),省去手动下载和配置的麻烦。
注意:使用Selenium会打开真实的浏览器窗口,资源消耗大,速度慢。它应该是我们“迫不得已”时的选择。优先尝试通过分析网络请求直接找到数据接口。
对于处理主流的HLS流(.m3u8),我们需要专门的下载和合并工具:
pip install m3u8m3u8: 用于解析M3U8文件,轻松提取出其中所有的.ts片段地址。
最后,为了将下载的众多TS片段合并成一个MP4文件,我们需要ffmpeg。这不是Python库,而是一个强大的命令行音视频处理工具。
- 下载:从官网 https://ffmpeg.org/download.html 下载对应系统的版本。
- 配置:将
ffmpeg可执行文件所在目录添加到系统的环境变量PATH中。这样你就可以在命令行或Python代码中直接调用ffmpeg命令了。在Python中,我们可以用subprocess模块来调用它。
3.2 请求头(Headers)的艺术:让自己看起来像浏览器
这是爬虫能否成功的第一步,也是很多新手最容易忽略的一步。服务器会通过请求头来判断访问者是谁。一个光秃秃的Pythonrequests请求,很容易被识别为爬虫并被拒绝。
最基本的,我们需要伪装User-Agent,让自己看起来像一个普通的浏览器。但仅仅这样还不够,对于视频网站,以下几个头信息至关重要:
import requests 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', 'Referer': 'https://www.example-video-site.com/', # 关键!表示请求来自哪个页面,很多网站会校验此字段。 'Accept': '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br', # 注意:requests会自动解压,这里写上和浏览器一样即可。 'Connection': 'keep-alive', }Referer:极其重要。它告诉服务器当前请求是从哪个页面链接过来的。视频资源服务器通常会严格校验这个字段,如果Referer不对或者缺失,会直接返回403或404错误。这个值通常就是视频所在页面的URL。Accept-Encoding: 写上浏览器支持的压缩格式,但requests的响应内容会自动解压,所以我们直接处理文本或二进制内容即可。Cookie: 如果需要登录后才能观看的视频,那么Cookie就是必需的。可以通过浏览器登录后,从开发者工具的Network面板中,复制任意一个请求的Cookie头值过来。
如何获取这些头信息?最准确的方法是:打开目标视频页面,按F12打开开发者工具,在Network面板里,找到那个真正的视频或m3u8文件的请求,点击它,在右侧的Headers选项卡中,找到Request Headers部分,直接复制整个键值对字典。
3.3 会话(Session)保持与连接复用
当我们进行一系列操作(如先访问首页,再跳转到播放页,最后获取视频地址)时,使用requests.Session()可以自动管理Cookies,保持登录状态,并复用TCP连接,提升效率。
session = requests.Session() session.headers.update(headers) # 为会话设置统一的请求头 # 第一个请求,可能获取到一些初始Cookie或Token page_response = session.get('https://www.example.com/video/123') # ... 解析page_response,获取下一步需要的参数 ... # 第二个请求,session会自动带上第一次请求获得的Cookie api_response = session.get('https://api.example.com/get_play_url', params=params)4. 深度解析:定位视频源URL的实战流程
理论说再多,不如实际走一遍。我们以一个假设的、综合了多种常见技术的视频网站为例,来演示完整的排查和抓取流程。假设目标URL是:https://www.example-video.com/play/abc123。
4.1 第一步:静态HTML分析(快速扫描)
首先,我们用最基础的方法试试水。
import requests from bs4 import BeautifulSoup url = 'https://www.example-video.com/play/abc123' headers = { 'User-Agent': 'Mozilla/5.0...', 'Referer': 'https://www.example-video.com/' } response = requests.get(url, headers=headers) soup = BeautifulSoup(response.text, 'lxml') # 查找video标签 video_tags = soup.find_all('video') for tag in video_tags: src = tag.get('src') if src: print(f"找到video标签src: {src}") # 有时地址在source标签内 for source in tag.find_all('source'): print(f"找到source标签src: {source.get('src')}") # 查找可能包含视频地址的script标签 script_tags = soup.find_all('script') for script in script_tags: if script.string: # 只检查有内容的script content = script.string # 简单搜索常见关键词 if 'm3u8' in content or 'mp4' in content or 'playUrl' in content: print("在script中发现疑似视频地址的代码块") # 这里可以进一步用正则表达式提取 # import re # urls = re.findall(r'https?://[^\s\'"]+\.(m3u8|mp4)[^\s\'"]*', content)如果这一步幸运地找到了一个直接的.mp4链接,并且能用浏览器打开下载,那么任务就简单完成了。但大多数情况下,这里要么一无所获,要么找到的地址是残缺的或需要拼接的。
4.2 第二步:动态网络请求追踪(核心步骤)
当静态分析无效时,我们必须借助浏览器开发者工具。手动操作流程如下:
- 打开Chrome浏览器,进入目标视频页面
https://www.example-video.com/play/abc123。 - 按
F12打开开发者工具,切换到Network面板。 - 点击面板上的清空按钮(一个带斜线的圆圈)。
- 在筛选器(Filter)中输入
m3u8或ts或mp4,然后刷新页面或点击播放按钮。 - 观察列表中出现的请求。重点关注类型为
XHR、Fetch或Media的请求。
情景A:直接找到M3U8文件你可能会看到一个请求,它的名称类似index.m3u8、playlist.m3u8,或者是一串随机字符.m3u8?token=xxx。点击这个请求,在Headers选项卡中可以看到完整的Request URL,这就是我们梦寐以求的播放列表地址。复制这个地址。
情景B:找到获取地址的API接口更常见的情况是,找不到直接的媒体文件请求,但会发现一个或多个XHR请求,其响应内容是JSON格式,里面包含了视频的播放地址。请求的URL可能包含getPlayInfo、videoUrl、playurl等关键词。点击这个请求:
- 在Headers选项卡里,复制Request URL(这是API的地址)和Request Method(通常是GET或POST)。
- 查看Query String Parameters或Payload选项卡,了解调用这个API需要传递哪些参数(如
vid,cid,t,sign等)。这些参数很可能需要从页面源代码或之前的请求响应中提取。 - 在Response选项卡里,查看服务器返回的JSON数据,其中
url、playUrl、m3u8_url等字段很可能就是视频地址。
假设我们找到了一个API:GET https://api.example-video.com/player/video_url, 它需要参数video_id=abc123&t=1648886400&sign=xyz789。返回的JSON是{"code":0, "data": {"url": "https://v-cdn.example.com/abc123/index.m3u8?token=aaa"}}。
那么我们的Python代码就需要模拟这个请求:
import requests import json import time api_url = 'https://api.example-video.com/player/video_url' params = { 'video_id': 'abc123', 't': str(int(time.time())), # 常见的时间戳参数 'sign': 'xyz789' # 这个sign需要破解,可能是其他参数通过某种算法生成的 } # 注意,sign的生成是最大难点,我们稍后讨论。 headers = { 'User-Agent': '...', 'Referer': 'https://www.example-video.com/play/abc123', # Referer必须正确 } response = requests.get(api_url, params=params, headers=headers) if response.status_code == 200: data = response.json() if data.get('code') == 0: m3u8_url = data['data']['url'] print(f"成功获取M3U8地址: {m3u8_url}") else: print(f"API返回错误: {data}") else: print(f"请求失败,状态码: {response.status_code}")4.3 第三步:破解参数加密与签名(攻坚克难)
很多网站的API,特别是返回核心资源地址的API,都会对请求参数进行签名(sign)或加密,以防止未经授权的调用。这个sign值通常是由其他几个参数(如video_id、t时间戳、一个固定的key)按照特定规则(如MD5、SHA1、Base64、自定义算法)计算得出的。
如何找到这个算法?
- 搜索关键词:在开发者工具的Sources面板或Network面板中已加载的JS文件里,全局搜索(Ctrl+Shift+F)关键词,如
sign、encrypt、md5、sha1、token、getSign等。 - 打断点调试:在可能生成sign的API请求发起前打上XHR/Fetch断点,然后重新触发请求,查看调用栈,找到生成参数的JavaScript函数。
- 复制算法:找到关键的JS函数后,尝试将其逻辑“翻译”成Python代码。如果算法不复杂(比如只是简单的字符串拼接后取MD5),这很容易。如果算法很复杂或混淆了,可以考虑使用
PyExecJS库,直接在Python中执行这段JS代码来得到sign。
# 假设我们找到了一个简单的MD5签名算法: sign = md5(video_id + t + '一个固定盐值') import hashlib def generate_sign(video_id, t, salt='MY_SECRET_SALT'): sign_str = video_id + t + salt # 注意:JS和Python的字符串编码可能不同,确保一致 m = hashlib.md5() m.update(sign_str.encode('utf-8')) return m.hexdigest() # 使用 t = str(int(time.time())) sign = generate_sign('abc123', t) params['sign'] = sign如果逆向JS实在困难,还有一个“取巧”但有效的方法:直接复用浏览器的请求。用Selenium打开页面,让页面正常加载并播放视频,然后从Selenium控制的浏览器中,直接获取网络请求的日志,提取出已经包含正确签名的完整URL。这种方法省去了逆向分析,但依赖于浏览器环境。
5. 下载与处理:从M3U8到完整MP4
当我们最终拿到了一个有效的.m3u8文件地址后,工作只完成了一半。接下来需要下载并解析这个M3U8文件,然后下载所有的视频片段(.ts文件),最后将它们合并。
5.1 解析M3U8并下载TS片段
我们使用m3u8库来解析。
import os import requests import m3u8 def download_m3u8_video(m3u8_url, output_filename='output.mp4'): """ 下载并合并M3U8视频流。 """ # 1. 下载并解析M3U8文件 print(f"正在获取M3U8播放列表: {m3u8_url}") response = requests.get(m3u8_url, headers=headers) if response.status_code != 200: print(f"获取M3U8失败: {response.status_code}") return playlist = m3u8.loads(response.text, uri=m3u8_url) # 注意传入uri用于解析相对路径 if not playlist.segments: print("M3U8文件中未找到视频片段。") return print(f"共发现 {len(playlist.segments)} 个TS片段。") # 2. 创建临时目录存放TS文件 temp_dir = 'ts_files_temp' os.makedirs(temp_dir, exist_ok=True) ts_paths = [] # 3. 遍历并下载所有TS片段 for i, segment in enumerate(playlist.segments): ts_url = segment.absolute_uri # 获取片段的绝对URL ts_filename = os.path.join(temp_dir, f'segment_{i:05d}.ts') ts_paths.append(ts_filename) print(f"下载片段 {i+1}/{len(playlist.segments)}: {ts_url}") try: ts_response = requests.get(ts_url, headers=headers, stream=True) ts_response.raise_for_status() with open(ts_filename, 'wb') as f: for chunk in ts_response.iter_content(chunk_size=1024*1024): # 1MB chunks if chunk: f.write(chunk) except Exception as e: print(f"下载片段 {ts_url} 失败: {e}") # 可以选择跳过或终止 print("所有TS片段下载完成。") # 4. 合并TS文件为MP4 (使用ffmpeg) # 方法一:使用ffmpeg concat协议(推荐,能处理编码问题) concat_file = os.path.join(temp_dir, 'file_list.txt') with open(concat_file, 'w', encoding='utf-8') as f: for ts_path in ts_paths: # 注意:文件路径需要转义,特别是Windows下的反斜杠 f.write(f"file '{os.path.abspath(ts_path)}'\n") ffmpeg_cmd = [ 'ffmpeg', '-f', 'concat', '-safe', '0', '-i', concat_file, '-c', 'copy', # 直接流复制,速度最快,无需重新编码 '-bsf:a', 'aac_adtstoasc', # 处理AAC音频的必要比特流过滤器 output_filename ] import subprocess try: print("正在使用ffmpeg合并片段...") subprocess.run(ffmpeg_cmd, check=True, capture_output=True) print(f"视频合并成功: {output_filename}") except subprocess.CalledProcessError as e: print(f"ffmpeg合并失败: {e}") print(f"stderr: {e.stderr.decode()}") except FileNotFoundError: print("未找到ffmpeg命令,请确保已安装并添加到系统PATH环境变量。") # 5. 清理临时文件(可选) # import shutil # shutil.rmtree(temp_dir, ignore_errors=True)关键提示:
ffmpeg的-c copy参数是精髓,它表示不进行耗时的重新编码,只是将TS片段像拼积木一样拼接起来,速度极快。-bsf:a aac_adtstoasc是处理AAC音频流的常用过滤器,没有它合并后的视频可能无声。
5.2 处理加密的M3U8
如果M3U8文件里包含#EXT-X-KEY标签,说明TS片段是被加密的。这个标签会指定加密方法(通常是AES-128)和密钥文件的URL(URI)。
# 在解析playlist后检查 if playlist.keys and playlist.keys[0]: key_info = playlist.keys[0] print(f"视频被加密,方法: {key_info.method}, 密钥URI: {key_info.uri}") # 你需要下载这个密钥文件 key_response = requests.get(key_info.absolute_uri, headers=headers) key = key_response.content # 这是一个16字节的密钥 # 然后,在下载每个TS片段后,需要用这个密钥进行AES-128解密,然后再写入文件。 # 可以使用 cryptography 库: pip install cryptography # from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes # ... 解密过程略复杂,需要根据具体格式处理 ...对于加密视频,ffmpeg如果能够访问到密钥,有时也能直接解密合并。你可以尝试将密钥保存为文件,然后使用ffmpeg的-decryption_key参数。但更通用的方法还是在Python下载过程中就完成解密。
6. 进阶策略与疑难杂症处理
掌握了基本流程后,我们还需要一些进阶技巧来应对更复杂的情况和提高效率。
6.1 应对反爬机制:IP限制、验证码与行为检测
- IP限制:频繁请求可能导致IP被封。解决方案是使用代理IP池。可以购买付费代理服务,或者使用一些免费的代理IP(但稳定性差)。在
requests中使用代理很简单:proxies = { 'http': 'http://your-proxy-ip:port', 'https': 'http://your-proxy-ip:port', # 注意,很多http代理也支持https } response = requests.get(url, headers=headers, proxies=proxies) - 验证码:遇到验证码,爬虫基本就难以前进了。可以考虑:1) 降低请求频率,避免触发;2) 使用打码平台(付费)进行识别;3) 尝试寻找无需验证码的API接口或数据源。
- 行为检测:网站会检测鼠标移动、点击轨迹等。使用Selenium可以很好地模拟真人操作,但速度慢。对于纯API请求,确保你的请求头足够“真实”,并且请求间隔加入随机延时。
import time import random time.sleep(random.uniform(1, 3)) # 随机等待1-3秒
6.2 异步加速与断点续传
当需要下载大量TS片段时,同步下载会非常慢。我们可以使用aiohttp和asyncio进行异步并发下载,极大提升速度。
import aiohttp import asyncio import aiofiles async def download_ts_async(session, ts_url, save_path): try: async with session.get(ts_url) as resp: if resp.status == 200: content = await resp.read() async with aiofiles.open(save_path, 'wb') as f: await f.write(content) print(f"成功下载: {save_path}") else: print(f"下载失败 {ts_url}: {resp.status}") except Exception as e: print(f"请求异常 {ts_url}: {e}") async def download_all_ts_async(ts_url_list, temp_dir): connector = aiohttp.TCPConnector(limit=10) # 控制并发数,避免被封 async with aiohttp.ClientSession(headers=headers, connector=connector) as session: tasks = [] for i, ts_url in enumerate(ts_url_list): save_path = os.path.join(temp_dir, f'segment_{i:05d}.ts') task = asyncio.create_task(download_ts_async(session, ts_url, save_path)) tasks.append(task) await asyncio.gather(*tasks, return_exceptions=True) # 在主函数中调用 # ts_urls = [seg.absolute_uri for seg in playlist.segments] # asyncio.run(download_all_ts_async(ts_urls, temp_dir))对于大文件,可以实现断点续传,但这对于TS小片段集合来说,更简单的做法是记录已下载的片段索引,下次运行时跳过即可。
6.3 常见错误码与排查思路
- 403 Forbidden:最常见。原因:请求头不完整(缺
Referer、User-Agent不对)、Cookie失效、IP被禁、签名错误。逐一检查。 - 404 Not Found:URL拼写错误,或者资源地址已过期(特别是带时间戳Token的地址)。需要重新获取最新的地址。
- 302/301 重定向:
requests默认会自动处理重定向。但有时需要检查重定向后的最终地址。可以通过设置allow_redirects=False来禁用自动重定向,然后手动处理response.headers['Location']。 - 视频播放几秒就结束:下载的
m3u8可能是“顶级播放列表”,里面包含的是不同清晰度(如720p, 1080p)的次级m3u8地址。你需要解析这个顶级列表,选择其中一个清晰度对应的m3u8地址,再去下载那个地址,里面才是真正的TS片段列表。 - 合并后的视频音画不同步:TS片段本身可能有问题,或者
ffmpeg合并命令参数不当。尝试不用-c copy,而是重新编码(如-c:v libx264 -c:a aac),但这非常慢。更好的方法是确保下载的TS片段是完整的,并且来自同一个清晰的m3u8流。
7. 伦理、法律与最佳实践
在结束这篇长文之前,我必须强调技术应用的边界。
尊重版权与法律法规:本文分享的技术知识仅用于学习、研究和测试,以及下载你拥有合法权限的视频(如自己上传的、明确标注为CC协议可下载的公开课等)。未经授权下载、传播受版权保护的商业视频是违法行为。请在法律和道德允许的范围内使用爬虫技术。
遵守网站规则:查看目标网站的robots.txt文件(通常在网站根目录,如https://www.example.com/robots.txt),它规定了爬虫哪些页面可以访问,哪些不可以。虽然这不是法律,但遵守它是良好的网络礼仪。
控制访问频率:在你的爬虫代码中,务必在请求之间添加延时(例如time.sleep(2)),避免对目标服务器造成瞬间高并发压力,这既是礼貌,也能减少你IP被封的风险。
处理数据 responsibly:对于爬取到的数据,妥善保管,不要用于非法用途或侵犯他人隐私。
技术是一把双刃剑,强大的Python爬虫能力意味着更大的责任。希望你在探索技术奥秘的同时,也能成为一个负责任的技术使用者。