最近在准备开发一个去水印的小程序,于是就抓包分析了一下快手的用户作品接口。发现快手的作品接口并没有加密参数,直接就可以获取到作品的详细信息。
于是顺便写了一个Python小工具,除了可以通过作品的视频分享链接下载视频外,还可以通过分享的用户主页链接批量下载视频作品。
👉 完整源码获取方式见文末。
本文目录
一、抓包分析:接口无加密
准备开发去水印小程序时,抓包分析了快手的用户作品接口。发现一个好消息:快手的作品接口并没有加密参数,直接请求就能拿到作品信息。

二、跳转流程:从分享链接到播放页
直接访问App分享的作品链接,会进行302跳转。跳转到作品的一个播放页,分享链接相当于一个短链接,只做中转。
抓包后发现,访问分享链接时,响应头里面的Location参数值就是即将跳转的链接。整个过程跳转了2次才到视频播放页。

第3次请求返回的是一个HTML页面,点击查看预览,就是一个播放页面。我们需要的视频、标题、封面、点赞、观看等数据都在里面。

三、数据提取:解析 window.__APOLLO_STATE__
需要提取这些数据。把预览选项卡切换到“响应”,这个页面展示的就是播放页的HTML代码。
在代码里面搜索作品的标题,可以看到搜索结果中有类似这样的数据:
"VisionVideoDetailPhoto:3xrpm44xn3x28c2": {
"id": "3xrpmxxx3x28c2",
"duration": 213133,
"caption": "6月12号,周五最新前瞻 #股票知识 #财经形势解说 #散户必看 #股民交流 ",
"likeCount": "0",
"realLikeCount": 0,
"coverUrl": "https:\u002F\u002Fp4.a.ae6a.jpg?",
"photoUrl": "https:\u002F\u002Fk0u76y66.mp4",
"liked": false,
"timestamp": 1781186105445,
"expTag": "1_u\u002F2009766578849529937_xpcwebdetailxxnull0",
"llsid": null,
"viewCount": "527",
"videoRatio": 0.5625,
"stereoType": 0,
"musicBlocked": null,
"riskTagContent": null,
"riskTagUrl": null,
"manifest": {
"type": "id",
"generated": true,
"id": "$VisionVideoDetailPhoto:3xrpm44xn3x28c2.manifest",
"typename": "VisionVideoManifest"
}
这些数据是JS代码,把这个视频信息的对象赋值给了window.__APOLLO_STATE__。
我们需要用正则匹配先匹配下来,然后把匹配到的字符串转成Python代码里面的JSON字典,再一个一个提取。

四、整体思路
- 访问作品的分享链接,从响应头的
Location中提取视频ID - 将视频ID拼接到
/short-video/接口 - 访问播放页,用正则提取
window.__APOLLO_STATE__ - 解析JSON,获取视频链接、标题、封面、点赞、观看数据
- 下载视频
五、代码实现
1. 解析ID函数
get_video_id_from_url函数会把分享的作品链接和用户主页链接,都解析成视频ID或用户ID。
def get_video_id_from_url(self, url):
# 如果不包含http,直接当作video_id返回
if not url.startswith(('http://', 'https://')):
return url.strip()
url = url.strip().split()[0]
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
try:
response = requests.get(url, headers=headers, allow_redirects=False)
if response.status_code == 302:
location = response.headers.get("Location")
match = re.search(r'/short-video/([a-zA-Z0-9]+)', location)
if match:
return match.group(1)
match = re.search(r'photoId=([a-zA-Z0-9]+)', location)
if match:
return match.group(1)
match = re.search(r'/user/([a-zA-Z0-9]+)', location)
if match:
return match.group(1)
except:
pass
return None

2. 获取视频信息函数
把作品的ID传给get_video_info函数,就可以提取出视频链接、标题、封面、点赞、观看等数据。
def get_video_info(self, video_id: str) -> Optional[Dict[str, Any]]:
url = f"https://www.xxxxx.com/short-video/{video_id}"
try:
response = requests.get(url, headers=self.headers, cookies=self.cookies, timeout=30)
response.raise_for_status()
except Exception as e:
print(f"请求失败: {e}")
return None
match = re.search(r'window\.__APOLLO_STATE__\s*=\s*({.*?});', response.text, re.DOTALL)
if not match:
print("解析失败")
return None
try:
apollo_state = json.loads(match.group(1))
except:
print("JSON解析失败")
return None
photo_key = f"VisionVideoDetailPhoto:{video_id}"
photo_data = apollo_state.get("defaultClient", {}).get(photo_key)
if not photo_data:
print("未找到视频数据")
return None
title = sanitize_filename(photo_data.get("caption", ""))
return {
"video_id": video_id,
"title": title,
"cover": photo_data.get("coverUrl", ""),
"video_url": photo_data.get("photoUrl", ""),
"duration": round(photo_data.get("duration", 0) / 1000, 1),
"like_count": int(photo_data.get("likeCount", 0)),
"view_count": int(photo_data.get("viewCount", 0))
}

3. 下载文件函数
通过download_file函数,把视频和封面下载到本地。
def download_file(self, url: str, save_path: str) -> bool:
"""下载文件"""
try:
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
response = requests.get(url, headers=headers, stream=True, timeout=60)
response.raise_for_status()
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
return True
except Exception as e:
print(f"下载失败: {e}")
return False
六、使用示例
def download_single_video(self, video_id: str, base_folder: str = "downloads") -> bool:
"""下载单个视频和封面到 video_id 文件夹"""
info = self.get_video_info(video_id)
if not info:
return False
# 显示视频信息
print("\n" + "=" * 60)
print(f"📹 视频信息 - {info['video_id']}")
print("=" * 60)
print(f"📝 标题: {info['title']}")
print(f"⏱️ 时长: {info['duration']}秒")
print(f"❤️ 点赞: {info['like_count']}")
print(f"👀 播放: {info['view_count']}")
print("=" * 60)
# 询问是否下载
download_choice = input("\n是否下载该视频?(y/n): ").strip().lower()
if download_choice != 'y':
print("⏭️ 已跳过下载")
return False
# 创建文件夹
folder = os.path.join(base_folder, f"{info['title'][:10]}")
os.makedirs(folder, exist_ok=True)
# 下载视频
video_path = os.path.join(folder, "视频.mp4")
print(f"\n📥 下载视频中...")
if not self.download_file(info['video_url'], video_path):
return False
print(f"✅ 视频下载完成")
# 下载封面
cover_path = os.path.join(folder, "封面.jpg")
print(f"📥 下载封面中...")
self.download_file(info['cover'], cover_path)
print(f"✅ 封面下载完成")
return True
def download_user_videos(self, user_id: str, base_folder: str = "downloads") -> None:
"""下载用户所有视频和封面到 user_id/作品_id 文件夹"""
result = self.get_user_video_list(user_id)
if not result or result['total'] == 0:
print("❌ 未获取到视频列表")
return
# 显示用户视频列表
print("\n" + "=" * 60)
print(f"👤 用户 {user_id} 的作品列表")
print("=" * 60)
for idx, video in enumerate(result['video_list'], 1):
print(f"{idx:2d}. {video['title'][:50]}...")
print(f" ⏱️ {video['duration']}秒 | ❤️ {video['like_count']} | 👀 {video['view_count']}")
print("=" * 60)
# 询问是否批量下载
batch_choice = input("\n是否批量下载所有作品?(y/n): ").strip().lower()
if batch_choice != 'y':
print("⏭️ 已跳过批量下载")
return
user_folder = os.path.join(base_folder, f"用户_{result['user']}")
os.makedirs(user_folder, exist_ok=True)
success_count = 0
for idx, video in enumerate(result['video_list'], 1):
print(f"\n[{idx}/{result['total']}] 📹 处理作品: {video['video_id']}")
print(f" 📝 标题: {video['title'][:50]}...")
print(f" ⏱️ {video['duration']}秒 | ❤️ {video['like_count']} | 👀 {video['view_count']}")
# 检查视频URL是否有效
if not video['video_url']:
print(f" ❌ 视频URL为空,跳过")
continue
# 创建视频专属文件夹
video_folder = os.path.join(user_folder, f"{idx}_{video['title'][:10]}")
os.makedirs(video_folder, exist_ok=True)
# 下载视频
video_path = os.path.join(video_folder, "视频.mp4")
print(f" 📥 下载视频中...")
if self.download_file(video['video_url'], video_path):
print(f" ✅ 视频下载完成")
# 下载封面(如果有)
if video['cover']:
cover_path = os.path.join(video_folder, "封面.jpg")
print(f" 📥 下载封面中...")
self.download_file(video['cover'], cover_path)
print(f" ✅ 封面下载完成")
success_count += 1
print(f" ✅ 作品处理完成")
else:
print(f" ❌ 视频下载失败")
print("\n" + "=" * 60)
print(f"📊 下载统计")
print("=" * 60)
print(f"✅ 成功: {success_count} 个")
print(f"❌ 失败: {result['total'] - success_count} 个")
print(f"📁 保存路径: {user_folder}")
print("=" * 60)

接口返回的 photoUrl 是平台存储的原始视频地址,下载下来的视频就是无水印版本。整个过程中没有对视频做任何二次处理,直接拿到的是上传时的原片。如果你批量下载的视频还有水印,那大概率是发布者上传时本身就带了水印。
七、获取源码
👉 完整Python代码:https://pan.quark.cn/s/3365c9b275e2?pwd=LHWf
🔔 更多逆向工具和实战技巧,欢迎关注公众号【jsnx55】
温馨提示:本文技术仅供学习交流,请勿用于非法用途。

