抖音批量下载与去水印完整指南:douyin-downloader 数据流拆解与调优
【免费下载链接】douyin-downloaderA practical Douyin downloader for both single-item and profile batch downloads, with progress display, retries, SQLite deduplication, and browser fallback support. 抖音批量下载工具,去水印,支持视频、图集、合集、音乐(原声)。项目地址: https://gitcode.com/GitHub_Trending/do/douyin-downloader
团队需要归档一批竞品作者主页下的公开视频做内容分析,但逐条手动保存既不现实,带水印的源文件也无法直接用于下游处理。douyin-downloader 是一款面向抖音内容的 Python 批量下载工具:支持视频、图集、合集、原声的去水印下载,内置进度展示、指数退避重试、SQLite 去重与浏览器兜底。入口是run.py,配置是一份 YAML,核心逻辑分布在 core/、control/、storage/ 几个模块里,全部基于 async/await 异步 IO。
把一条链接丢给它之后,数据会经过解析、路由、去重、并发下载、落盘五类节点。下面沿这条数据流逐段拆开。
粘贴视频、图集、主页或合集链接即可开始的下载工作区
一次下载请求经过哪些节点
短链解析:先解析再分类
短链(v.douyin.com这类)必须先还原成完整 URL 才能分类。cli/main.py 里的download_url()是整个流水线的调度入口,短链解析、URL 分类、下载器创建、历史写入都串在这一个协程里:
# cli/main.py(节选):单条 URL 的处理主流程 async def download_url(url, config, cookie_manager, database=None, progress_reporter=None): file_manager = FileManager(config.get("path")) rate_limiter = RateLimiter(max_per_second=float(config.get("rate_limit", 2) or 2)) retry_handler = RetryHandler(max_retries=config.get("retry_times", 3)) queue_manager = QueueManager(max_workers=int(config.get("thread", 5) or 5)) async with DouyinAPIClient(cookie_manager.get_cookies(), proxy=config.get("proxy")) as api_client: if is_short_url(url): url = await api_client.resolve_short_url(normalize_short_url(url)) parsed = URLParser.parse(url) # 归类为 video / user / gallery 等 downloader = DownloaderFactory.create(parsed["type"], config, api_client, file_manager, cookie_manager, database, rate_limiter, retry_handler, queue_manager, progress_reporter=progress_reporter) result = await downloader.download(parsed) if result and database: await database.add_history({...}) # 任务级历史落库 return result四个控制组件(限速、重试、队列、文件管理)在函数开头一次性装配好,之后所有下载器共享同一套参数。这样 CLI 参数-t 8这类临时覆盖只改一处,全链路生效,不用在每个下载器里重复读配置。
core/url_parser.py 用正则从 URL 里提取aweme_id、sec_uid、mix_id等关键字段,返回带type的结构化字典:
# core/url_parser.py(节选):URL 分类与关键字段提取 @staticmethod def parse(url: str) -> Optional[Dict[str, Any]]: url_type = parse_url_type(url) # video/user/gallery/collection/music/live if not url_type: return None result = {"original_url": url, "type": url_type} if url_type == "video": result["aweme_id"] = URLParser._extract_video_id(url) # /video/{id} 或 modal_id= elif url_type == "user": result["sec_uid"] = URLParser._extract_user_id(url) return result纯字符串规则、无网络开销,解析失败可以立即短路,不会浪费一次 API 调用。
工厂路由:URL 类型到下载器的映射
拿到类型后,core/downloader_factory.py 负责路由。所有下载器继承BaseDownloader,构造函数签名完全一致,工厂只是做类型分发:
| URL 类型 | 下载器 | 媒体形态 | 典型场景 |
|---|---|---|---|
video | VideoDownloader | MP4 | 单条视频 |
gallery | VideoDownloader | 图集/实况图 | 图片笔记 |
user | UserDownloader | 批量作品 | 作者主页批量下载 |
collection | MixDownloader | 合集 | 系列内容 |
music | MusicDownloader | 音频 | 原声/音乐 |
live/live_replay | LiveDownloader/LiveReplayDownloader | FLV/播放列表 | 直播录制(实验性) |
# core/downloader_factory.py(节选):类型到下载器实例的分发 if url_type == "video": return VideoDownloader(**common_args) elif url_type == "user": return UserDownloader(**common_args) elif url_type == "gallery": return VideoDownloader(**common_args) # 图集与视频共用一个下载器 elif url_type == "collection": return MixDownloader(**common_args) elif url_type == "music": return MusicDownloader(**common_args)图集复用VideoDownloader而不是另起炉灶,是因为两者都走_download_aweme_assets()这条统一资产下载路径,只是媒体类型分支不同。
主页批量:模式策略如何分发
作者主页批量下载时,core/user_downloader.py 本身不实现抓取逻辑,而是把请求交给 core/user_modes/ 下的模式策略(Strategy Pattern),由UserModeRegistry自动发现并注册:
# core/user_downloader.py(简化):按启用模式顺序执行 for mode in enabled_modes: # post / like / mix / music strategy = UserModeRegistry.get_strategy(mode) aweme_list = await strategy.collect(self, sec_uid) # 各策略各自的 API 与分页 aweme_list = self._filter_by_time(aweme_list) # start_time / end_time 过滤 aweme_list = self._limit_count(aweme_list, mode) # number.post: 50 限制条数 await self._download_mode_items(aweme_list, mode) # 入队 + 线程池并发下载新增一种模式(比如"关注列表")只需要在user_modes/里加一个继承BaseUserModeStrategy的文件,注册器会自动发现,工厂和主流程零改动。跨模式去重也在这一层:同一个aweme_id不会在post和like里被下载两次。
按作者同步内容、筛选新作品后直接加入下载队列
去重判定:本地文件索引和 SQLite 双检查
批量任务跑得久的场景里,"哪些已经下过了"是核心问题。BaseDownloader._should_download()用两道关卡判定:
# core/downloader_base.py(节选):双检查去重 async def _should_download(self, aweme_id: str, *, force: bool = False) -> bool: if force: return True await self._ensure_local_aweme_index() # 首次:线程池扫描文件名建索引 if self._is_locally_downloaded(aweme_id): # 关卡一:本地文件索引 return False if self._redownload_missing_files_enabled() or self.database is None: return True if await self.database.is_downloaded(aweme_id): # 关卡二:SQLite 历史 return False return True第一道关卡是全量扫描path下媒体文件名中的 15–20 位数字 ID,且刻意忽略_cover、_music等附属文件——只下过封面的归档不算"已下载",下次补齐主媒体。扫描放在asyncio.to_thread里执行,避免大库目录把事件循环冻住。
技术要点:去重是"磁盘状态优先"的设计。SQLite 历史只记录、不决定跳过(README 明确说明)。实际效果:删库留文件不会触发重下;删文件留库记录会触发重下——程序把"库里有记录但本地缺失"视为需要重试。调库或清数据时别指望单删一侧就能完全控制行为。
并发下载:候选地址降级与 15 分钟兜底时限
真正拉字节之前先过限速器(默认 2 req/s),再请求作品详情。媒体文件下载是失败率最高的一环,_download_video_with_fallback()的设计值得细看:
# core/downloader_base.py(节选):多候选地址的降级下载 _VIDEO_ITEM_DEADLINE_S = 900 # 单条作品 15 分钟总时限 async def _download_video_with_fallback(self, candidates, save_path, session, ...): """在候选地址间降级:每轮按序各试一次,整轮失败再退避重试""" async def _attempt_round() -> bool: for url, headers in candidates: if await self._download_with_retry(url, save_path, session, ...): return True return False # 按轮扫描候选 + RetryHandler 退避,外加总时限兜底候选 URL 列表来自video.bit_rate清晰度梯队的自动选最高码率。为什么"按轮扫描"而不是死磕一个 URL?因为 play 端点的失败多为 302 落到 PCDN 死节点(重试同一地址有意义),而直连地址 403/过期则应该换下一个候选。轮内切换 + 轮间退避同时覆盖两种失败模式。
易踩的坑:没有总时限的话,"候选数 × 重试轮数 × 单次 300s 超时"最坏能挂 80 分钟,整条队列陪葬。
_VIDEO_ITEM_DEADLINE_S = 900就是为此加的——一条真跑满 15 分钟的作品基本已经没救了,直接放弃比拖死队列划算。
封面、音乐、头像这些可选资产则不同:它们相互独立,代码里把各自的协程统一登记后用asyncio.gather并行拉取,不让慢附件占住下载槽。
每个任务独立显示状态与计数,失败项可单独重试
落盘:文件结构与历史写入
全部资产就绪后,_download_aweme_assets()做三件事:主媒体成功后才把aweme_id标记进本地索引(封面成功不算)、写download_manifest.jsonl行式清单、把完整aweme_data连同author_sec_uid、封面镜像列表插入 SQLiteaweme表。目录结构由filename_template/folder_template渲染,默认形如:
Downloaded/ └── AuthorName/ └── post/ └── 2024-02-07_Title_aweme_id/ ├── ...mp4 / ..._cover.jpg / ..._music.mp3 └── ..._data.jsonmanifest 是固定 schema(作者改名也有author_sec_uid兜底),配合aweme表可以脱离程序本身做二次检索。
理解完这条数据流,下面用三步把整条链路跑起来。
从零跑通:三步完成首次批量下载
环境准备:Python 3.8+,克隆并安装依赖。浏览器兜底和自动获取 Cookie 需要 chromium:
git clone https://gitcode.com/GitHub_Trending/do/douyin-downloader cd douyin-downloader pip install -r requirements.txt pip install playwright python -m playwright install chromiumSuccessfully installed aiohttp aiosqlite aiofiles rich pyyaml ... Downloading chromium-... Chromium installed to ...最小可运行配置:复制示例配置,然后跑自动 Cookie 获取器,它会打开浏览器,登录抖音后回终端按 Enter,Cookie 直接写回配置文件:
cp config.example.yml config.yml python -m tools.cookie_fetcher --config config.yml打开浏览器登录抖音,完成后回到终端按 Enter ... Cookies written to config.yml最小配置只需要这几项(msToken、ttwid等 Cookie 字段已由上一步写入):
# config.yml:最小可运行配置 link: - https://www.douyin.com/video/7604129988555574538 path: ./Downloaded/ thread: 3 retry_times: 3 database: true database_path: dy_downloader.db首次执行验证:
python run.py -c config.ymlFound 1 URL(s) to process Database initialized ... === Overall Summary === Total: 1, Success: 1, Failed: 0, Skipped: 0Downloaded/{作者}/post/下出现.mp4、_cover.jpg、_data.json即全链路正常。同一链接再跑一次,输出会变成Skipped: 1——双检查去重已生效。
✅ 到这里,单条链路已经闭环,接下来按规模调参数。
调优:5 条链接测试与千级批量生产
📌 两档典型规模,配置差异主要在三处:并发、限频、增量。
5 条链接冒烟测试——低并发、快速暴露配置错误:
# 冒烟测试配置:先验证 Cookie、目录、网络 link: - https://www.douyin.com/video/7604129988555574538 - https://www.douyin.com/note/7341234567890123456 - https://www.douyin.com/collection/7341234567890123456 path: ./Downloaded/ thread: 3 # 低并发,报错容易定位 retry_times: 3 progress: quiet_logs: true # 保持终端进度条干净千级批量生产——限频与兜底是重点:
# 千级批量配置:作者主页 post 模式全量归档 link: - https://www.douyin.com/user/MS4wLjABAAAAxxxx mode: - post number: post: 1000 # 0 表示不限量 thread: 5 rate_limit: 2 # 2 req/s,降低风控概率 increase: post: true # 本地已存在主媒体则跳过,支持断点续跑 start_time: "2024-01-01" # 时间范围过滤 database: true database_path: dy_downloader.db browser_fallback: enabled: true headless: false # 保留可见窗口,风控验证码需要手动过| 场景 | 关键参数 | 推荐值 | 理由 |
|---|---|---|---|
| 冒烟测试 | thread/rate_limit | 3 / 默认 2 | 快速暴露配置与 Cookie 问题 |
| 千级批量 | thread/rate_limit | 5 / 2 | 与默认限频匹配,平衡速度与风控 |
| 千级批量 | increase.post | true | 中断后重跑跳过已完成,避免全量重来 |
| 千级批量 | browser_fallback.headless | false | 分页被风控时人工过验证码,无头模式无法交互 |
高频故障:症状、排查命令与处理
⚠️故障一:作者主页只拉到约 20 条帖子症状:批量任务停在 20 条左右,无报错。 排查:grep -A5 browser_fallback config.yml确认enabled: true且headless: false。 处理:这是分页风控的典型表现。启用兜底后程序会拉起浏览器,在弹窗里手动完成验证,且不要过早关闭窗口。
故障二:报登录态失效或大量 4xx症状:日志出现"登录态失效"或user_info获取失败。 排查:python -m tools.cookie_fetcher --config config.yml重新获取。 处理:Cookie 过期直接重跑该命令即可;交互式环境下 CLI 检测到LoginRequiredError也会自动拉起重新登录并重试一次。
故障三:队列长时间无进展症状:进度条静止,看似卡死。 排查:sqlite3 dy_downloader.db "SELECT aweme_id, title FROM aweme ORDER BY download_time DESC LIMIT 10;"看最后落库记录。 处理:单条作品有 900s 兜底时限,真正卡死时等时限触发即可;确认坏作品后可删除对应目录与库记录后重跑。
配置调顺之后,还有一类需求超出了工具边界。
边界与扩展:哪些场景需要额外方案
明确说下不擅长的部分:collect/collectmix收藏夹模式只支持当前登录 Cookie 对应的账号,且不能与post/like/mix混用;浏览器兜底目前只对post模式完整验证;直播录制中 HLS 源只保存播放列表,可播放输出要自行接 ffmpeg;放映厅(lvdetail)的 DRM 加密影视内容则完全不支持。如果你想扩展抓取模式,最短路径是在 core/user_modes/ 下新增一个继承BaseUserModeStrategy的策略类——注册器会自动发现,无需改工厂与主流程;直播回放方向的补全可以看 core/live_replay_downloader.py 的实现与对应测试。
【免费下载链接】douyin-downloaderA practical Douyin downloader for both single-item and profile batch downloads, with progress display, retries, SQLite deduplication, and browser fallback support. 抖音批量下载工具,去水印,支持视频、图集、合集、音乐(原声)。项目地址: https://gitcode.com/GitHub_Trending/do/douyin-downloader
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考