做抖音解析时,我差点把别人的视频数据写进去

126 阅读6分钟

一篇关于「让模型读懂一条视频」的踩坑记录。代码开源:github.com/taoyao-dev/agent-senses


前言:一个很蠢的起点

我给模型发了一条抖音链接,它试了半小时,一个字也没读出来。

不是它笨。它能读 mp4,但视频里没有字幕轨,它没有耳朵也没有眼睛——能拿到文件,不等于读懂内容。

于是我想写个东西:一个链接丢进去,返回文案、作者、互动数、逐句转写。做成 MCP 服务,Claude Code 挂上就能调。

写的过程中踩了四个坑,有一个差点让我把别人的数据写进去。记下来。


一、先要分清:这是「下载器」还是「理解器」

市面上做抖音解析的开源项目不少,大多是下载器:给个链接,返回一个 mp4。

但 agent 要的不是文件,是内容。文件只是中间产物。所以设计目标从一开始就不是「下得下来」,而是「读得懂」:

链接 → 元数据(结构化) → 音频 → 转写(逐句带时间戳) → 一个对象

顺带一个反直觉的发现:yt-dlp 在抖音这条路上其实不好走。它需要新鲜的 cookie,而现在的 Chromium 系浏览器把 cookie 用 App-Bound 加密了,程序读不出来。绕开 cookie 反而是更短的路。


二、整体链路

短链 ──302──> video_id ──Playwright(系统Chrome)──> 媒体流URL + detail接口JSON
                                                      │
                                          带 Referer 下载 → ffmpeg 合流 → ASR

用 Playwright 驱动系统里已经装好的 Chrome(channel="chrome"),不用再下一份 chromium,启动快、指纹也更像真人。然后在页面加载过程中挂一个 on_response 钩子,既收元数据也收媒体地址:

def on_response(resp):
    u = resp.url
    if not detail and any(k in u for k in _AWEME_API):
        ad = _pick_aweme(resp.json(), video_id)
        if ad:
            detail.update(ad)
    elif "/video/tos/" in u and "video" not in streams:
        streams["video"] = u

下面四个坑,都是在这个钩子上磨出来的。


三、坑一:别从 DOM 抠文本

第一版是解析播放页的 DOM,[data-e2e="video-desc"] 抓标题、.account-name 抓作者。能跑,但有两个问题:

  1. 拿不到精确互动数(点赞/评论/收藏),页面上显示的是「1.2万」这种四舍五入
  2. 前端一改版就崩

改成拦接口以后,/aweme/v1/web/aweme/detail/ 直接返回结构化的 aweme_detail,字段全、数字是原值、不怕改版。

但 detail 接口不是每次都会响——页面复用了前端缓存的时候请求不会重放。这时候整页 reload() 一次再等,通常能逼出来。


四、坑二(重点):播放页会轮播「下一条」

这个是差点写错数据的那个。

思路很自然:接口拿到了就用接口的,接口缺字段就用 DOM 补。听起来没毛病,直到我发现——抖音的播放页会自己往下轮播。等我的钩子收完数据、再去 DOM 抠的时候,页面可能已经滑到了下一条视频。

于是「DOM 兜底」补进来的标题和作者,是别人的。

我印象很深的一个例子:一个熊出没的 id,打开来 DOM 显示的是另一个电影的名字。而且这个错误不会报异常、不会为空——它安安静静地给你一份看起来很正常、但属于别人的数据。这种 bug 比崩了可怕得多。

两层防御,都很朴素:

第一层,接口这头按 id 校验。 post、related 这些接口里装的是一堆别人的视频,不校验就会把隔壁的当成这条:

def _pick_aweme(j, video_id):
    """从接口响应里捞出「就是这条视频」的 aweme 对象。"""
    ad = j.get("aweme_detail")
    if isinstance(ad, dict):
        return ad if str(ad.get("aweme_id")) == video_id else None
    for key in ("aweme_list", "data"):
        for x in j.get(key) or []:
            if str(x.get("aweme_id")) == video_id:
                return x
    return None

