在 Discord 贴 X 链接,为什么改个前缀视频就能播

发布于 · 3,125 字 · 约 8 分钟#Github 解读#Agent 抓取原文链接
在 Discord 贴 X 链接,为什么改个前缀视频就能播 封面图
  • FxEmbed 通过在 X 链接前加前缀(如 fxtwitter.com)返回塞满 OpenGraph 标签的 HTML,让 Discord 恢复视频、多图和投票预览
  • 利用 X 网页客户端公开的 guest bearer token 以游客身份调用 GraphQL 接口取数,并精细管理 token 缓存与随机 UA 伪装
  • 同一推文有五个取数入口,借用网页版、iOS、Android 三个官方客户端的配额池做加权调度与降级重试,摊薄限流压力
  • NSFW 等游客拿不到的数据依赖加密打包的真实账号代理池(elongator),这是自托管者最大的隐性成本
  • 单 Worker 按 Host 头路由七个 realm 服务十几个域名,支持多平台逆向接口;但翻译功能已坏 14 个月、搜索 API 下线、单人维护风险高

你大概率见过这个场景。有人在 Discord 群里贴了条 X 的链接,预览卡片出来了,标题在,作者在,视频没了。四张图的推文只给你看第一张,投票干脆整个消失。群里老人熟练地补一句,链接前面加个 fx 再发一遍。

卡片立刻换了副样子。视频直接内嵌播放,多图拼成一张网格,投票选项带着百分比整整齐齐。twitter.com 加 fx 变 fxtwitter.com,x.com 加 fixup 变 fixupx.com,bsky.app 加 fx 变 fxbsky.app,规则就这么简单。

这个「加前缀」背后,是一套跑了四年多的寄生系统。FxEmbed,今天 5574 Star、263 个 fork,MIT 协议,作者 dangered wolf 从 2022 年 7 月写下第一版完整实现维护到现在,我拆它源码的这天,线上跑的正是当天从 main 分支构建的版本。越往下拆越觉得,这是一份「平台封锁下怎么活下来」的工程标本。

它的全部魔法,只是一张 HTML

FxEmbed 是一个 Cloudflare Worker,整个服务没有数据库,没有队列,没有前端框架。

它对 Discord 做的事情说出来有点朴素,返回一页塞满 OpenGraph meta 标签的 HTML。

Discord 抓链接预览时,会派一个 User-Agent 带 Discordbot 的爬虫去请求 URL,读到的 og:title、og:image、twitter:player:stream 就决定了卡片长什么样。X 自己的页面给这套爬虫的数据越来越少,2023 年之后基本只剩标题和一张缩略图。FxEmbed 要做的,就是当 Discord 的爬虫来敲门时,替 X 把这页 HTML 写好。

关键的分流逻辑在 src/realms/twitter/routes/status.ts。代码里维护着一个覆盖 40 多个关键词的 BOT_UA_REGEX,bot、embed、crawl、spider、python、curl 全在名单上,命中就走 embed 渲染,没命中就 302 重定向回 x.com 原文。你想想看,人类用户根本不会停在 fxtwitter.com,他们只是不小心点开了一个机器人链接。

有意思的是名单里藏着 Firefox/92 这个精确到小版本号的特判。源码注释专门解释了,Discord 的部分爬虫会顶着 Firefox 92.0 的 UA 发请求,没人知道为什么,但必须兼容。这种「对方平台的怪癖当圣旨供着」的细节,全仓库到处都是。识别 Wayback Machine 靠的是请求头 Via 里带 archive.org,也顺手做了特判。

甚至报错都要迁就 Discord。src/worker.ts 的 onError 里,凡是命中嵌入客户端正则的请求,错误状态码会被强制改回 200,因为 Discord 会缓存非 200 响应对应的卡片,一个短暂的失败会让整条链接永久变成死卡。把错误包成 200 返回,坏卡片至少还有机会被重新抓一次。

数据从哪来,网页客户端的公开秘密

接下来的问题才是真正的硬骨头。渲染卡片需要推文数据,而 X 的官方 API 从 2023 年起收费,最低档每月也要上百美元,一个免费公益服务显然走不起这条路。

FxEmbed 的答案写在 src/constants.ts 里,一个硬编码的 GUEST_BEARER_TOKEN。

你猜怎么着,这串 token 不是谁泄露的机密,它就是 X 网页版客户端自己天天在用的公开凭证,任何人打开浏览器开发者工具都能抄下来。配上这串 token,再调 api.x.com/1.1/guest/activate.json 换一个游客身份,就能以未登录状态查询 GraphQL 接口。

