构建实录 / 2026-06-15
从一个报错到一个服务:短视频解析的完整工程拆解

这是一个把短视频「拆」成结构化数据的解析服务:丢一条抖音或视频号分享链接进去,异步处理完,回一份标题、文案、封面、去水印视频、SRT 字幕、逐字转写和整理好的 Markdown,打成 ZIP。
注意是服务不是脚本——有鉴权、有计费、有队列、有降级、有回调。这篇按一次请求的生命周期,把里面的工程决策逐层拆开。先从一个报错讲起,它定义了整个项目的对手。
起点:error_code=300012
做这个项目的头几天,这串数字见得比谁都多。它是抖音风控的回应:匿名去拉视频,请求被重定向到验证页,什么都拿不到。换 cookies、降频率、换出口,各有各的失效方式。
要做的事说起来简单:一条链接进,一整包能直接二次创作的素材出。做内容工作流的人都懂,这套东西手工弄一条要十几分钟,纯体力活。但平台不想让你顺利拿到——这才是整个项目真正的对手,也是"脚本"和"服务"的分界线:脚本处理正常情况,服务处理平台跟你作对的情况。
最重要的决策:只有元数据是硬失败点
被 300012 教育几轮后,我做了这个项目里最重要的决定:重新定义"成功"。
直觉是"下到视频才算成功"。但下载偏偏是最脆弱的环节——cookies 会过期、风控会升级、视频号的视频流干脆是加密的。如果下载失败等于整单失败,服务的可用性就绑死在平台风控的心情上。
新的状态机长这样:
PENDING → PROCESSING → { SUCCESS | PARTIAL_SUCCESS | FAILED }
- 拿到标题和文案 = 可用,记
PARTIAL_SUCCESS - 视频、封面、字幕全是"增强能力",拿到更好,拿不到不降级为失败
- 只有连元数据都取不到,才是
FAILED
这一条写进了 PRD 当红线。理由很简单:大部分场景里文案才是核心价值,视频拿不到只是少个附件。把"部分成功"当失败,会让一堆本可用的结果被白白丢掉。整个 pipeline 因此只有一个硬失败点,其余每步失败只记 last_warning 继续走。
一次请求的生命周期:八阶段 pipeline
栈很朴素:FastAPI + Redis + arq(异步任务队列)+ 适配器模式。进程拆两条线:
- api 进程:只做校验、鉴权、入队、查询、记账,不碰任何解析业务。发链接进来,落一条记录、扔进 Redis 队列,立刻返回
job_id。 - worker 进程:从队列取任务,跑完整 pipeline。
为什么必须异步:单条任务耗时秒级到分钟级(下载 + whisper 转写 + 上传云)。同步处理会把接口长时间占住,稍并发就崩。入队即返回、后台消费、可带 webhook 回调。两个进程靠 Redis 队列解耦、靠共享 SQLite 同步状态。
worker 的 process_job 分八个阶段,每段单独计时落日志:
1. metadata ← 唯一硬失败点:异常即 FAILED 并回调,避免卡在 processing
2. video ← 增强能力,失败不中断(last_warning 记原因)
3. cover ← 优先平台封面;有视频则 ffmpeg 截帧兜底
4. subtitle ← 仅当有视频:ffmpeg 抽音频 → faster-whisper 转写 srt + txt
5. r2 ← 视频/封面/文案并发上传 R2(单文件内再分片多线程)
6. 落库 ← SUCCESS(有视频)/ PARTIAL_SUCCESS(无视频)
7. 计费 ← 仅 SUCCESS 且有 customer_id 才 record_charge
8. 回调 ← 带 callback_url 则 POST,JSON 与 GET 查询接口同构
有个连锁结论要记牢:字幕依赖视频(无视频 → 无音频 → 无 whisper)。所以 PARTIAL_SUCCESS 交付的是标题 + 文案 + 封面,不含字幕。这点在计费口径上要提前想清楚。
平台适配:一个接口,两种实现
平台逻辑全部收口在适配器里,一个平台一个文件,实现同一个抽象接口:
class VideoAdapter(ABC):
async def fetch_metadata(self) -> VideoMetadata: ... # 失败抛 MediaFetchError
async def fetch_video(self, dest) -> Path | None: ... # 拿不到返回 None,不抛
async def fetch_cover(self, dest) -> Path | None: ...
签名本身就编码了降级哲学:fetch_metadata 失败抛异常(硬失败点),fetch_video 拿不到返回 None(软降级)。worker 完全不关心是抖音还是视频号,按接口调即可,基类还带 tikhub_calls 计数供计费。平台识别用正则从分享文案里抠出第一个链接,按域名判定路由。
抖音是双通道:主路径 yt-dlp + 浏览器导出的 cookies,成本为零;但登录态过期会被重定向到验证页(又是 300012),此时自动切 TikHub 付费接口兜底——懒加载、整次解析最多调一次、把调用次数记在任务上。平时省钱,风控来了保命。而且兜底下载按 host 去重、分散到不同 CDN 节点,避免慢链路超时。
视频号:视频是加密的
抖音相对直接,视频号才是硬骨头——视频流是加密的 mp4,直接下下来是一堆解不开的字节,没有公开接口。
方案是开源解密方案 + 第三方数据接口配合:从 TikHub 拿到加密流地址和 decode_key,下载加密流,再丢给一个自建的解密 sidecar 还原成正常 mp4,才进后续转写/上传。这个容器又拖 Node 又拖 Chromium,镜像一点八个 G,重是重,但换来的是不把命脉压在某个第三方解密服务的存活上——自建 sidecar 的 POOL_SIZE 我自己控。成本上抖音基本免费,视频号一条大概一分钱多。
字幕:为什么自己跑 whisper,而不是抓平台字幕
我没抓平台自带字幕,而是把视频声音扒出来、用 whisper 从头识别:
ffmpeg -i video -vn -ar 16000 -ac 1 out.wav # 16kHz 单声道,whisper 标准输入
→ faster-whisper(small, CPU, int8 量化, language=zh, vad_filter)
→ srt(带时间轴)+ txt(纯口播文案)
三个理由:平台字幕覆盖率太低,很多视频根本没有,自己识别才能保证任何视频都有文案;faster-whisper 走 CTranslate2 后端,int8 量化能在纯 CPU 上跑,不强依赖 GPU;代价是慢,这是当前规模下我接受的取舍。
并发与吞吐:一个被测出来的数字
worker 名义 max_jobs=10,实际有效吞吐只有 3–4。原因链条要说清楚:
whisper 转写用 asyncio.to_thread 卸载到线程——CTranslate2 计算时会释放 GIL,所以转写不阻塞事件循环,别的 job 的 IO 阶段能继续跑。但模型是进程内单例,多个 job 同时进转写阶段,抢的是同一个模型实例;视频号还额外受解密 sidecar 的 POOL_SIZE 限制。所以 max_jobs=10 只是队列并发度,真正的转写吞吐卡在单例上。这个数字是压出来的,不是设出来的——也是"要不要上 GPU"的判断依据:访问量没到那个点,就先不上。
一个具体的性能坑:上传从几分钟压到十几秒
产物传对象存储(Cloudflare R2,S3 兼容)。一开始单流上传,国内服务器 → R2 是国际链路,一个几十兆的视频传三五分钟还经常断。
解法是 boto3 的分片 + 多线程并发:
TransferConfig(
multipart_threshold=8 * 1024 * 1024, # >8MB 走分片
multipart_chunksize=8 * 1024 * 1024,
max_concurrency=8, # 单文件内 8 路并发
use_threads=True,
)
再叠加 worker 层面多文件之间也并发(视频/封面/文案同时传),整体从几分钟干到十几秒。结论:跨境慢链路,单流是原罪,多流翻好几倍。
存储与分发:不开一个公网入站端口
对外姿势值得单说——服务器不开任何公网入站端口:
- API 走 Cloudflare Tunnel 纯出站打通,服务器裸端口只绑
127.0.0.1给同机服务调 - 下载走 R2 公网自定义域,产物直接从 CDN 出,不占服务器一点带宽
一举挡掉两个烦恼:被扫描攻击,和带宽被下载打满。整套用 Docker compose 跑五个服务:api、worker、redis、cloudflared、解密 sidecar。
计费与去重:账本 + 唯一索引
对外按量计费用后付费记账,两个关键设计:
- 每客户每链接只扣一次:靠
(customer_id, source_url)的唯一索引兜底,重复提交被数据库直接挡掉,不用在应用层写判重 - 本地缓存复用:同一链接库里抓过就返历史结果,不重新下载、不再调付费接口——省钱又防刷
两级鉴权:管理员 key 和客户 key,中间件 require_api_key 统一拦。
关键技术决策与取舍
| 决策 | 取舍 |
|---|---|
| SQLite 而非 Postgres | 单机零运维、api/worker 共享文件;代价是写并发受限,是当前规模上限 |
| Redis + arq 队列 | 保留架构、为批量留路;当前流量其实单机够用 |
| 视频是「增强能力」 | 解密/下载失败降级 PARTIAL 而非失败,保住核心交付 |
| 抖音本地优先 + TikHub 兜底 | 优先免费 yt-dlp,cookies 失效自动兜底,可用性换成本 |
| 视频号自建解密 sidecar | 不依赖第三方解密服务,但镜像重(Node+Chromium ~1.8G) |
| Cloudflare Tunnel + R2 双域名 | 不开公网入站、下载走 CDN 免流;依赖 Cloudflare |
坑的清单
抖音视频"有声音没画面":HEVC 编码播放器不认,yt-dlp 的 format 优先选 H.264。
worker 首启卡死在下模型:faster-whisper 从 HuggingFace 拉模型走国际线路,国内服务器纹丝不动。设 HF_ENDPOINT 指国内镜像 + HF_HUB_DISABLE_XET=1,缺一个都卡。
SQLite 偶发 database is locked:api 和 worker 两进程写同一库文件,写并发一撞就锁。连接超时放宽到 30 秒缓解,但这是架构天花板。
部署传单文件导致 ImportError:旧代码漂移。教训写进部署纪律——每次全量同步整个 app 目录再构建。
部署状态与演进路线
当前部署在自有服务器(CentOS 7、15G 内存、郑州国内出口 IP——抖音风控友好),Docker 26 + Compose v2,五容器编排。服务能用,但我清楚它的边界,按优先级排了路线:
P0(正在修)——两个必须先堵的洞:
- SSRF:
callback_url客户可控,worker 会向其 POST,可被指向内网服务或云元数据端点,得校验拒绝私网主机; - 计费成本倒挂:视频号
PARTIAL_SUCCESS一律计费 0,但那次 TikHub 的钱已经花了,得对"调用了 TikHub 的 partial"按成本计费。
P1——上量前的伸缩与安全:SQLite 写并发天花板(迁 Postgres,/api/stats 改 SQL 聚合而非全量拉内存);登录层未真正隔离(/api/login 把真实 key 下发到浏览器 localStorage,应改发短期只读 session token);元数据阶段无重试(瞬时 5xx/超时应走 arq retry);无配额告警(cookies 静默过期后流量全转付费兜底,等于慢性烧钱)。
P2——去重只认原始 URL(同视频不同短链会重复计费,应改"平台 + 作品 id"规范化键);扁平定价不看时长;/download 与 R2 公网地址无鉴权(job_id 可枚举)。
写在最后
做完最大的感受:很多"得付费买"的工具,拆开看没那么神秘——认链接、下视频、转文字、存文件,每层都有现成轮子。难的从来不是单点技术,而是把它们稳稳串起来,以及伺候好那些"大部分时候没事、偶尔出幺蛾子"的边角:风控、加密、慢链路、并发锁、成本倒挂。
这些边角,才是脚本和服务的区别。P0/P1/P2 这张清单本身也是——一个脚本不会有"已知安全边界待办",一个服务必须有。