douyin-downloader 源码走读:单条链接到批量落盘的下载流水线,4 个核心文件讲清楚
【免费下载链接】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,aiohttp异步请求 +aiosqlite去重 +rich终端进度条,YAML 配置,一条命令跑。它解决的是"批量把作品稳定存到磁盘"这件事:重复作品不重下、请求速率不封 IP、登录态失效能自动重登、翻页被风控有浏览器兜底。
五分钟跑通:装依赖、取 Cookie、下第一条视频
环境准备:两条命令加一个 Cookie
Python 3.8+ 即可,核心依赖就是requirements.txt里那一串(aiohttp、aiosqlite、rich、pyyaml、gmssl等),不需要任何系统级服务:
git clone https://gitcode.com/GitHub_Trending/do/douyin-downloader cd douyin-downloader pip install -r requirements.txt # 弹出浏览器登录抖音,回终端按 Enter,Cookie 自动写入配置 python -m tools.cookie_fetcher --config config.ymlCookie 是后续一切接口请求的门票,tools/cookie_fetcher.py用 Playwright 起一个真实浏览器让你人工登录,比手动从 DevTools 复制省事。
最小配置:六个字段就够
仓库里没有预置config.yml,复制config.example.yml后按最小集裁剪即可,单视频场景只需要:
link: - https://www.douyin.com/video/7604129988555574538 path: ./Downloaded/ thread: 5 retry_times: 3 database: truelink接受任意可识别链接(视频/图文/合集/音乐/用户主页/短链),thread是并发数,database: true会生成dy_downloader.db用于去重和历史查询。
第一次成功:看什么输出
python run.py -c config.yml # 或者不写配置文件,命令行直接追加链接: # python run.py -c config.yml -u "https://www.douyin.com/video/7604129988555574538"结束时终端会打印=== Overall Summary ===和Total / Success / Failed / Skipped四项计数,Downloaded/下出现以作者名命名的目录,里面是日期_标题_aweme_id.mp4。桌面端(Douzy)把同样的后端做成了可视化工作台:
图:抖音下载链接工作台,粘贴视频或主页链接即可开始,进度实时可见
源码深潜:串起抖音下载主链路的 4 个文件
cli/main.py:一条 URL 怎么变成下载器
入口链路是run.py→cli/main.py:main()→asyncio.run(main_async())。每个 URL 在download_url()里走同一条流水线:短链先解出真实地址,URLParser.parse()分类,工厂按类型造下载器,执行后把汇总写进download_history表。
# cli/main.py — 主调度:短链解析 → URL 分类 → 工厂建下载器 → 执行 async with DouyinAPIClient( cookie_manager.get_cookies(), proxy=config.get("proxy"), ) as api_client: if is_short_url(url): resolved_url = await api_client.resolve_short_url(normalize_short_url(url)) if resolved_url: url = resolved_url parsed = URLParser.parse(url) # ... 能力门禁:部分类型解析得出来但没有下载器,提前拦下 ... 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)DownloaderFactory(core/downloader_factory.py)把 URL 类型字符串映射到VideoDownloader/UserDownloader/MixDownloader/MusicDownloader等具体类,所有下载器共享同一套构造签名——这样新增一种链接类型时,只需要在core/url_parser.py加一个正则分支和工厂里注册一行,主流程零改动。URLParser.parse()本身就是一张正则分类表:/video/(\d+)、/user/([A-Za-z0-9_-]+)、/collection/(\d+)分别抽出aweme_id、sec_uid、mix_id,分类结果决定后面的一切。
core/downloader_base.py:去重先问磁盘,再问数据库
所有下载器继承BaseDownloader,去重决策集中在_should_download():
# core/downloader_base.py — 去重判定:磁盘文件是主判据,数据库只是兜底 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): return False return True本地索引不是凭空来的:首次运行时_build_local_aweme_index()用rglob扫整个下载目录,从文件名里用 15~20 位数字的正则抽出aweme_id,跳过_cover/_music等附属文件和 0 字节文件。为什么磁盘优先?因为数据库可能被误删或损坏,而"磁盘上确实有这个文件"是最不会说谎的证据;数据库查不到时宁可补下也不能把作品永久误判为已下过。索引构建是同步重活,所以放在asyncio.to_thread里跑,避免大库扫描冻住事件循环。
core/downloader_base.py:多镜像轮转和 15 分钟总时限
同一个视频的下载地址通常有多个 CDN 镜像(play_addr的url_list),下载逻辑是"每轮按序每个候选试一次,整轮失败才退避重试":
# core/downloader_base.py — 单视频多镜像轮转 + 兜底总时限 _VIDEO_ITEM_DEADLINE_S = 900 async def _download_video_with_fallback( self, candidates, save_path, session, *, aweme_id=None, ) -> bool: async def _attempt_round() -> bool: for url, headers in candidates: if await self._download_with_retry( url, save_path, session, headers=headers, optional=True, retry=False, on_progress=on_progress, ): return True raise RuntimeError(f"All {len(candidates)} video url candidate(s) failed") return await self._run_within_item_deadline( self.retry_handler.execute_with_retry(_attempt_round), save_path )注释里写得很直白:play 端点失败多是 302 落到 PCDN 死节点,重试同一 URL 有意义;直连地址 403/过期则应换下一个候选。两种失败模式用"按轮扫 + RetryHandler 退避"同时覆盖。而没有_VIDEO_ITEM_DEADLINE_S这道 15 分钟总闸,重试轮数 × 候选数 × 单次超时最坏能把一条视频拖到 80 分钟,整条队列跟着停摆。镜像多时镜像列表本身即重试(_download_first_available里use_backoff = len(urls) == 1),避免在已知 403 的死镜像上叠加多轮退避。
control/rate_limiter.py:抖动为什么必须在锁内
RateLimiter是"最小间隔 + 随机抖动"的极简实现,retry_handler.py配 1s/2s/5s 固定退避,二者是所有 API 调用前的两道闸:
# control/rate_limiter.py — 最小间隔 + 随机抖动 async def acquire(self): async with self._lock: current = time.time() time_since_last = current - self.last_request if time_since_last < self.min_interval: wait_time = self.min_interval - time_since_last await asyncio.sleep(wait_time) await asyncio.sleep(random.uniform(0, 0.5)) self.last_request = time.time()默认 2 请求/秒,抖动 0~0.5s 在锁内执行是关键:锁内睡眠保证下一个协程从"本次实际发出时刻"起算间隔,而不是从"抢到锁时刻"起算,否则 N 个协程排队时真实速率会远超配置值。RetryHandler的延迟表是[1, 2, 5],超过长度后一直用 5s,max_retries指首次尝试之外的重试次数。存储侧storage/database.py的aweme表(aweme_id TEXT UNIQUE去重)建表时顺手开了PRAGMA journal_mode=WAL,让下载写入和历史读取能并发,SQLite 在这里只记历史与增量判定,不参与"要不要下"的主判断。
进阶调优:批量下载线程数、增量与画质配置
thread / rate_limit / retry_times:三个数字先记牢
thread(默认 5):下载 worker 池大小,见control/queue_manager.py,建议 3~8;再大不是更快,而是更容易触发接口风控。rate_limit(默认 2):API 请求上限(次/秒),全量抓主页时别超过 3,配合上面的抖动已是相当保守的画像。retry_times(默认 3):单文件失败退避重试次数,媒体下载基本够用了,失败大头是镜像死节点,那部分靠多候选轮转兜。
下载落盘结构
folderstyle: true且按模式分组时(group_by_mode: true),文件按"作者/模式/日期_标题_aweme_id"三层组织,download_manifest.jsonl每行一条作品记录:
Downloaded/ ├── download_manifest.jsonl └── 作者名/ ├── post/ │ └── 2024-02-07_作品标题_aweme_id/ │ ├── ....mp4 │ ├── ..._cover.jpg │ ├── ..._music.mp3 │ └── ..._data.json ├── like/ ├── mix/ └── music/文件名日期取作品发布时间create_time而非下载时间,缺失才回退当天——这样按时间翻档案是准的。
增量下载、原画与浏览器兜底
increase: post: true # 只下库里没有的新作品,依赖 database: true number: post: 0 # 0 = 不限,全量 video_quality: original # 探测上传原片,失败退回最高转码档 browser_fallback: enabled: true headless: false # 必须非无头:翻页风控后要人工过验证码REST 服务模式(--serve --serve-port 8000)把同一套后端暴露成/api/v1/download接口,job 按 TTL + 容量自动剪裁,适合挂进自动化流水线:
图:抖音下载任务中心,按作品查看任务结果、重试失败项
踩坑手册:批量下载的高频问题与改法
只能抓到 20 条作品,翻页不再前进
现象:主页批量任务停在第 20 条附近,日志没有报错。原因:接口翻页风控,后续页返回重复或空数据。解法:确认browser_fallback.enabled: true且headless: false,浏览器弹出后人工过掉滑块再让它继续;注意兜底目前只对post模式完整验证过,like/mix/music主要靠 API 正常分页。
任务中途报"登录态失效,需要重新登录"
现象:批量跑到一半抛出 LoginRequiredError,当前 URL 被判失败。原因:Cookie/登录态过期,API 返回未登录状态码。解法:交互环境下cli/main.py:_run_with_relogin()会自动弹浏览器登录一次并重试;手动场景直接重跑python -m tools.cookie_fetcher --config config.yml刷新 Cookie。
重跑任务想强制重下,作品全被 skip
现象:删了部分文件重跑,跳过计数照旧,媒体没补回来。原因:磁盘文件 + 数据库双通道去重,只删一半不生效。解法:两条都删,单作品:
rm -rf Downloaded/作者名/post/*_<aweme_id>/ sqlite3 dy_downloader.db "DELETE FROM aweme WHERE aweme_id = '<aweme_id>';"下载的 mp4 播放花屏无声,文件还"不见了"
现象:进度条跑满、时长正常,播放器里花屏,随后文件被删。原因:付费作品下发的是 CENC 加密流,_discard_if_encrypted()检测到加密盒会主动删掉并判失败,避免留一个放不了的"假成功"。解法:这类内容接口不提供解密密钥,下载参数无解;换非付费作品,或确认账号对该内容有授权。
磁盘文件为主判据、数据库只做兜底的"双通道去重"是这套代码里最值得搬走的实践:磁盘状态不会说谎,库不确定时宁可补下也不误杀。想自研长跑型抓取工具,优先通读core/downloader_base.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),仅供参考