packages/atmosphere/src/providers/twitter/fetch.ts 里的 twitterFetch 函数把游客身份管理做成了精细活。guest token 不缓存就白白浪费配额,但 Cloudflare 的 Cache API 按规矩不能缓存 POST 响应,工程师的解法是拿一个同 URL 的 GET 假请求当缓存键,把 POST 换来的 token 手动 cache.put 进去,缓存时长 GUEST_TOKEN_MAX_AGE 定为 3 小时。每个边缘节点各自缓存各自的,一套缓存把 guest token 的消耗压到了最低。

请求头也要演得像。packages/atmosphere/src/helpers/user-agent.ts 里的 generateUserAgent 每次随机拼一个 UA,Chrome 或 Edge 二选一,Windows、macOS、Linux、Android 四平台轮换,版本号在 145 到 143 之间浮动,连 sec-ch-ua 请求头都配套生成。X 的风控若盯着特征指纹,看到的永远是一群换着设备上网的普通用户。

配额快见底时它会自救。每次响应回来都读 x-rate-limit-remaining,低于 10 就主动把当前 token 从边缘缓存里删掉,下一轮换新 token 重新激活。

同一条推文,五个取数入口

guest token 能解决的问题终究有上限,未登录状态看不到 NSFW 推文,看不到完整评论串,单 token 配额也不经打。FxEmbed 的应对方案是我在这份源码里看到的最漂亮的设计。

X 的 GraphQL 接口按「查询名 + 客户端」维度分配限流,网页版的 TweetDetail 是一个池子,iPhone 客户端的 ConversationTimeline 是另一个池子,Android 的 TweetResultsByIds 又是一个池子。FxEmbed 干脆把三个客户端的请求样本全抓了下来,graphql/queries.ts 里躺着 30 多个带 queryId 的查询定义,每个都标注了来源平台的 feature flags 和 variables,连 Grok 相关的新特性开关都同步维护着。

graphql/orchestrator.ts 的调度器在这五个取推文的入口之间做加权随机,TweetDetail 权重 150,iOS 的 ConversationTimeline 权重 150,TweetResultByRestId 和两个 Android 批量接口各 500。选中的入口失败了,就按权重降级挨个试剩下的。ConversationTimeline 的定义处留着一句注释,从 Twitter iPhone 客户端抓取,限额比 TweetDetail 高。同一秒全球 Discord 用户的取数请求,被这套权重摊到了三个客户端的配额池里。

你想想这意味着什么。

X 想限制这类服务,光封一个接口没用,它面对的是一个把自己家三个官方客户端的身份都借去用的对手。

把整条决策流画出来会更直观,token 缓存、端点选择和账号代理升级,是同一条请求链上的三道关卡。

twitterFetch 请求决策流,token 缓存命中直通、未命中先激活,异常时三档重试并可升级账号代理
twitterFetch 请求决策流,token 缓存命中直通、未命中先激活,异常时三档重试并可升级账号代理

任何一环失手都不会直接报错,而是退回上一档调整参数重来,三次机会用尽才算真的失败。

账号池,真正的隐藏成本

有些数据游客身份永远拿不到,NSFW 推文会返回 reason 为 NsfwLoggedOut 的占位结果。twitterFetch 对此有一套自动升级,检测到这个 reason 就把本次请求切换到「账号代理」通道,用真实登录账号的 cookie 重新请求,三次重试的机会内完成降级回退。

这套账号通道在代码里有个历史名字,叫 elongator,2023 年 4 月就有了。如今它的实现是 src/providers/twitter/proxy/credentials.ts,一批登录账号的凭据在构建时用 AES-256-GCM 加密打包进 Worker bundle,运行时靠环境变量里的 CREDENTIAL_KEY 解密,每次请求从池子里随机抽一个账号,Bluesky 的账号还按 PDS 主机做了偏好分组,Fisher-Yates 洗牌保证负载均匀。配套的 npm scripts 提供了完整的工具链,encrypt、push、pull、presign 一应俱全,凭据池放在 S3 上管理。

坦白讲,这才是自托管者的真实门槛。README 把自托管写得三行命令就能跑,但公开游客通道拿不到的数据,都压在这批需要自己养的账号上。X 的封号成本,最终是这套系统唯一无法开源的部分。

不止 X,六个平台的私有接口图鉴

如果你以为它只修 X 和 Bluesky 的链接,那就低估这份代码了。这么说吧,README 写的是三件套 FxTwitter、FixupX、FxBluesky,但 src/realms/ 目录下躺着 twitter、bluesky、tiktok、instagram 四套路由,packages/atmosphere/src/providers/ 里还有 threads 和 mastodon。29244 行的 atmosphere 包(不含生成代码)是一个多云供应商抽象层,用统一的 transports 接口把「公开抓取、匿名代理、账号代理、中继」四种数据通道标准化了。

