一篇关于「让模型读懂一条视频」的踩坑记录。代码开源:github.com/taoyao-dev/agent-senses
前言:一个很蠢的起点
我给模型发了一条抖音链接,它试了半小时,一个字也没读出来。
不是它笨。它能读 mp4,但视频里没有字幕轨,它没有耳朵也没有眼睛------能拿到文件,不等于读懂内容。
于是我想写个东西:一个链接丢进去,返回文案、作者、互动数、逐句转写。做成 MCP 服务,Claude Code 挂上就能调。
写的过程中踩了四个坑,有一个差点让我把别人的数据写进去。记下来。
一、先要分清:这是「下载器」还是「理解器」
市面上做抖音解析的开源项目不少,大多是下载器:给个链接,返回一个 mp4。
但 agent 要的不是文件,是内容。文件只是中间产物。所以设计目标从一开始就不是「下得下来」,而是「读得懂」:
链接 → 元数据(结构化) → 音频 → 转写(逐句带时间戳) → 一个对象
顺带一个反直觉的发现:yt-dlp 在抖音这条路上其实不好走。它需要新鲜的 cookie,而现在的 Chromium 系浏览器把 cookie 用 App-Bound 加密了,程序读不出来。绕开 cookie 反而是更短的路。
二、整体链路
scss
短链 ──302──> video_id ──Playwright(系统Chrome)──> 媒体流URL + detail接口JSON
│
带 Referer 下载 → ffmpeg 合流 → ASR
用 Playwright 驱动系统里已经装好的 Chrome (channel="chrome"),不用再下一份 chromium,启动快、指纹也更像真人。然后在页面加载过程中挂一个 on_response 钩子,既收元数据也收媒体地址:
python
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.2万」这种四舍五入
- 前端一改版就崩
改成拦接口以后,/aweme/v1/web/aweme/detail/ 直接返回结构化的 aweme_detail,字段全、数字是原值、不怕改版。
但 detail 接口不是每次都会响------页面复用了前端缓存的时候请求不会重放。这时候整页 reload() 一次再等,通常能逼出来。
四、坑二(重点):播放页会轮播「下一条」
这个是差点写错数据的那个。
思路很自然:接口拿到了就用接口的,接口缺字段就用 DOM 补。听起来没毛病,直到我发现------抖音的播放页会自己往下轮播。等我的钩子收完数据、再去 DOM 抠的时候,页面可能已经滑到了下一条视频。
于是「DOM 兜底」补进来的标题和作者,是别人的。
我印象很深的一个例子:一个熊出没的 id,打开来 DOM 显示的是另一个电影的名字。而且这个错误不会报异常、不会为空------它安安静静地给你一份看起来很正常、但属于别人的数据。这种 bug 比崩了可怕得多。
两层防御,都很朴素:
第一层,接口这头按 id 校验。 post、related 这些接口里装的是一堆别人的视频,不校验就会把隔壁的当成这条:
python
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 补字段:
python
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:
python
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 合流------省一次转码,也省掉合流失败的一整类错误:
python
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 外壳分开
python
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:
MCP 服务,Claude Code 直接挂:
bash
claude mcp add agent-senses --scope user -- python "<本目录>/server.py"
它现在有两副「器官」:读抖音,和搜/抓网页。我想做的事其实就一句话------模型不缺脑子,缺感官。中文内容平台这块,大厂不屑做、老外做不了,偏偏每个用中文的 agent 都缺。
如果你也在做类似的东西,评论区聊聊你踩的坑。尤其是「接口和 DOM 打架」这类问题,我挺好奇别人怎么处理的。