返回博客

构建实录 / 2026-06-15

从一个报错到一个服务:短视频解析的完整工程拆解

Kosen Video Parser 解析链路示意

这是一个把短视频「拆」成结构化数据的解析服务:丢一条抖音或视频号分享链接进去,异步处理完,回一份标题、文案、封面、去水印视频、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(正在修)——两个必须先堵的洞:

  • SSRFcallback_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 这张清单本身也是——一个脚本不会有"已知安全边界待办",一个服务必须有。