AGENTS.md 里记录的逆向细节密度高得惊人。Instagram 的接口指纹来自反编译的 com.instagram.android,Threads 的路由模板是从 com.instagram.barcelona 的 smali 里抠出来的,Threads 的账号池直接复用 Instagram 的凭据再换一个 app 指纹。TikTok 则是一篇小型失败史,Android API 的请求要过 X-Gorgon、X-Ladon、X-Argus 三道签名,签名逻辑在 libmetasec_ov.so 原生代码里,没签名的请求返回 200 加空 body,所以只能退回解析服务端渲染页面的 UNIVERSAL_DATA_FOR_REHYDRATION 数据岛。哪些接口能用、哪些彻底没戏,每个结论都在代码注释里留了证据。

FxEmbed 分层架构,单个 Worker 按 Host 头路由七个 realm,向下经 atmosphere 包对接六个平台的数据源
FxEmbed 分层架构,单个 Worker 按 Host 头路由七个 realm,向下经 atmosphere 包对接六个平台的数据源

翻译功能的三级流水线也值得一看。优先调 X 自家的 Grok 翻译接口 /2/grok/translation.json,把 X 内置的 AI 翻译白嫖过来;不行就走 Polyglot 中继服务;最后一级在 Cloudflare Workers AI 上跑 gpt-oss-120b,再兜底一个 m2m100-1.2b 传统翻译模型。一个公益服务把免费推理额度用到了这个份上。

一个 Worker 的七个分身

架构上最反直觉的一处,是这一大家子全跑在同一个 Worker 里。src/worker.ts 的 getPath 钩子按请求的 Host 头把流量路由到七个 realm,fxtwitter.com 走 twitter,api.fxtwitter.com 走 api,fxbsky.app 走 bluesky,各不干扰。作者在注释里专门解释了为什么叫 realm 而不是 domain,同一个域名的不同子域可以属于不同 realm,一个 Worker 就这样同时服务着十几张域名。

域名本身就是一套功能字典。d. 前缀直接返回媒体文件,t. 前缀纯文本卡片,i. 前缀强制 Telegram Instant View,g. 是画廊模式,m. 强制多图拼接,甚至 URL 以 .mp4 结尾也能触发直链。.env.example 里还躺着一批社区捐赠的域名,twittpr.com、wuff.gay、cocktiktok.com,各有各的生存故事。

branding.json 则把白标能力做成了配置。每张域名可以有自己的名字、favicon、主题色和全套图标,同一个代码库对外呈现为五个不同的产品。说真的,这种「一套内核多个皮肤」的克制,比很多创业公司的多品牌架构做得还干净。

四年老兵身上的旧伤

拆到这里必须泼点冷水。我实测了两个 issue 里的问题,都还在。

翻译功能坏了 14 个月。

issue #1387 从 2025 年 7 月开到现在,一堆人接力确认 Discord 和 Telegram 都不能用,我用 API 请求带 lang 参数验证,translation 字段确实返回空。代码里的三级翻译流水线写得挺完整,线上就是不出活,修一个公益功能的优先级显然排不过修取数链路。

/2/search 接口对所有查询返回 404,issue #2303 也在开着。大量第三方工具依赖这个公开 API,现在等于整个搜索功能下线状态。

维护结构也是个双刃剑。4249 个 commit 里 dangered wolf 一个人占了 2104,第二名人类贡献者只有 20 个,bus factor 低得吓人。不过换个角度看,2026 年还没过完就进了 1318 个 commit,是四年里最活跃的一年,仓库里躺着 AGENTS.md,贡献者名单里 cursoragent 有 20 个 commit,AI 代理已经在这场猫鼠游戏里上场干活了。没有 release,没有版本号,x-powered-by 响应头直接打印构建时间,部署节奏就是追着 X 的接口变更连轴出 hotfix。

我一直觉得这类项目最值得学的不是代码,是它对「接口」的理解。Discord 的 embed 爬虫是一套不敢随便改的公共契约,X 网页客户端的 guest token 是一份每天暴露在浏览器里的公开凭证,三个官方客户端的 GraphQL 配额是三份可以摊薄使用的隐形资产。FxEmbed 把这三个「平台自己留着的口子」串成了一条生产线,平台每关上一个,它就换下一个,四年了还活着。

代价也摆在那里,账号池见不得光,翻译坏了没人修,单人维护的悬顶之剑一直挂着。想自托管拿去修 Discord 链接预览,公开通道白嫖得很舒服;想要完整数据能力,先想想你打算拿什么账号去填那个加密池。

至于 X 什么时候会把这条路彻底堵死,没人知道。但只要 embed 爬虫还存在一天,改个前缀就能复活链接的魔法,就还能再活一天。

评论互动

© 2026 王若风的技术博客 · Powered by Astro