第一次接触HLS流媒体下载时,我被那些.ts片段文件和神秘的EXT-X-KEY搞得一头雾水。直到有次需要保存某平台的编程教程,才真正搞明白这套机制。HLS(HTTP Live Streaming)就像把整块披萨切成小片分发——视频被分割成若干.ts文件,而m3u8就是记录这些切片顺序的"菜单"。
典型的加密m3u8文件会包含这样的关键段落:
m3u8复制#EXT-X-KEY:METHOD=AES-128,
URI="https://example.com/key.key",
IV=0x1234567890abcdef1234567890abcdef
这里藏着三个重要信息:
我遇到过最棘手的情况是某教育平台采用动态密钥,每次请求返回不同的key。后来发现他们的密钥实际是通过固定算法生成的,只需要模拟登录获取token后就能预测出有效密钥。
工欲善其事必先利其器,推荐使用Python 3.8+环境,太老的版本可能会遇到Crypto库兼容问题。这是我的必备工具清单:
安装命令如下:
bash复制pip install requests pycryptodome
brew install ffmpeg # Mac用户
遇到过最坑的依赖问题是Windows环境下Crypto模块报错,解决方案是手动重命名安装目录下的Crypto文件夹:
python复制# 常见错误修复
import Crypto
from Crypto.Cipher import AES # 而不是 from crypto.Cipher import AES
建议创建独立的虚拟环境,我习惯用conda管理:
bash复制conda create -n m3u8_downloader python=3.8
conda activate m3u8_downloader
拿到m3u8文件就像得到藏宝图,需要先破解其中的密码。用requests获取内容后,重点解析这些标签:
python复制import re
def parse_m3u8(content):
segments = []
key_info = {'method': None, 'uri': None, 'iv': None}
for line in content.split('\n'):
if '#EXT-X-KEY' in line:
# 提取加密信息
key_info['method'] = re.search(r'METHOD=([^,]+)', line).group(1)
key_info['uri'] = re.search(r'URI="([^"]+)"', line).group(1)
if 'IV=' in line:
key_info['iv'] = re.search(r'IV=([^,]+)', line).group(1)
elif '#EXTINF' in line and not line.startswith('#EXT-X'):
# 记录片段时长
duration = float(re.search(r'([\d.]+)', line).group(1))
segments.append({'duration': duration})
return key_info, segments
实际项目中我总结出几个常见坑点:
#EXT-X-STREAM-INF时要选择合适的分辨率#EXT-X-MEDIA-SEQUENCE的起始值密钥获取是最关键的环节,根据我的踩坑经验,大致有这些获取方式:
python复制key = requests.get(key_uri).content
python复制import base64
key = base64.b64decode(requests.get(key_uri).text)
python复制# 模拟执行JS代码获取密钥
import execjs
with open('decrypt.js') as f:
js_code = f.read()
key = execjs.compile(js_code).call('get_key')
python复制session = requests.Session()
session.cookies.update({'auth_token': 'your_token'})
key = session.get(key_uri).content
python复制import time
timestamp = int(time.time() * 1000)
dynamic_uri = f"{key_uri}?t={timestamp}"
python复制headers = {
'Referer': 'https://original.site',
'Origin': 'https://original.site',
'X-Client-Key': 'fixed_value'
}
拿到密钥后,解密过程看似简单却暗藏玄机。这是经过多次调试后的稳定版本:
python复制from Crypto.Util.Padding import unpad
def decrypt_ts(encrypted_data, key, iv=None):
iv = iv or key # 当IV不存在时使用key作为IV
cipher = AES.new(key, AES.MODE_CBC, iv=iv[:16])
try:
# 处理可能存在的填充数据
decrypted = unpad(cipher.decrypt(encrypted_data), AES.block_size)
except ValueError:
# 某些平台使用非标准填充
decrypted = cipher.decrypt(encrypted_data)
return decrypted
特别注意这几个技术细节:
ValueError: Input strings must be a multiple of 16错误时需要检查数据完整性下载数千个ts文件时,直接串行下载会非常慢。我的优化方案是:
python复制from concurrent.futures import ThreadPoolExecutor
def download_segment(args):
idx, url, path = args
for _ in range(3): # 重试机制
try:
r = requests.get(url, timeout=10)
with open(f"{path}/{idx}.ts", 'wb') as f:
f.write(r.content)
return True
except:
continue
return False
with ThreadPoolExecutor(max_workers=16) as executor:
results = list(executor.map(
download_segment,
[(i, url, 'temp') for i, url in enumerate(ts_urls)]
))
bash复制copy /b *.ts merged.ts
bash复制ffmpeg -f concat -safe 0 -i <(for f in *.ts; do echo "file '$PWD/$f'"; done) -c copy output.mp4
python复制def verify_download(ts_dir, expected_count):
actual_count = len([f for f in os.listdir(ts_dir) if f.endswith('.ts')])
if actual_count != expected_count:
missing = set(range(expected_count)) - {
int(f.split('.')[0]) for f in os.listdir(ts_dir)
}
print(f"缺失片段:{sorted(missing)}")
将上述模块整合后的完整解决方案(带异常处理和日志记录):
python复制import os
import re
import requests
import logging
from Crypto.Cipher import AES
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urlparse
class M3U8Downloader:
def __init__(self, m3u8_url, output_file, max_workers=8):
self.m3u8_url = m3u8_url
self.output_file = output_file
self.max_workers = max_workers
self.temp_dir = "temp_ts"
self.logger = self._setup_logger()
def _setup_logger(self):
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def run(self):
try:
os.makedirs(self.temp_dir, exist_ok=True)
# 解析m3u8
self.logger.info("开始解析m3u8文件...")
key_info, segments = self._parse_m3u8()
# 获取密钥
if key_info['method']:
self.logger.info(f"检测到{key_info['method']}加密")
key = self._get_key(key_info)
else:
key = None
# 下载所有片段
self.logger.info(f"开始下载{len(segments)}个视频片段...")
self._download_all_segments(segments, key, key_info.get('iv'))
# 合并文件
self.logger.info("开始合并视频文件...")
self._merge_files()
self.logger.info(f"视频已保存至 {self.output_file}")
except Exception as e:
self.logger.error(f"处理失败: {str(e)}")
raise
finally:
# 清理临时文件
for f in os.listdir(self.temp_dir):
os.remove(os.path.join(self.temp_dir, f))
os.rmdir(self.temp_dir)
# 其他方法实现...
关键改进点:
面对越来越复杂的防御措施,这些技巧可能会帮到你:
python复制headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Referer': 'https://original.site/video',
'Origin': 'https://original.site',
'DNT': '1'
}
python复制import random
import time
def delayed_request(url):
time.sleep(random.uniform(0.5, 1.5))
return requests.get(url)
python复制proxies = {
'http': 'http://user:pass@proxy_ip:port',
'https': 'http://user:pass@proxy_ip:port'
}
response = requests.get(url, proxies=proxies)
python复制import websockets
async def listen_ws(uri):
async with websockets.connect(uri) as websocket:
while True:
message = await websocket.recv()
if 'encryptionKey' in message:
return json.loads(message)['encryptionKey']
python复制from selenium.webdriver import ChromeOptions
options = ChromeOptions()
options.add_argument('--headless')
driver = webdriver.Chrome(options=options)
driver.get('https://video.site')
key = driver.execute_script('return window.playerConfig.key')
最后提醒:技术无罪,请务必在合法合规的前提下使用这些方法。建议仅用于下载自己有权限访问的内容,比如已购买的课程或明确允许下载的资源。