当前浏览的是 v7 预览版文档(v7 尚未正式发布,API 可能随版本调整)。本页是 v7 新增内容,去 v6 正式版文档首页
@ikenxuan/amagi

端点注册表

一份端点声明如何派生出方法、路由、类型、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。最朴素的一个端点长这样:

packages/core/src/platforms/douyin/endpoints/videoWork.ts
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
routestringHTTP 路径,同平台内必须唯一(注册期同步校验,重复直接抛)
paramszod schema参数 TS 类型(ParsedOf)、运行时校验、OpenAPI 的 query parameters、文档参数表 —— 四样东西的唯一出处
doc.summarystringOpenAPI summary、SDK 方法页的首句。类型可选,测试强制(见下)
build(params, ctx) => RequestSpec | RequestSpec[]构造请求描述。返回数组即多请求聚合 / 分段并发
signTSign | false | SignFn字符串查平台签名器表(TSign 默认宽 string,平台的 defineXxxEndpoint 把它收窄到签名器名联合、写错编译期即报错);false 显式不签;函数即一次性签名器
responsetype<T>()TData 的唯一来源。normalize / paginate.merge 的返回包 NoInfer<TData>、只被检查不参与推导;只有 compute 参与推导(可只写 compute 不写 response)

字段全貌用源码渲染,避免与实现漂移:

Prop

Type

可选槽位与它们的真实使用量

每个可选槽位对应一种非常规端点形态。下表的「用它的端点数」是当前仓库的实际统计 —— 它同时告诉你哪些槽位是主干、哪些是个别特例:

槽位端点数做什么
retryOn46(24 在 B站、20 在抖音、2 在快手)命中这些错误码时端点级重试。B站的 -412 → RISK_CONTROL;快手弹幕约 13% 概率的 result: 11 → PLATFORM_UNAVAILABLE;抖音的一般 Argus 拦截 → ANTIBOT_PAGE(点名签名的 SIGNATURE_REFUSED 不在名单 —— 重签补不出缺失的访客 id)
retryFresh20(全在抖音)重试时重新 build + 重新签名而不是原样重放。Argus 按单次请求的 token 组判定,重放同一个 msToken + a_bogus 必然同样被拦;msToken 由签名 step 现签,重签即换新
paginate15(抖音 7、快手 5、小红书 2、B站 1)声明式翻页,见翻页与会话
prepare42(抖音 26、小红书 13、快手 3)前置请求或前置注入。抖音的 26 条由 defineDouyinEndpoint 横切注入 Cookie 文本规范化(签名与发送用同一份凭据);小红书 13 个端点经 defineXiaohongshuEndpoint 横切注入游客会话降级(cookie 里没有 a1 就先换游客 cookie + SSK,将来新增端点自动覆盖,端点声明里不逐个写);快手两条 H5 端点注入 did,另一条 PC 端点用 kuaishouPcPrepare
decode2响应不是普通 JSON:protobuf(B站弹幕)、multi-JSON(抖音搜索)
judge8(抖音 2、快手 4、B站 1、小红书 1)覆盖平台默认判定
partial18(抖音 8、快手 7、小红书 2、B站 1)多请求部分失败语义:'tolerate' / 'fail'(缺省)
aggregate2「同构分段并发」的带类型收尾(弹幕按时间区间切段并发),取代这类端点的 normalize;与 normalize 互斥(同时写编译报错,见契约与信封)。抖音 / 快手弹幕
normalize1把响应整理成最终 data 的通用兜底(第一参 unknown)。分页用 paginate.merge、同构分段用 aggregate,剩下的才用它。当前仅快手 userProfile(异构聚合)
compute2纯本地计算,不发请求(B站 AV/BV 互转)
sign: false4显式不签名(抖音四条免鉴权端点)。签名端点改用 SignStep 清单,如 [msToken(184), aBogus(), secsdk()],按 phase 排序执行(见 sign/steps.ts)

toCanonical 是留给后续阶段的空槽位,v7 里类型恒为 undefined。

注册表

四个平台各一份 endpoints/index.ts,把声明组装成对象字面量,键是端点短名:

packages/core/src/platforms/douyin/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 Registry

Registry = 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.tsregistry运行时 Proxy,无产物
参数校验runtime/execute.ts 的 validate 阶段def.params无
HTTP 路由server/routes.ts 的 createRoutesregistry无
OpenAPI 规范server/openapi.ts → scripts/gen-openapi.mts四个 registry提交(packages/core/openapi.json,80 条 path)
自托管 /openapi.jsonserver/auth.ts 的 mountOpenApiSpec同上,现算无
80 个 HTTP 端点页packages/docs/scripts/generate-docs.tsopenapi.jsongitignore
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 方法名
parseWorkparseWork(没有 fetch 前缀)
commentsfetchWorkComments
searchsearchContent
avToBvconvertAvToBv
videoStreamfetchVideoStreamUrl
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' }),全部落在 query
  • operationId = `${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

On this page