Python小工具|快手作品批量去水印:单视频+批量下载(附源码)

Python小工具|快手作品批量去水印:单视频+批量下载(附源码)

最近在准备开发一个去水印的小程序,于是就抓包分析了一下快手的用户作品接口。发现快手的作品接口并没有加密参数,直接就可以获取到作品的详细信息。

于是顺便写了一个Python小工具,除了可以通过作品的视频分享链接下载视频外,还可以通过分享的用户主页链接批量下载视频作品

👉 完整源码获取方式见文末。

本文目录

  1. 抓包分析:接口无加密
  2. 跳转流程:从分享链接到播放页
  3. 数据提取:解析 window.__APOLLO_STATE__
  4. 代码实现:核心函数解析
  5. 使用示例与效果演示
  6. 获取源码

一、抓包分析:接口无加密

准备开发去水印小程序时,抓包分析了快手的用户作品接口。发现一个好消息:快手的作品接口并没有加密参数,直接请求就能拿到作品信息。

图1:快手作品接口抓包分析

二、跳转流程:从分享链接到播放页

直接访问App分享的作品链接,会进行302跳转。跳转到作品的一个播放页,分享链接相当于一个短链接,只做中转。

抓包后发现,访问分享链接时,响应头里面的Location参数值就是即将跳转的链接。整个过程跳转了2次才到视频播放页。

图2:从分享链接到播放页的跳转流程

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

图3:播放页中的视频数据

三、数据提取:解析 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字典,再一个一个提取。

图4:window.__APOLLO_STATE__中的视频数据

四、整体思路

  • 访问作品的分享链接,从响应头的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
图5:从Location中提取视频ID

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))
    }
图6:提取视频详细信息

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)
Python小工具运行效果
图7:Python运行效果


接口返回的 photoUrl 是平台存储的原始视频地址,下载下来的视频就是无水印版本。整个过程中没有对视频做任何二次处理,直接拿到的是上传时的原片。如果你批量下载的视频还有水印,那大概率是发布者上传时本身就带了水印。

七、获取源码

👉 完整Python代码:https://pan.quark.cn/s/3365c9b275e2?pwd=LHWf

🔔 更多逆向工具和实战技巧,欢迎关注公众号【jsnx55

温馨提示:本文技术仅供学习交流,请勿用于非法用途。

Comments

No comments yet. Why don’t you start the discussion?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注