# douyin-downloader 源码深度拆解 > 工具:`jiji262/douyin-downloader`(https://github.com/jiji262/douyin-downloader) > 拆解时间:2026-06-10 > 目的:理解其底层实现机制,学习可复用在其他领域的技术技巧 --- ## 一、总体架构 ``` ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ CLI │──▶│ Downloader │──▶│ API Client │──▶ 抖音HTTP API │ main.py │ │ Factory │ │ api_client │ └──────────┘ └──────┬───────┘ └──────┬───────┘ │ │ ┌────▼────┐ ┌─────▼──────┐ │ Auth │ │ Anti-Bot │ │ Cookie │ │ X-Bogus │ │ msToken │ │ A-Bogus │ └─────────┘ └────────────┘ ┌──────┐ ┌──────────────┐ │Control│ │ Storage │ │限流/重试│ │ Database │ │队列管理│ │ FileManager │ └──────┘ │ 元数据 │ └──────────────┘ ``` **核心思想:** 全 HTTP API 驱动,模拟浏览器指纹 + 签名算法绕过反爬,直接请求抖音 Web API 获取原始视频/图片流地址,再用 aiohttp 批量下载。 **零浏览器依赖:** 除 cookie_fetcher(登录辅助)外,所有下载流程都不需要浏览器/Playwright/CDP。这比我们的 CDP 方案轻量得多。 --- ## 二、API 调用层(核心中的核心) ### 2.1 DouyinAPIClient(`core/api_client.py`) 这是整个工具的心脏。它封装了与抖音后端的所有通信。 **关键 API 端点:** | 端点 | 用途 | 参数 | |------|------|------| | `/aweme/v1/web/aweme/detail/` | 获取单个视频详情 | aweme_id, aid | | `/aweme/v1/web/aweme/post/` | 用户发布的视频列表 | sec_user_id, max_cursor, count | | `/aweme/v1/web/aweme/favorite/` | 用户点赞列表 | sec_user_id, max_cursor, count | | `/aweme/v1/web/mix/list/` | 用户合集列表 | sec_user_id, max_cursor, count | | `/aweme/v1/web/music/list/` | 用户使用音乐列表 | sec_user_id, max_cursor, count | | `/aweme/v1/web/user/profile/other/` | 用户个人信息 | sec_user_id | | `/aweme/v1/web/user/following/list/` | 关注列表 | sec_user_id, max_time | | `/aweme/v1/web/collects/list/` | 收藏夹列表 | cursor, count | | `/aweme/v1/web/collects/video/list/` | 收藏夹内容 | collects_id, cursor, count | | `/aweme/v1/web/aweme/comment/list/` | 评论列表 | aweme_id, cursor, count | | `/aweme/v1/web/hot/search/search_board/` | 热搜榜 | — | | `/aweme/v1/web/search/item/` | 搜索 | keyword, offset, count, sort_type | | `/aweme/v1/play/` | 视频播放流 | video_id, ratio, watermark, line | ### 2.2 关键机制:默认查询参数 每个 API 请求都附带大量"设备指纹"参数,模拟真实浏览器: ```python # 从 api_client.py _default_query() 提取 { "device_platform": "webapp", "aid": "6383", # App ID,1128=纯视频, 6383=视频+图文 "channel": "channel_pc_web", "pc_client_type": "1", "pc_libra_divert": "Windows", "version_code": "290100", "version_name": "29.1.0", "cookie_enabled": "true", "screen_width": "1536", "screen_height": "864", "browser_language": "zh-CN", "browser_platform": "Win32", "browser_name": "Chrome", "browser_version": "139.0.0.0", "os_name": "Windows", "os_version": "10", "cpu_core_num": "16", "device_memory": "8", "platform": "PC", "downlink": "10", "effective_type": "4g", "support_h265": "1", "support_dash": "1", "msToken": "xxx", # 动态获取 } ``` **关键发现:** aid=6383 是所有请求的基础,但当获取视频详情时如果 `aweme_detail` 为空并且 `filter_detail.filter_reason="images_base"`(说明是图文笔记),会切换 aid=1128 再试一次。这个 "aid 切换" 技巧可以绕过某些内容过滤。 --- ## 三、反爬签名算法(最核心的技术) 抖音 Web API 的每一个请求都**必须带签名**,否则返回空响应或 403。douyin-downloader 实现了两套签名: ### 3.1 X-Bogus(`utils/xbogus.py`) 这是抖音较老的签名方案,实现难度较低。 **生成流程:** 1. **RC4 加密 User-Agent**:用固定密钥 `\x00\x01\x0c` 对 UA 做 RC4 加密 2. **Base64 编码 UA 密文**:把 RC4 输出做标准 Base64 3. **双层 MD5 哈希**: - 对 Base64 串做 MD5 → 得到 ua_md5 - 对固定串 "d41d8cd98f00b204e9800998ecf8427e"(空字符串的 MD5)做双层 MD5 → 得到 empty_md5 - 对 URL 路径做三层 MD5 → 得到 url_md5 4. **组装 19 字节数组**:融合 UA、URL、时间戳、固定常数 ct=536919696 5. **异或校验**:所有字节逐一 XOR 得到最后一个校验字节 6. **奇偶拆分 + RC4 + 自定义 Base64 编码**: - 把 19 字节拆成奇偶两组 - RC4 加密合并后的序列 - 用自定义字符表 `Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=` 做 3→4 编码 **输出:** URL 末尾附加 `&X-Bogus=<22字符签名>` ### 3.2 A-Bogus(`utils/abogus.py`)— 新一代签名 来自 [Johnserf-Seed/f2](https://github.com/Johnserf-Seed/f2) 项目,使用**国密 SM3 哈希**(而非 MD5),是目前更安全的方案。 **生成流程:** 1. **生成浏览器指纹**:`BrowserFingerprintGenerator.generate_fingerprint("Chrome")` 模拟 2048 个字符的浏览器指纹字符串(含屏幕/硬件/Canvas/WebGL/字体等特征) 2. **SM3 哈希请求参数**:对 URL query string 做多次 SM3 哈希(带盐值) 3. **自定义 Base64 编码**:使用 255 字节的置换表 `self.big_array`(一个特大置换数组),对哈希结果做类似 RC4 的流加密 4. **输出:** 替换 URL 中的 query string 为带签名的版本,例如 `&a_bogus=<签名>` ### 3.3 签名选择策略 ``` if A-Bogus 可用(abogus 模块已安装): 用 A-Bogus(更安全,不易被风控) else: 用 X-Bogus(兼容,功能完整但风控风险稍高) ``` 从源码中的 fallback 逻辑可以看到,A-Bogus 依赖 `gmssl`(国密算法包),是一个可选依赖。没有它也完全能用。 **对我们自己的工具开发的启示:** X-Bogus 的实现只有 200 多行 Python,没有外部依赖,完全可以复制独立使用。它是绕过抖音反爬最关键的一步。 --- ## 四、URL 解析与短链处理(`core/url_parser.py` + `utils/validators.py`) ### 4.1 短链解析 用户粘贴的链接可能是: - `https://v.douyin.com/xxx/`(短链) - `https://v.iesdouyin.com/xxx/`(短链变体) - 直接裸链 `v.douyin.com/xxx`(无 scheme) ```python # 流程 if is_short_url(url): resolved_url = await api_client.resolve_short_url(normalize_short_url(url)) url = resolved_url # 替换为真实 URL ``` `resolve_short_url` 是 HTTP HEAD 请求跟随 302 重定向,从 Location header 拿到真实URL。 ### 4.2 URL 类型判断 ``` /video/<数字> → video 视频 /user/ → user 用户主页 /note/<数字> → gallery 图文笔记 /gallery/<数字> → gallery 图文 /slides/<数字> → gallery 幻灯片 /collection/<数字> → collection 合集 /mix/<数字> → collection 合集 /music/<数字> → music 音乐 /live/<数字> → live 直播 live.douyin.com/<数字> → live 直播(子域) ``` URL 解析结果会包含对应 ID,下载器根据 type 创建对应的下载器实例。 **可复用技巧:** 这种 "URL 类型路由" 模式适用于任何多类型资源平台(B站/小红书/微博),解析后就分发到不同下载器。 --- ## 五、下载管道(下载器体系) ### 5.1 工厂模式(`core/downloader_factory.py`) ```python downloader = DownloaderFactory.create(url_type, config, api_client, ...) # url_type="video" → VideoDownloader # url_type="user" → UserDownloader # url_type="live" → LiveDownloader # ... ``` ### 5.2 视频下载流程(`core/video_downloader.py` → `core/downloader_base.py`) ``` 1. URL解析 → 获取 aweme_id 2. 检查是否已下载(DB 查询 + 文件系统扫描) 3. 调用 get_video_detail(aweme_id) 获取完整元数据 4. 从元数据中提取视频流 URL: a. 从 video.bit_rate 找多档画质(highest/lowest/720p/1080p) b. 按画质偏好选择 play_addr c. 从 play_addr.url_list 提取无 watermark 的 CDN 直链 d. 如果 CDN 直链不是 douyin.com 域名,需要再签 X-Bogus 5. 下载 MP4 视频文件 6. 可选:下载封面图、背景音乐、头像、JSON 元数据、评论 7. 可选:对下载的视频做语音转文字(转录) 8. 写入 SQLite 数据库记录 + manifest 清单 ``` ### 5.3 画质选择算法(关键!) 抖音 API 返回的视频 `video.bit_rate` 是一个多档质量列表: ```python bit_rates = [ {"bit_rate": 1000000, "play_addr": {"width": 640, "url_list": [...]}}, {"bit_rate": 3000000, "play_addr": {"width": 1280, "url_list": [...]}}, ] ``` **选择策略:** - `highest`:bit_rate 最大 → 水印优先权最低 - `lowest`:bit_rate 最小 - `1080p`:按 width 最接近 1920 的档位 - **无 watermark 优先**:在所有候选 URL 中,优先选 URL 不含 `watermark=1` 或 `playwm` 或 `tplv-dy-water` 的 ### 5.4 无水印视频流获取 ``` 方法1(首选):从 bit_rate[].play_addr.url_list 找 CDN 直链 优先 douyinvod.com 等 CDN 域名,因为不走 /aweme/v1/play/ 重定向 方法2(回退):用 video_id + ratio 构造 /aweme/v1/play/ API https://www.douyin.com/aweme/v1/play/?video_id=xxx&ratio=1080p&watermark=0&line=0 这个 API 返回 302 重定向到真实 CDN 地址 ``` **无水印的核心原则:** `watermark=0` 参数 + 从 `play_addr_256`/`play_addr_265`/`play_addr_h264` 这些字段拿的 URL 本身就不含水印。真正的无水印是通过 API 返回的干净地址完成的。 ### 5.5 图文笔记下载 抖音的图文笔记(Gallery)数据结构完全不同: - `aweme_type` = 2, 68, 150 - 图片在 `aweme_data.image_post_info.images[]`(`image_post_info` 是一个 dict) - 或 `aweme_data.images[]` 兜底 - 每张图有多个备选 URL(原图/缩略图/水印图),按**无 watermark 优先**排序 **可复用技巧:** aweme_type 字段可以在一开始就判断内容类型,避免对图文请求视频 API 浪费。 ### 5.6 直播录制(`core/live_downloader.py`) ``` 1. 调用 /webcast/room/web/enter/ 获取房间信息 2. 从 room.stream_url 提取 FLV/HLS 流地址 - flv_pull_url: {SD, HD, FULL_HD, ORIGIN} - hls_pull_url_map: {HD1, HD2, HD3} 3. 按画质优先级选最高清(ORIGIN > FULL_HD > HD > SD > LD) 4. 用 aiohttp 分块写入 .flv 文件 5. 不依赖 ffmpeg(flv 是标准格式) ``` --- ## 六、音频提取(`core/audio_extraction.py`) **用途:** 把下载的视频文件压缩为低带宽 MP3,用于后续的语音转文字。 **方式:** 通过 `imageio-ffmpeg` 包获取 ffmpeg 二进制(PyInstaller 打包为 sidecar,不依赖系统 PATH 上的 ffmpeg),然后调用: ``` ffmpeg -y -i -vn -ac 1 -ar 16000 -b:a 32k -f mp3 ``` 参数含义: - `-vn`:丢弃视频流 - `-ac 1`:单声道 - `-ar 16000`:16kHz 采样率(Whisper 标准输入格式) - `-b:a 32k`:32kbps 比特率(极低带宽) - `-f mp3`:MP3 封装 **可复用技巧:** `imageio-ffmpeg` 包在很多平台上都预编译了 ffmpeg 静态二进制,比在系统安装 ffmpeg 更跨平台。而且 PyInstaller 可以把它打包进单文件。 --- ## 七、语音转文字(`core/transcript_manager.py`) **为什么投喂 mp3 而非 mp4?** 上传音频比上传视频快 10-20 倍,OpenAI 的 Whisper API 按分钟计费,视频抽音频后上传不增加费用。 **流程:** ``` 1. 检查配置: transcript.enabled = true 2. 检查 API Key: 从环境变量 OPENAI_API_KEY 或 settings.yml 3. 检查视频后缀: - 如果是 .m4a/.mp3/.wav/.aac/.opus/.flac 等音频格式 → 直接上传 - 否则 → 调用 extract_audio() 抽成 mp3 后上传 4. 调用 OpenAI Whisper API (POST /v1/audio/transcriptions) - multipart 表单, model=gpt-4o-mini-transcribe - 支持 language_hint 参数指定语言 5. 保存结果: {视频名}.transcript.txt + {视频名}.transcript.json 6. 记录到数据库 transcript_jobs 表 ``` **可复用技巧:** 这是"本地下载 → 远程 API 转写"的标准模式。任何需要语音转文字的场景都可以按这个流程来。 --- ## 八、认证与 Cookie 管理 ### 8.1 CookieManager(`auth/cookie_manager.py`) - 把 Cookie 存为 JSON 文件(`.cookies.json`,模式 600 仅所有者可读) - Cookie 验证:至少需要 `ttwid`, `odin_tt`, `passport_csrf_token` 三个 key - 自动过滤非法 Cookie 名(RFC6265 规范) **关键 Cookie:** | Cookie Key | 用途 | 必须? | |------------|------|--------| | `sessionid` | 登录会话 | 下载用 | | `sessionid_ss` | 安全登录会话 | 下载用 | | `sid_tt` | 会话 Token | 下载用 | | `sid_guard` | 会话保护 | 下载用 | | `ttwid` | 设备标识 | 基本 | | `odin_tt` | 用户标识 | 基本 | | `passport_csrf_token` | CSRF 防护 | 基本 | | `msToken` | 请求签名 | 会自动生成 | ### 8.2 msToken 管理器(`auth/ms_token_manager.py`) **用途:** msToken 是每个 API 请求必须带的参数,它由抖音的 mssdk 服务生成。 **获取策略(两层 fallback):** ``` 1. 优先用现有的 Cookie 里的 msToken 2. 从 F2 项目的 conf.yaml 读取 msToken 生成配置(url, magic, version, dataType, strData, ulr) 3. 调用 mssdk API 生成真实 msToken(从 Set-Cookie 头提取) 4. 全部失败 → 生成 182 位随机字符 + "==" 的假 msToken ``` **核心:** 即使是假 msToken(随机字符串),抖音也基本接受!这降低了门槛——不需要真的登录也能拿到部分公开数据。 ### 8.3 Cookie 获取工具(`tools/cookie_fetcher.py`) 这是一个 Playwright 辅助工具,通过打开浏览器让用户手动登录,然后: 1. 在登录后从 `context.storage_state()` 提取所有 Cookie 2. 从请求 headers、localStorage、sessionStorage 中抓取 msToken 3. 过滤出必需+建议的 Cookie(默认不存辣鸡 Cookie) 4. 写入 JSON 文件 + 可选自动写入 config.yml --- ## 九、搜索与发现(`core/discovery.py`) ### 9.1 热搜榜采集 ```python page = await api_client.get_hot_search_board() # → 调用 /aweme/v1/web/hot/search/search_board/ # → 返回热搜榜 JSON # → 写入 output_dir/hot_board/{timestamp}.jsonl ``` ### 9.2 搜索采集 ```python # 分页参数: offset, count # 排序: sort_type (0=综合, 1=最新, 2=最热) # 时间: publish_time (0=不限, 1=近24h, 7=近7天, 30=近30天) while has_more: page = api_client.search_aweme(keyword, offset=offset, count=page_size) accumulated.extend(page.items) offset = page.max_cursor ``` **JSONL 格式:** 每行一个完整的 JSON 对象,方便增量追加和流式处理。 --- ## 十、评论采集(`core/comments_collector.py`) ``` 1. 获取作品 aweme_id 2. 分页拉取评论: /aweme/v1/web/aweme/comment/list/ 3. 每页 page_size 条(默认 20),cursor 游标分页 4. 可选包含二级回复 (include_replies) 5. 可选上限 (max_comments) 6. 去重: 用 cid/comment_id 做 seen_ids 7. 写入 {视频名}_comments.json ``` **防无限循环机制:** 如果 cursor 连续两轮未推进但 has_more=True,停止拉取并 warning(避免接口变更导致死循环)。 --- ## 十一、控制层(限流/重试/队列) ### 11.1 RateLimiter(`control/rate_limiter.py`) ```python RateLimiter(max_per_second=2) # 每秒最多 2 个请求,用 asyncio.sleep 做令牌桶 ``` 默认每秒 2 个请求,足够慢以避免触发反爬阈值。 ### 11.2 RetryHandler(`control/retry_handler.py`) ``` 重试策略:退避延迟 [1, 2, 5] 秒 最多 3 次重试 特殊处理:空 200 响应(抖音反爬信号)→ 重新签名后重试 ``` **空 200 响应检测:** ```python if response.status == 200 and not body: # 反爬信号!需要重新签名重试 continue ``` 这个坑我们自己在 CDP 方案里也遇到过——抖音返回的 HTTP 200 但 body 为空。 ### 11.3 QueueManager(`control/queue_manager.py`) ```python QueueManager(max_workers=5) # 默认 5 个并发下载 ``` 基于 `asyncio.Semaphore`,控制同时下载的文件数量,防止带宽挤爆。 --- ## 十二、存储层 ### 12.1 FileManager(`storage/file_manager.py`) **文件路径模板:** 高度可定制: ```python DEFAULT_FOLDER_TEMPLATE = "{author_name}/{publish_date}_{aweme_id}" DEFAULT_FILE_TEMPLATE = "{publish_date}_{aweme_id}_{title}" ``` 模板变量: - `{author_name}` - 作者昵称 - `{aweme_id}` - 视频 ID - `{title}` - 视频标题(自动清理非法字符) - `{publish_date}` - 发布日期 - `{publish_ts}` - 发布时间戳 - `{media_type}` - 媒体类型 - `{author_sec_uid}` - 作者安全 ID ### 12.2 数据库(`storage/database.py`) 使用 SQLite (`dy_downloader.db`),主要表: - `download_records` - 下载记录 - `transcript_jobs` - 转录作业 - `top_authors` - 热门作者 **去重机制:** 同时检查数据库记录 + 本地文件系统扫描(通过文件名中的 aweme_id 模式匹配)。 ### 12.3 文件去重算法 ```python # base_downloader.py def _build_local_aweme_index(self): # 扫描下载目录所有文件 # 用正则 (? **总评:** 这是一个架构非常干净的 Python 下载器。最大的价值在于纯 API 驱动(零浏览器)、完整实现两套反爬签名算法、以及完善的 go-with-the-flow 用户工作流(从获取 Cookie 到下载到转写一条龙)。核心的 `api_client.py` + `xbogus.py` 两个文件(~1400 行)就可以独立完成 80% 的功能。