第二层,DOM 这头做一致性比对。 只有当 DOM 里那段文案和接口返回的文案开头一致,才允许用 DOM 补字段:

desc_api = detail.get("desc") or ""
desc_dom = dom.get("desc") or ""
same_video = bool(desc_api) and (not desc_dom or desc_dom[:15] == desc_api[:15])

item.author = (detail.get("author") or {}).get("nickname") or ""
if not item.author and same_video:      # 只有确认是同一条,才敢用 DOM
    item.author = dom.get("author", "")

宁可字段为空,也不要填错。 空值调用方一眼能看出来,错值会被当成真的用下去。


五、坑三:whisper 的中文会在简繁之间跳

faster-whisper 转中文,同一条音频里能出现「吃點東西」和「宝头强」并存——繁体简体混着来。

第一反应是喂 initial_prompt 引导它输出简体。实测没用,反而会让别的词念错。 这条路放弃了。

最后是事后转一遍,用 opencc 的 t2s:

try:
    from opencc import OpenCC
    _S2T = OpenCC("t2s")
except Exception:          # 没装就原样返回,不阻断转写
    _S2T = None

def to_simplified(text: str) -> str:
    return _S2T.convert(text) if _S2T else text

顺带说清楚定位:这是速记,不是精校。人名、专有名词、口音重的地方都会错。引用前自己核对——README 里也是这么写的,别拿它当字幕。


六、坑四:抖音有两套媒体流

同一个站,两种播放器:

  • 新版:直接托管合成好的 mp4,路径里带 /video/tos/,自带音轨
  • 旧版:DASH 拆开,/media-video 和 /media-audio 两条

代码里两套都认,命中合成流就跳过 ffmpeg 合流——省一次转码,也省掉合流失败的一整类错误:

muxed = "/video/tos/" in streams["video"]
vpath = _download(streams["video"], wd / "video.mp4")
apath = vpath if muxed else _download(streams["audio"], wd / "audio.m4a")
...
full = vpath if muxed else media_mod.merge(vpath, apath, wd / "full.mp4")

还有个隐性的坑:分片 URL 带时效签名,抓到手里必须立刻下。隔几分钟再下就是 403。所以下载和抓取之间不能插任何耗时的活儿。


七、结构上:核心库和 MCP 外壳分开

def fetch(url, *, want_video=True, want_transcript=True) -> Item:
    ...

所有能力都在 senses/ 里(纯函数,不依赖 MCP),server.py 只是一层很薄的外壳,把 fetch 注册成 douyin_get 工具。

这么切的好处很实际:MCP 协议改了,或者以后想接别的 agent 框架(LangChain、自己的 CLI、定时任务),只换外壳,核心库一行不动。 顺便也让测试好写——测 senses 不需要起 MCP 服务。


八、边界,写在明面上

  • 只读公开内容,不碰登录态,不做任何账号操作
  • 只开源「看」的部分:解析、搜索、抓取、转写。「动手」的部分不开源——发帖、评论、读私信这些一旦公开风控细节,工具几天就死
  • 转写是速记不是精校,引用前核对
  • 现在只有抖音。B站、小红书还没做,接口形状不一样,慢慢加

九、最后

项目在 GitHub 上,MIT:

github.com/taoyao-dev/…

MCP 服务,Claude Code 直接挂:

claude mcp add agent-senses --scope user -- python "<本目录>/server.py"

它现在有两副「器官」:读抖音,和搜/抓网页。我想做的事其实就一句话——模型不缺脑子,缺感官。中文内容平台这块,大厂不屑做、老外做不了,偏偏每个用中文的 agent 都缺。

如果你也在做类似的东西,评论区聊聊你踩的坑。尤其是「接口和 DOM 打架」这类问题,我挺好奇别人怎么处理的。