新增接口
v7 中新增一个平台接口只需一份端点声明
新增接口
v7 起,给平台加一个数据接口只剩一个实质步骤:在 endpoints/ 下新建一份端点声明(用该平台的 defineXxxEndpoint,如抖音 defineDouyinEndpoint)。参数类型、运行时校验、请求构造、签名、HTTP 路由、fetcher 方法、返回类型全部由声明派生 —— 不再有 v6 那种「改 8 步、动 11–15 个文件」的流程。
实现之前建议先读两处真实代码,建立直观认识:
- 端点声明模板:
packages/core/src/platforms/douyin/endpoints/videoWork.ts - 注册表汇总:
packages/core/src/platforms/douyin/endpoints/index.ts
第 1 步:新建端点声明文件
在 packages/core/src/platforms/<平台>/endpoints/ 下新建 <短名>.ts,用该平台的 defineXxxEndpoint 声明端点 —— 它和 type 都从同目录 ./define 里取。抖音 videoWork 的真实声明长这样:
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
})
为什么是 defineDouyinEndpoint 而不是通用 defineEndpoint? 平台专属的这层只做一件事:把
sign 的字符串分支从宽 string 收窄到本平台签名器表推导出的名字联合 —— 抖音是
'a-bogus' | 'x-bogus'、B站 'wbi' | 'qtparam'、快手 'hxfalcon'、小红书是 xhs-* 那七个。
名字写错(如 'a-bogas')编译期即报错,不必等运行时查表抛 未注册的签名器。运行时它仍是恒等
函数,其余行为与 defineEndpoint 完全一致 —— 但直接 import 通用 defineEndpoint 会绕过这道收窄,
所以端点文件从同目录 ./define 一次性取 defineXxxEndpoint 与 type。
换成你的接口时,逐个替换这些字段(name / route / doc / params 四个是必填,缺一个编译或 CI 就会拦下来):
| 声明字段 | 做什么 | 取值来源 |
|---|---|---|
name | 端点全名 <平台>.<短名> | 该平台已有声明的命名习惯 |
route | HTTP 路由路径,同平台内唯一 | v6 的路径风格(snake_case)保持不变 |
doc | 文档元数据:OpenAPI 的 summary / description 的唯一出处 | summary 必写:中文名词短语、不带句号、≤40 字(如 '视频作品详细信息');一句话讲不完的约束才补可选的 description。不要写 tags —— 声明里根本没这个键,tag 由生成器从 name 的平台段派生 |
params | zod schema | 参数类型和校验的唯一出处,不再手写第二遍 |
build | 按参数构造请求描述 | URL 构造放在同目录 api.ts 的纯函数里 |
sign | 签名声明 | 平台签名器表里的名字(defineXxxEndpoint 已把它收窄到本平台签名器名联合,写错名字编译期即报错);不签名的接口写 false |
还有一处登记:在 packages/core/test/types/response-mapping.test-d.ts 里加一行 —— 补样本 + pnpm gen:types 之后,packages/core/test/contracts/response-source.test.ts 会红,并直接告诉你这一行该换成什么。
doc 在类型上是可选的(已有端点一个不改也能编译),但新增端点必写:packages/core/test/contracts/endpoint-doc.test.ts 拿四个注册表逐个断言 doc.summary 非空、≤40 字、不以句号结尾、同平台内不重复 —— 漏一个 CI 直接红。
响应类型:在 Web 控制台录一份样本
历史做法是「抓一次响应 → 贴进第三方 JSON→TS 在线工具 → 把结果拷回仓库 → 把 JSON 扔掉」。转换那一步本来就只占十分钟,真正贵的是「重新取得证据」和「确认没漏变体」—— 而证据每次都被扔掉了。后果是:改一个字段要重新抓一遍;想知道某个字段为什么是可选的,没人答得上来;发现新变体全靠碰巧遇到。
现在这件事有工具:本地 Web 控制台(packages/web,Vite + React + Tailwind + HeroUI)。一条命令起两侧:
pnpm console # Node 侧(默认只监听 127.0.0.1:7345)+ 浏览器侧(Vite 把 /api/* 代理过去)要分两个终端跑就 pnpm console:server 与 pnpm console:web —— 两个都得起,只起浏览器侧时每个请求都会失败。
cookie 从环境变量读(AMAGI_COOKIE_DOUYIN / AMAGI_COOKIE_BILIBILI / AMAGI_COOKIE_KUAISHOU / AMAGI_COOKIE_XIAOHONGSHU),页面上一个字都不回显。
界面上一趟走完:左栏搜到你的新端点 → 填参(表单由端点的 zod schema 派生,一个都不手写)→ 点「发送」,或者「批量 N 组」按参数矩阵录一轮 → 同屏看响应体与它的类型声明(三栏并排:请求 / 响应 / 类型;「类型」栏还有「已提交」「diff」「对比」三页)→ 「留下 / 丢掉」 → 「生成类型」。产物落在 packages/response-types/src/generated/(独立包,core 当 devDep 装、透给下游),全量重新生成仍然是 pnpm gen:types;生成完重建一次那个包(pnpm --filter @ikenxuan/amagi-response-types build),core 的类型解析指到它的 dist/。
中间那个「人看一眼」不是形式。 录制与入库分开两步,因为「这份该不该留」无法自动判断:错误页与风控页都是合法 JSON,混进去会让业务字段全变成可选可 null,而没有任何测试会红;两份同形样本对类型的贡献又是零 —— 那两份 B站 comments 只差一个 number 参数,2.57 MB 换来零收益。
样本留在本地,不进 git(.gitignore 掉了 corpus/**/*.json,例外只有人写的
seeds.json 与 *.doc.json)。它是重新生成类型的输入,不是可提交的证据 ——
三条理由、代价,以及产物文件头那个溯源块(几份样本、参数哈希、录制日期、amagi 版本)
见贡献指南的「响应样本留在本地,不进 git」一节。
脱敏由 packages/typegen 的 scrubSample 自动做,但脱敏清单要人看:suspects
是「像密钥但没有规则匹配上」的路径。批量录制 pnpm record:corpus 还在,降级成辅助 ——
它录出来的东西还是得一份一份在控制台里过。命令全貌见贡献指南的「corpus 工作流」。
样本目前只覆盖 62 / 80 个端点,而且大多只有一份:不透明 ID(aweme_id / sec_uid /
bvid)没法生成、只能来自 corpus/seeds.json 或依赖图,而多数平台的种子还没填。
所以新增端点时 response 槽位照旧写手写类型令牌(上面那张表的最后一行)——
生成树与手写树是并存的两棵,前缀刻意不同名(生成 BilibiliEmojiList_V0 /
手写 BiliEmojiList),调用处一眼可辨读的是哪棵。
录样本不是替代手写类型,是让下一次改这个类型的人有据可查。
一份端点声明的字段大致按「调一次接口」的流程往下写(①–⑧ 对应项目架构的管线图)—— 除必填四项外都可选、按需添:
必填骨架
name / route / params / doc(见上表),其余全部可选。
② prepare(可选)
发请求前换游客 cookie、取密钥;多数端点不写。
③ build → ④ sign
build 按参数拼请求(返回数组 = 多个请求并发);sign 写签名器名 / false(不签)/ 一次性函数。
⑥ decode → ⑦ judge(可选)
⑤ 是框架发请求。非 JSON 响应(protobuf / 多段 JSON / HTML)在 decode 解;judge 覆盖平台默认判定。
⑧ 收尾整形(可选,三选一)
normalize(通用兜底)/ aggregate(同构分段并发)/ paginate.merge(翻页),最多写一个 —— aggregate 与 normalize 同时写会编译报错。
response
响应类型令牌 type<T>(),data 的类型只从这里来。
完整字段清单、每个槽位的实际使用量与设计取舍,见项目架构的「端点声明」一节与端点注册表。
第 2 步:登记进 registry
在平台注册表汇总文件(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登记后还有三件机械事:
-
把声明与响应类型加进该文件尾部的 re-export 列表(照抄现有行)。
-
去
client/method-names.ts加一条映射。 这是必需的,不只对不规则名 —— 见下面「方法名」那条。 -
同步被锁死的计数判据。 当前总数是 80(抖音 26、B站 27、快手 14、小红书 13),它在这些地方被写死,逐一改到位:
文件 要改什么 test/contracts/endpoint-doc.test.ts逐平台数 + 合计 80 test/client/method-names.test.ts映射表条数、逐平台数,以及「不规则 N 条 / 其余 N 条是规则映射」两条 —— 你的新方法名落在哪一边决定改哪个 test/openapi/spec.test.tspaths条数,以及「N 个端点带响应类型」test/server/openapi-route.test.tspaths条数(三处)test/platforms/<平台>/endpoints.test.ts该平台 registry 的端点数 + 路由清单 test/types/response-mapping.test-d.ts该平台的 describe标题与新增一行类型断言test/contract/__snapshots__/fetcher-surface.test.ts.snap四组方法名快照,跑 vitest -u更新test/contracts/endpoint-paginate.test.ts声明了 paginate才要改 —— 分页端点总数(当前 15)test/openapi/response-schemas.test.ts新端点的响应类型进了生成树才要改 —— it.each的抽样验收清单文档站里也写着这些数字:
content/docs/v7/dev/internals/registry.mdx(规模、派生物表、可选槽位使用量)与本页。
这一步之后:全部自动派生
登记完成即接口可用,以下东西不需要你维护:
- fetcher 方法。
client.douyin.fetcher.fetchXxx与静态douyinFetcher.fetchXxx都从 registry 派生(Proxy 懒加载,方法集合自动跟随注册表),参数与返回类型由声明推导,自动走 v7 信封。 - 校验。 参数 schema 即校验,校验失败返回
success: false信封(不再throw)。 - HTTP 路由。
route由服务端自动注册成/api/<平台>/<route>,不再手写路由文件。 - 返回类型。
response/normalize/compute推导出精确类型;声明过的字段有补全,平台新加的字段读出来不算编译错误。 - 文档与测试清单。 方法与路由清单由注册表推出,不再有手写的第二份事实。
方法名:规则名也要登记
fetcher 方法名默认按「fetch + 端点短名首字母大写」派生,只有 16 个不规则名(parseWork、searchContent、convertAvToBv 等)需要那张映射表才能拼出来。
但每个新端点都必须在 client/method-names.ts 里登记一行,规则名也一样。兜底规则救不了你,因为有两处会红:
packages/docs/scripts/generate-docs.ts在映射表里查不到短名时直接抛,于是pnpm build:docs失败 —— 而它是 CI 必需检查;test/client/method-names.test.ts把映射表的方法名集合与活 fetcher 的键逐条比对,活 fetcher 多一个方法即失败。
常见形态的写法
| 你想做的事 | 写法 |
|---|---|
| 无参接口 | params: zod.object({}),fetcher 方法可以省略 options(见抖音 emojiList) |
| 数字参数(可传字符串) | zod.coerce.number()(快手的 count 就是这么强转的) |
| 必填与文案 | zod.string().min(1, { error: '中文提示' }),文案直接写在 schema 上 |
| 声明式翻页 | paginate 槽位声明 maxPageSize / items / hasMore / nextParams / merge(收尾),一页与最终返回不同形时加 page 令牌覆盖(见 userFavoriteList / 快手 userWorkList) |
| 签名的接口 | sign 给原子反爬参数清单,如 [msToken(184), aBogus(), secsdk()],按 phase 排序执行(见抖音 videoWork) |
| 不签名的接口 | sign: false(见抖音 guestUserInfo / emojiResourceMeta) |
验证
pnpm typecheck # 类型检查(含文档站的三道静态门)
pnpm lint # 代码检查
pnpm format:check # 格式检查(oxfmt 只读版)
pnpm test # vitest:端点用例 + fetcher 表面快照 + registry 结构锁 + doc.summary 钉子
pnpm openapi # 重新生成 openapi.json(**必须提交**)
pnpm openapi:check # 已提交的 openapi.json 与四个 registry 逐字节一致
pnpm deps:check # dpdm:必须报 0 环
pnpm build:docs # 文档站整站构建,含 80 页端点文档的重新生成这份清单之外的 openapi:check 与 build:docs 也是 CI 的必需检查 —— 只跑前四条发现不了「忘了重新生成 openapi.json」和「忘了登记方法名」。
用你的新接口跑一次端到端:client.<platform>.fetcher.fetchXxx(...) 成功返回信封后,任务就完成了。
想知道派生链路的全貌
这一页只讲「怎么做」。声明里每个字段被谁读取、派生出哪些产物、注册表如何到达 client 与 server,见端点注册表。
最后更新于 2026-10-23