1. 为什么我放弃了图形工具转回命令行做抖音视频归档做内容运营或者素材收集的朋友大概率都遇到过这样的场景刷到一个特别对味的账号想把ta主页的视频全部存下来做参考结果一条条点开、复制链接、打开解析网站、等广告、点下载一套流程下来十几分钟就没了存了不到十条。更别提有些解析站还会偷偷给你塞个水印或者干脆解析失败。我最早也是用各种在线解析工具和浏览器插件后来发现两个致命问题一是批量能力几乎为零二是稳定性完全看运气。直到我把整个流程搬到命令行里用三条核心命令串起来才真正实现了从单条视频到整个作者主页的批量无水印下载。这篇文章就把我这套方法完整拆开讲包括背后的原理、命令的每个参数为什么这么写、以及我在实际跑批量任务时踩过的那些坑。先明确一下这套方案适合谁如果你需要定期归档某个账号的公开视频、做竞品素材收集、或者单纯想把自己喜欢的创作者内容存到本地这套方法都能用。它不依赖任何图形界面Windows、macOS、Linux 都能跑核心就是链接解析 批量调度 文件命名这三件事。下面我会从最基础的单条下载讲起再一步步扩展到主页批量最后给出可以直接抄的脚本。2. 单条视频无水印下载先搞懂链接是怎么被还原的2.1 抖音视频链接的两种形态与识别逻辑在动手之前得先弄清楚你手里拿到的链接到底是什么。抖音的视频链接通常有两种形态一种是分享短链形如https://v.douyin.com/xxxxx/这种链接短、带跳转另一种是完整网页链接里面包含modal_id或者item_ids这样的参数。短链的好处是复制方便坏处是它本身不包含视频的真实地址需要先做一次重定向解析。我实测下来命令行里处理短链最稳的方式是用curl跟随重定向拿到最终的完整 URL再从里面提取视频 ID。这一步是整个流程的地基如果 ID 提取错了后面全白搭。视频 ID 一般是 19 位左右的数字串藏在modal_id或者路径的/video/后面。# 跟随短链重定向拿到最终地址 curl -sIL https://v.douyin.com/xxxxx/ | grep -i location | tail -1这里-s是静默模式不打印进度-I只取响应头-L跟随跳转。三个参数组合起来就能把短链的最终落点揪出来。拿到落点后用grep配合正则提取那串数字 ID这一步我建议单独写个小函数后面批量的时候直接复用。2.2 无水印地址的构造原理与参数含义很多人以为无水印是什么黑科技其实原理很朴素。抖音的视频文件在服务器上通常存了多个版本带水印的那个是给普通播放用的而去水印版本往往是通过替换播放地址中的特定参数来获取的。常见的做法是把播放接口里的playwm替换成play或者把分辨率参数调整到原始档位。我一般用sed做这个替换因为它足够简单直接# 把带水印的播放地址替换为无水印地址 echo $PLAY_URL | sed s/playwm/play/g注意这个替换逻辑会随着平台接口调整而变化如果你发现替换后下载的视频仍然带水印大概率是参数名变了这时候需要重新抓一次播放请求看看当前用的是哪个字段。构造出无水印地址后用curl或者wget直接下载就行。我偏好curl因为它的-o参数可以精确控制输出文件名配合-L还能处理二次跳转curl -L -o video_${VIDEO_ID}.mp4 $NO_WM_URL-o指定输出文件名-L跟随跳转${VIDEO_ID}用变量拼进去这样每个文件都自带唯一标识不会互相覆盖。单条下载跑通之后你会发现整个流程其实就是解析 ID → 构造地址 → 下载这三步而批量无非是把这三步套进一个循环里。2.3 第一次跑通时最容易忽略的请求头问题我第一次跑的时候下载下来的文件只有几 KB用播放器打开直接报错。排查了半天才发现是请求头缺失导致的。抖音的 CDN 对User-Agent和Referer有校验如果你用默认的 curl 头去请求服务器会返回一个错误页面而不是视频流。解决办法是手动带上这两个头curl -L \ -H User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) \ -H Referer: https://www.douyin.com/ \ -o video_${VIDEO_ID}.mp4 $NO_WM_URLUser-Agent伪装成常见浏览器Referer告诉服务器你是从站内跳过来的。这两个头加上之后下载成功率直接从三成提到了九成以上。这个坑非常典型很多教程只给地址不给头新手照着做必然失败。3. 从单条到主页批量把循环和分页逻辑搭起来3.1 主页视频列表的获取与分页游标单条下载只是热身真正的效率提升在于批量抓取作者主页。抖音的作者主页视频列表是通过接口分页返回的每一页会带一个max_cursor游标下一页请求时把这个游标带上就能继续往下翻。这个机制和很多内容平台的列表接口是一个套路。命令行里我一般先用curl请求第一页把返回的 JSON 存下来然后用jq提取视频 ID 列表和下一页游标# 请求主页第一页 curl -s https://www.douyin.com/aweme/v1/web/aweme/post/?sec_user_id${SEC_UID}count20max_cursor0 \ -H User-Agent: Mozilla/5.0 \ -H Referer: https://www.douyin.com/ \ -o page_1.json # 提取视频 ID 列表 jq -r .aweme_list[].aweme_id page_1.json # 提取下一页游标 jq -r .max_cursor page_1.jsonjq是处理 JSON 的神器-r表示输出原始字符串而不是带引号的 JSON 值。aweme_list是视频数组aweme_id就是每条视频的唯一 ID。拿到游标后把它传给下一次请求循环往复直到has_more变成 0。这里有个细节sec_user_id是作者的加密 ID不是那个昵称。获取方式是打开作者主页从 URL 里找sec_uid后面的那串字符。这个 ID 是批量任务的入口拿错了就抓不到任何数据。3.2 用 while 循环串起分页与下载的完整链路把分页和下载串起来核心就是一个while循环。我习惯把游标作为循环条件只要它不为 0 就继续翻页CURSOR0 PAGE1 while [ $CURSOR ! 0 ] || [ $PAGE -eq 1 ]; do curl -s https://www.douyin.com/aweme/v1/web/aweme/post/?sec_user_id${SEC_UID}count20max_cursor${CURSOR} \ -H User-Agent: Mozilla/5.0 \ -H Referer: https://www.douyin.com/ \ -o page_${PAGE}.json jq -r .aweme_list[].aweme_id page_${PAGE}.json all_ids.txt CURSOR$(jq -r .max_cursor page_${PAGE}.json) PAGE$((PAGE 1)) sleep 2 done这段脚本做了四件事请求当前页、提取 ID 追加到总列表、更新游标、页码加一。sleep 2是我强烈建议加的每次请求之间停顿两秒既降低被限流的概率也给服务器一点喘息空间。我试过不加 sleep 连续请求跑到第三页就开始返回空数据了。all_ids.txt里最终会存下这个作者所有视频的 ID一行一个。有了这个列表下载阶段就变成了纯粹的遍历和单条下载的逻辑完全一致。3.3 批量下载时的并发控制与限速策略拿到 ID 列表后最朴素的做法是for循环一条条下。但如果你有几百条视频串行下载会慢到让人抓狂。这时候可以考虑并发但并发数一定要控制我一般不超过 3 个同时进行。用xargs可以很方便地做并发控制cat all_ids.txt | xargs -P 3 -I {} bash -c VID{} URLhttps://www.douyin.com/aweme/v1/play/?video_id${VID}ratio1080pline0 curl -L -H User-Agent: Mozilla/5.0 -H Referer: https://www.douyin.com/ \ -o videos/${VID}.mp4 $URL sleep 1 -P 3表示最多 3 个并发进程-I {}把每个 ID 替换到命令里。每个下载任务结束后sleep 1进一步降低压力。实测下来3 并发加 1 秒间隔几百条视频跑下来基本不会触发限流速度也比纯串行快了两三倍。提示并发数不是越高越好。我试过-P 10结果一半的请求返回 403反而要重跑。稳定比快更重要。4. 文件命名、去重与断点续传批量任务的三个隐形坑4.1 用视频描述做文件名时的字符清洗下载下来的文件如果全叫1234567890.mp4过两天你根本不知道哪条是哪条。我习惯用视频的描述文字做文件名但描述里经常带表情符号、斜杠、问号这些文件系统不接受的字符直接拿来命名会报错。清洗的办法是用sed把非法字符替换掉SAFE_NAME$(echo $DESC | sed s#[/\\:*?|]#_#g | cut -c1-50)sed里把斜杠、反斜杠、冒号、星号、问号、引号、尖括号、竖线这些统统换成下划线cut -c1-50截取前 50 个字符避免文件名过长。这样处理之后文件名既保留了可读性又不会触发系统限制。我一般会把文件名拼成日期_描述_ID.mp4的格式日期用date %Y%m%d生成。这样按文件名排序就是按时间排序找起来特别方便。4.2 重复下载的判定先查文件再请求批量任务跑第二遍的时候如果不去重会把已经下过的视频再下一遍白白浪费时间和流量。最简单的去重逻辑是下载前先检查文件是否存在if [ -f videos/${VID}.mp4 ]; then echo 已存在跳过${VID} continue fi这一句-f判断就能省掉大量重复请求。如果你用的是描述做文件名那就判断描述对应的文件是否存在。我建议把 ID 作为文件名的核心部分因为描述可能重复ID 永远唯一。更严谨一点的做法是记录一个downloaded.txt每下完一条就追加一行 ID下次跑之前先grep一下。两种方式我都用过文件存在性判断更简单日志记录更适合需要统计的场景。4.3 断点续传curl 的 -C 参数怎么用才对批量下载最怕跑到一半网络断了重新跑又得从头来。curl的-C -参数支持断点续传它会自动从文件已下载的字节位置继续curl -L -C - -o videos/${VID}.mp4 $URL-C -的意思是自动检测已下载部分并从断点继续。但这里有个坑如果服务器不支持 Range 请求断点续传会失败curl 会报错。我遇到这种情况时的处理办法是先删掉那个不完整的文件重新完整下载一次。另外断点续传要求文件名必须一致所以你的命名规则要稳定不能这次用描述、下次用 ID否则 curl 找不到对应的半成品文件。5. 三条核心命令的完整拆解与参数逐行注释5.1 命令一链接解析与视频 ID 提取第一条命令负责把各种形态的链接统一转换成视频 ID。这是整个流程的入口也是最容易出错的地方。# 输入任意形态的抖音视频链接 # 输出纯数字视频 ID RAW_URL$1 # 如果是短链先跟随重定向 if [[ $RAW_URL *v.douyin.com* ]]; then RAW_URL$(curl -sIL $RAW_URL | grep -i ^location | tail -1 | awk {print $2} | tr -d \r) fi # 从完整链接中提取视频 ID VIDEO_ID$(echo $RAW_URL | grep -oE (modal_id|/video/)[0-9]{15,20} | grep -oE [0-9]{15,20} | head -1) echo $VIDEO_ID逐行看curl -sIL拿重定向头grep -i ^location找跳转地址tail -1取最后一个因为可能有多级跳转awk {print $2}取地址部分tr -d \r去掉 Windows 换行符。后面用正则匹配modal_id或/video/后面的数字串head -1确保只取第一个匹配。这个函数我封装成了get_vid.sh后面所有脚本都调它。好处是链接形态再怎么变只要改这一个地方就行。5.2 命令二无水印地址构造与下载第二条命令负责把 ID 变成实际可下载的无水印地址并完成下载。# 输入视频 ID # 输出本地 mp4 文件 VID$1 OUT_DIR${2:-./videos} mkdir -p $OUT_DIR # 构造播放接口地址 PLAY_URLhttps://www.douyin.com/aweme/v1/play/?video_id${VID}ratio1080pline0 # 替换为无水印版本 NO_WM_URL$(echo $PLAY_URL | sed s/playwm/play/g) # 下载 curl -L \ -H User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ -H Referer: https://www.douyin.com/ \ -C - \ -o ${OUT_DIR}/${VID}.mp4 \ $NO_WM_URLratio1080p指定清晰度line0是线路选择。mkdir -p确保输出目录存在不存在就创建。-C -支持断点续传。整个命令跑完videos/目录下就会多一个以 ID 命名的 mp4 文件。我实测发现ratio参数有时候不生效返回的还是默认清晰度。如果你对画质有要求可以尝试把ratio换成720p或origin多试几次看哪个返回的文件更大。5.3 命令三主页批量调度与循环控制第三条命令是调度器把前两条命令串起来加上分页循环和并发控制。#!/bin/bash # 输入作者 sec_user_id # 输出该作者主页所有视频 SEC_UID$1 OUT_DIR${2:-./videos} mkdir -p $OUT_DIR CURSOR0 PAGE1 all_ids.txt # 阶段一抓取所有视频 ID while true; do curl -s https://www.douyin.com/aweme/v1/web/aweme/post/?sec_user_id${SEC_UID}count20max_cursor${CURSOR} \ -H User-Agent: Mozilla/5.0 \ -H Referer: https://www.douyin.com/ \ -o page_${PAGE}.json COUNT$(jq -r .aweme_list | length page_${PAGE}.json) if [ $COUNT -eq 0 ]; then break; fi jq -r .aweme_list[].aweme_id page_${PAGE}.json all_ids.txt CURSOR$(jq -r .max_cursor page_${PAGE}.json) PAGE$((PAGE 1)) sleep 2 done # 阶段二并发下载 cat all_ids.txt | xargs -P 3 -I {} bash -c VID{} if [ -f $OUT_DIR/${VID}.mp4 ]; then exit 0; fi URLhttps://www.douyin.com/aweme/v1/play/?video_id${VID}ratio1080pline0 curl -L -H User-Agent: Mozilla/5.0 -H Referer: https://www.douyin.com/ \ -C - -o $OUT_DIR/${VID}.mp4 $URL sleep 1 echo 完成共下载 $(ls -1 $OUT_DIR | wc -l) 个文件这个脚本分两个阶段先翻页收集所有 ID再并发下载。while true配合COUNT -eq 0的退出条件比判断游标更可靠因为有些情况下游标不会归零但列表已经空了。xargs里的if [ -f ... ]做去重已经存在的文件直接跳过。注意脚本里的$OUT_DIR这种写法是为了把外层变量传进bash -c的内层引号嵌套比较绕但实测能正常工作。如果你觉得难读可以把下载逻辑单独写成一个脚本文件用xargs调用那个文件会清爽很多。6. 实测中遇到的限流、空数据与格式异常处理6.1 请求频率过高导致的空返回批量任务跑到一定量级最常见的现象是接口开始返回空列表。这不是你的脚本写错了而是触发了频率限制。我做过对比测试不加 sleep 连续请求平均第 3 到第 5 页就会开始返回空数据加上 2 秒间隔后连续翻 20 页都没问题。如果你已经遇到了空返回处理办法是暂停几分钟再继续同时把 sleep 时间调大。我一般会把翻页间隔设成 3 到 5 秒下载并发降到 2虽然慢一点但能保证跑完。还有一种情况是返回了数据但aweme_list是空的这时候检查一下status_code字段如果是非 0 的值说明请求被拒绝了需要换请求头或者等一会儿再试。6.2 下载文件损坏的排查链路下载下来的 mp4 打不开或者只有几 KB排查顺序我总结成这样现象可能原因排查方法文件几 KB请求头缺失检查 User-Agent 和 Referer文件几 MB 但打不开地址被替换成错误页面用file命令看文件类型部分文件正常部分损坏并发过高被限流降低并发数重试全部失败视频 ID 提取错误打印 ID 确认格式file命令很好用file video.mp4如果返回的是HTML document而不是MPEG-4说明你下到的是错误页面不是视频。这时候重点检查请求头和地址构造逻辑。我踩过最深的一个坑是某次接口调整后playwm替换成play不再生效下载下来的全是带水印的。后来发现需要额外加一个watermark0的参数。所以定期验证下载结果很重要别跑了几百条才发现全带水印。6.3 文件名冲突与特殊字符的兜底方案用描述做文件名虽然可读但描述重复的情况不少见尤其是同一个作者发系列内容时。我的兜底方案是文件名里始终保留 ID格式为描述前30字_ID.mp4。这样即使描述完全一样ID 也能保证唯一。特殊字符方面除了前面说的sed清洗还要注意文件名长度限制。Linux 下单文件名字节数上限通常是 255中文一个字占 3 字节所以描述截取 50 个字符是安全的。如果你在 Windows 上跑还要避开CON、PRN、AUX这些保留名虽然概率很低但真遇到会直接报错。7. 把这套流程固化成可复用脚本的几个经验7.1 参数化设计让脚本适配不同作者和清晰度一开始我把sec_user_id和清晰度写死在脚本里每次换个作者就要改代码很麻烦。后来改成用位置参数和默认值SEC_UID${1:?请提供 sec_user_id} OUT_DIR${2:-./videos} RATIO${3:-1080p}${1:?提示信息}的写法在参数缺失时会直接报错并打印提示比默默跑出错误结果好得多。${2:-./videos}表示第二个参数不传就用默认值。这样一条命令就能适配不同作者、不同输出目录、不同清晰度。我还加了一个--dry-run选项只打印将要下载的 ID 列表而不实际下载用来预览任务规模。这个功能在抓新作者时特别有用先看看有多少条心里有数再决定要不要全下。7.2 日志记录出问题时知道哪一步断了批量任务跑久了没有日志就是抓瞎。我在脚本里加了简单的日志输出LOG_FILEdownload_$(date %Y%m%d_%H%M%S).log exec (tee -a $LOG_FILE) 21exec这行把标准输出和标准错误同时写到终端和日志文件tee -a是追加模式。这样跑完之后日志里能看到每一条的下载状态、耗时、失败原因。我一般会统计成功数和失败数失败的 ID 单独存一个文件下次只重跑失败的。日志文件名带时间戳避免多次运行互相覆盖。这个习惯是从跑大规模任务时养成的有一次跑了 500 条中间断了全靠日志定位到是从第 237 条开始失败的。7.3 定期维护接口变化时的快速定位方法这类脚本最大的维护成本是接口会变。我的应对策略是把所有和接口相关的 URL、参数、请求头集中放在脚本开头的变量区一旦发现下载失败只需要改那几行不用翻遍整个脚本。定位问题的流程我固定成三步先用curl手动请求一次接口看返回的 JSON 结构有没有变再用jq检查关键字段是否还在最后对比新旧返回找出变化的字段名。整个过程通常十分钟内能搞定。另外我会在脚本里加一个自检命令跑之前先请求一条已知的视频确认能正常下载再开始批量。这个自检能挡掉大部分接口已变但你没发现的情况避免白跑几百条。这套命令行方案我从最初的三条命令慢慢迭代到现在带日志、带自检、带断点续传的完整脚本前后大概跑了上千条视频。它不是什么高深技术核心就是把重复劳动交给循环把稳定性交给参数控制。如果你也在做类似的内容归档建议先从单条跑通再逐步加批量、加并发、加日志每一步都验证过再往下走比一上来就写个大脚本然后到处报错要省心得多。
