端点注册表
一份端点声明如何派生出方法、路由、类型、OpenAPI 与文档 —— amagi v7 最核心的设计
端点注册表
amagi v7 的全部设计压力都集中在一件事上:一个平台接口只写一次。
v6 的做法是同一个接口在 11–15 个文件里各写一份(参数类型、校验 schema、URL 构造、fetcher 方法、路由、返回类型、文档),任意两份之间的不一致都能独立发生。v7 把它收成一份 defineEndpoint 声明,其余一切派生。
这一页讲清派生链路的每一环:声明里有什么、谁在读它、读出来变成什么。
声明本体
契约在 contracts/endpoint.ts:EndpointDef 是形状,defineEndpoint 是构造器(运行时是恒等函数,全部价值在类型推导),type<T>() 是只承载类型的响应令牌(运行时返回空对象)。
必填四项 —— name / route / params,以及类型上可选但 CI 强制的 doc。最朴素的一个端点长这样:
import zod from 'zod'
import type { RequestSpec } from '../../../contracts/request'
import type { DouyinVideoWorkResponse } from '../../../types/generated'
import { createDouyinApiUrls } from '../api'
import { withDouyinReferer } from '../referer'
import { douyinBogus } from '../sign/steps'
import { DOUYIN_RISK_RETRY, defineDouyinEndpoint, type } from './define'
/**
* 视频作品详情(新路径 `/fetch_video_work`,避免与其他作品类型共用路由)。
*
* 行为与旧版一致:`getWorkDetail` GET + a_bogus 签名。
*/
export const videoWork = defineDouyinEndpoint({
name: 'douyin.videoWork',
route: '/fetch_video_work',
doc: {
summary: '视频作品详细信息',
description: '已知是视频作品时用这条;形态不确定时先用 `parseWork` 自动识别。'
},
params: zod.object({
aweme_id: zod.string().min(1, { error: '作品ID不能为空' }).describe('作品 ID')
}),
build: (p, ctx): RequestSpec => ({
method: 'GET',
url: createDouyinApiUrls(ctx.userAgent).getWorkDetail(p),
headers: withDouyinReferer(ctx, { kind: 'video', awemeId: p.aweme_id })
}),
sign: douyinBogus(184),
retryOn: DOUYIN_RISK_RETRY,
retryFresh: true,
response: type<DouyinVideoWorkResponse>(),
// 预留:跨平台语义视图接入时在此填 (raw) => CanonicalWork
toCanonical: undefined
})
| 字段 | 类型 | 它导致什么 |
|---|---|---|
name | `${Platform}.${string}` | meta.endpoint;OpenAPI 的 operationId(平台_短名)与 tags(取平台段)。声明里没有 tags 字段 —— 平台就是 tag |
route | string | HTTP 路径,同平台内必须唯一(注册期同步校验,重复直接抛) |
params | zod schema | 参数 TS 类型(ParsedOf)、运行时校验、OpenAPI 的 query parameters、文档参数表 —— 四样东西的唯一出处 |
doc.summary | string | OpenAPI summary、SDK 方法页的首句。类型可选,测试强制(见下) |
build | (params, ctx) => RequestSpec | RequestSpec[] | 构造请求描述。返回数组即多请求聚合 / 分段并发 |
sign | TSign | false | SignFn | 字符串查平台签名器表(TSign 默认宽 string,平台的 defineXxxEndpoint 把它收窄到签名器名联合、写错编译期即报错);false 显式不签;函数即一次性签名器 |
response | type<T>() | TData 的唯一来源。normalize / paginate.merge 的返回包 NoInfer<TData>、只被检查不参与推导;只有 compute 参与推导(可只写 compute 不写 response) |
字段全貌用源码渲染,避免与实现漂移:
Prop
Type
可选槽位与它们的真实使用量
每个可选槽位对应一种非常规端点形态。下表的「用它的端点数」是当前仓库的实际统计 —— 它同时告诉你哪些槽位是主干、哪些是个别特例:
| 槽位 | 端点数 | 做什么 |
|---|---|---|
retryOn | 46(24 在 B站、20 在抖音、2 在快手) | 命中这些错误码时端点级重试。B站的 -412 → RISK_CONTROL;快手弹幕约 13% 概率的 result: 11 → PLATFORM_UNAVAILABLE;抖音的一般 Argus 拦截 → ANTIBOT_PAGE(点名签名的 SIGNATURE_REFUSED 不在名单 —— 重签补不出缺失的访客 id) |
retryFresh | 20(全在抖音) | 重试时重新 build + 重新签名而不是原样重放。Argus 按单次请求的 token 组判定,重放同一个 msToken + a_bogus 必然同样被拦;msToken 由签名 step 现签,重签即换新 |
paginate | 15(抖音 7、快手 5、小红书 2、B站 1) | 声明式翻页,见翻页与会话 |
prepare | 42(抖音 26、小红书 13、快手 3) | 前置请求或前置注入。抖音的 26 条由 defineDouyinEndpoint 横切注入 Cookie 文本规范化(签名与发送用同一份凭据);小红书 13 个端点经 defineXiaohongshuEndpoint 横切注入游客会话降级(cookie 里没有 a1 就先换游客 cookie + SSK,将来新增端点自动覆盖,端点声明里不逐个写);快手两条 H5 端点注入 did,另一条 PC 端点用 kuaishouPcPrepare |
decode | 2 | 响应不是普通 JSON:protobuf(B站弹幕)、multi-JSON(抖音搜索) |
judge | 8(抖音 2、快手 4、B站 1、小红书 1) | 覆盖平台默认判定 |
partial | 18(抖音 8、快手 7、小红书 2、B站 1) | 多请求部分失败语义:'tolerate' / 'fail'(缺省) |
aggregate | 2 | 「同构分段并发」的带类型收尾(弹幕按时间区间切段并发),取代这类端点的 normalize;与 normalize 互斥(同时写编译报错,见契约与信封)。抖音 / 快手弹幕 |
normalize | 1 | 把响应整理成最终 data 的通用兜底(第一参 unknown)。分页用 paginate.merge、同构分段用 aggregate,剩下的才用它。当前仅快手 userProfile(异构聚合) |
compute | 2 | 纯本地计算,不发请求(B站 AV/BV 互转) |
sign: false | 4 | 显式不签名(抖音四条免鉴权端点)。签名端点改用 SignStep 清单,如 [msToken(184), aBogus(), secsdk()],按 phase 排序执行(见 sign/steps.ts) |
toCanonical 是留给后续阶段的空槽位,v7 里类型恒为 undefined。
注册表
四个平台各一份 endpoints/index.ts,把声明组装成对象字面量,键是端点短名:
export const douyinRegistry = {
parseWork,
videoWork,
imageAlbumWork,
slidesWork,
textWork,
comments,
commentReplies,
userProfile,
userVideoList,
userFavoriteList,
userRecommendList,
search,
suggestWords,
shortUrl,
musicInfo,
musicAwemeList,
mixAweme,
liveRoomInfo,
loginQrcode,
emojiList,
dynamicEmojiList,
danmakuList,
guestUserInfo,
guestMusicInfo,
guestMusicAwemeList,
emojiResourceMeta
} as const satisfies RegistryRegistry = Record<string, AnyEndpointDef>。平台清单是 contracts/platform.ts 的 PLATFORMS,Platform 联合类型由它推导。
当前规模 —— 抖音 26、B站 27、快手 14、小红书 13,合计 80。这个数字在多处被断言,改了端点数就得同步(见新增接口)。
注册表不在包的公开导出面上:src/index.ts 没有 export * from './platforms',package.json
的 exports 也没有 ./platforms 子路径。它只以派生物的形态对外 —— 下游拿到的是 fetcher 方法、
HTTP 路由与类型,碰不到 registry 本身。
派生出什么
| 派生物 | 生成器 | 输入 | 产物进 git? |
|---|---|---|---|
| fetcher 方法集合 | client/fetcher.ts、client/static.ts | registry | 运行时 Proxy,无产物 |
| 参数校验 | runtime/execute.ts 的 validate 阶段 | def.params | 无 |
| HTTP 路由 | server/routes.ts 的 createRoutes | registry | 无 |
| OpenAPI 规范 | server/openapi.ts → scripts/gen-openapi.mts | 四个 registry | 提交(packages/core/openapi.json,80 条 path) |
自托管 /openapi.json | server/auth.ts 的 mountOpenApiSpec | 同上,现算 | 无 |
| 80 个 HTTP 端点页 | packages/docs/scripts/generate-docs.ts | openapi.json | gitignore |
| 4 个 SDK 方法页 | 同上 | registry 本体 + buildOpenApiSpec() | gitignore |
| SDK 方法名 | 手写表 client/method-names.ts | — | 提交 |
fetcher 是 Proxy,不是预生成对象
createFetcherFromRegistry 返回一个 Proxy:get / ownKeys / has / getOwnPropertyDescriptor 全部按当前 registry 现算,命中后造闭包并缓存。所以方法集合自动跟随注册表,Object.keys(client.douyin.fetcher) 与 'fetchVideoWork' in ... 都同步 —— 不存在「加了端点但方法没出现」这种状态。
绑定形态的方法签名是 (options, requestConfig?);静态形态是 v6 冻结的三参 (options, cookie?, requestConfig?)。
方法名是唯一的手写映射
client/method-names.ts 有 80 条 '<平台>.<短名>' → v6 方法名 的映射。它存在是因为有 16 处不规则命名,拼不出来:
| 端点短名 | v6 方法名 |
|---|---|
parseWork | parseWork(没有 fetch 前缀) |
comments | fetchWorkComments |
search | searchContent |
avToBv | convertAvToBv |
videoStream | fetchVideoStreamUrl |
searchNotes(小红书) | searchNotes(没有 fetch 前缀) |
其余 64 条按 fetch + 短名首字母大写的规则派生,运行时与类型层都有同款兜底。快手弹幕的短名刻意取
danmakuList 而不是 danmaku,就为了让规则名拼出 fetchDanmakuList、与抖音对齐、不必登记成不规则。
兜底规则的存在不代表新端点可以不登记。两处会红:generate-docs.ts 在
methodNamesOf(platform)[short] 为 undefined 时直接抛(于是 pnpm build:docs 失败,
而它是 CI 必需检查);method-names.test.ts 把映射表的方法名集合与活 fetcher 的键
逐条比对,多一个就失败。所以规则名也必须登记。
OpenAPI 的派生规则
buildOpenApiSpec() 遍历四个 registry:
paths=`/api/${platform}${def.route}`,重复即抛- 方法只有
get parameters=zod.toJSONSchema(def.params, { io: 'input' }),全部落在 queryoperationId=`${platform}_${short}`;tags=[platform]summary缺失即抛responses只有 200 与 401;data不展开(v6 的 ReturnDataType 有两万多行,展开会让规范爆炸)
序列化只有一种形式:2 空格缩进 + 末尾换行。pnpm openapi:check 把已提交的 openapi.json 归一化换行后逐字节比对,不一致即非 0 退出 —— 这是 CI 的必需检查之一。同一份 buildOpenApiSpec 还被单测拿来跟产物比对,所以 pnpm test 也能抓到「忘了重新生成」。
文档站的两批生成物
packages/docs/scripts/generate-docs.ts 在 next build / next dev / typecheck 之前跑(docs:api),先 rm 再生成:
- HTTP 80 页 ——
fumadocs-openapi的generateFiles,输入是openapi.json,per: 'operation'+groupBy: 'tag'。beforeWrite钩子补中文标题、平台图标,并追加一份自拼的索引页。 - SDK 4 页 —— 遍历 registry,方法名取
methodNamesOf,参数表从buildOpenApiSpec()的 JSON Schema 生成(与 HTTP 侧同一个函数,所以两边的参数表不可能互相矛盾),doc.summary进首句,doc.deprecated变 Callout,paginate变翻页说明,compute变「纯本地计算」。手写散文只有一份content/partials/sdk-prose.mdx,按区段<include>进来。
示例代码里的取值是唯一写死的东西(生成器里一张 SAMPLES 表),且取值与 schema 类型不符时直接抛 —— 参数改了型,示例会让构建红。
platforms/ 一层,与它底下的 legacy/ 一角
packages/core/src/platforms/ 是唯一的平台实现目录(2026-09-14 之前这里并列着 platform/ 单数与
platforms/ 复数两个目录,读仓库极易走错;合并后 platform/ 这个名字不再存在)。它内部分成两片:
platforms/<平台>/ | platforms/legacy/ | |
|---|---|---|
| 规模 | 208 文件 / 24713 行 | 13 文件 / 2622 行 |
| 是什么 | v7 实现层 | v6 保留实现(公开面照旧,行为逐字不动) |
| 内容 | endpoints/(80 份声明 + registry)、api.ts(URL 构造纯函数)、config.ts、judge.ts、sign/、session/、decode/、assemble/、passport/(仅 douyin)、routes.ts、utils.ts | 四个 v6 版 API.ts、v6 的 wbi.ts / qtparam.ts / douyin/sign、kuaishou/sign(分叉的 3 个文件)、xiaohongshu/sign、searchTypes.ts |
| 在公开导出面上? | routes.ts 与 utils.ts 在,其余不在 | 在(签名器经 utils.ts 转出;xxxApiUrls 由 src/index.ts 显式点名导出) |
| 新增端点要碰吗 | 要 | 不要 |
<平台>/utils.ts 是公开面,routes.ts 是唯一的路由挂载路径
四个 createXxxRoutes(cookie, requestConfig?) 住在 platforms/<平台>/routes.ts,是唯一的路由挂载
路径(门面 startServer 只认它们),内部完全走 v7:createRoutes(platform, registry, makeClientCtx(...))。
utils.ts 是每平台的公开工具面。它把三类东西凑到一起,不复制任何实现:
createXxxRoutes—— 同目录的./routes- v7 实现(
av2bv/parseDmSegMobileReply/douyinPassport)—— 直接引同平台的 v7 模块 - v6 实现(
wbi_sign/qtparam/xxxSign)—— 引../legacy/<平台>/
v6 的 xxxApiUrls 不经 utils —— 它由 src/index.ts 从 legacy/<平台>/API.ts 显式点名导出,
这样 v7 门面上只有 v7 自己的构造器(client.<平台>.apiUrls),两份同名不同义的东西不坐在同一个对象上。
src/index.ts 不写 export * from './platforms':那是实现层,整体 star 出去会把签名器、判定、
端点声明这些内部构件一并泄漏到公开面。改成四个 export * from './platforms/<平台>/utils' 逐个点名
—— 公开什么,看 utils.ts 一个文件就知道。
legacy 与 v7 是两份代码,不是一份的两个入口
包顶层导出的 douyinApiUrls 这类 v6 构造器(legacy/<平台>/API.ts),与 SDK 内部实际发请求用的
URL 构造器(<平台>/api.ts,挂上门面的是 client.<平台>.apiUrls)是两份代码,两边行数不同。
要改 URL 行为时必须确认自己改的是哪一份。
签名同理:legacy/douyin/sign、legacy/kuaishou/sign、legacy/xiaohongshu/sign 与 v7 的同名模块是
分叉的两份。只有算法叶子(a_bogus / x_bogus / he / hudr / primitives / bv2av /
danmaku / config)在合并时去重成了一份 —— 它们当时逐字节相同,v6 侧改为直接引 v7,少了 8 个副本。
分叉的那几个要留神:test/platforms/<平台>/sign.test.ts 一类用例拿 v6 那份当 oracle,对同一入参
断言 v7 输出与 v6 完全相等。这层对照依赖两份代码同时存在,动任何一侧前先读那些用例。
platforms/ 一层共 170 个 .ts。从 src/index.ts 递归跟随 import 有 169 个可达;剩下的
platforms/douyin/sign/decode.ts 只从 exports/signing.ts 进来 —— 那是 ./signing 子入口,
按设计不挂在主入口上。把六个包入口(. / ./express / ./axios / ./compat / ./signing /
./sign-steps)一起算,170 个全部可达。
这里曾经有 7 个不可达文件(platforms/kuaishou/judge.ts 与 platforms/kuaishou/sign/ 下 6 个),
成因是 PLATFORM_RUNTIME 里 kuaishou: {} —— 既没装签名器也没装默认 judge,写好的代码没人调用。
两项都补上之后它们全部可达,那也正是快手请求终于带上 __NS_hxfalcon 的同一件事,
见传输与签名。
这个数字值得偶尔重算一遍:「文件存在」不等于「被调用」,而这个仓库已经因此踩过两次
(judge 与 signers 各一次,都是既不报编译错误也不挂测试的静默漏项)。新加的
platforms/kuaishou/captcha.ts 就是靠这次重算才发现忘了从顶层导出 —— 那会让「把滑块地址
交给调用方」变成一句空话。重算时别只从 src/index.ts 出发:子入口独占的文件会被误判成孤儿。
doc 字段为什么被测试而不是类型强制
doc 在 EndpointDef 上是可选的,这样已有端点一个不改也能编译。真正的强制在 test/contracts/endpoint-doc.test.ts:拿四个注册表逐个断言 doc.summary 非空、≤40 字、不以句号结尾、同平台内不重复。
这是这个仓库反复使用的一种手法 —— 能用类型表达的用类型,不能的用测试钉住,两者都不做的约定等于不存在。
最后更新于 2026-10-23