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

契约与信封

contracts 是全仓的词汇表 —— AmagiResult、AmagiError、EndpointDef 的确切形状与三条硬约束

契约与信封

packages/core/src/contracts/ 是零依赖叶子层:谁都可以依赖它,它不依赖仓库内任何其他模块(外部只有 zod 与 axios 的类型导入)。九个文件、约 1300 行,是全仓的词汇表。

读这一层的收益最高 —— 上层几乎所有行为都由这里的形状决定。

九个文件

文件内容
result.tsAmagiResult<T> / AmagiSuccess<T> / AmagiFailure / isSuccess / isFailure / unwrap / AmagiThrownError
error.tsAmagiError / ErrorKind(12 个大类)/ AmagiErrorCode / Judge
meta.tsAmagiMeta / RequestTrace / TraceReason / STATIC_CLIENT_ID
endpoint.tsEndpointDef / defineEndpoint / SignDecl<TSign> / type<T>() / PaginateDef / PaginatedValue / AggregatedValue / PageOf / EndpointCtx / Registry
request.tsRequestSpec / RawResponse / RequestConfig / AmagiHeaders
cookie.tscookie 解析与序列化 —— 全仓唯一实现
platform.tsPLATFORMS 常量与 Platform 联合类型
session.ts会话契约:LoginState / LoginSession / QrcodeLoginStrategy
ua.ts浏览器指纹基线(UA / Sec-Ch-Ua),四个平台的版本号集中一处

信封

所有对外 API 返回同一个 AmagiResult<T>:

Prop

Type

Prop

Type

三条硬约束

  1. 顶层没有 code。 v6 用同一个 code 混装 HTTP 状态码、平台业务码与内部码,读的人无法判断拿到的是哪一种。v7 各归各位 —— HTTP 状态在 error.http.status,平台业务码在 error.platform.code,amagi 自己的错误码在 error.code。
  2. 成功分支运行时没有 error 键,失败分支没有 data 键。 类型上各留一个 ?: undefined 占位以便未收窄时也能读,但运行时对象里那个键不存在。v6 是 undefined as never 声明占位、键却留在对象里。
  3. success 是唯一判别键。 if (r.success) 之后 data 可用,else 之后 error 可用。

永不 reject

参数校验失败、平台业务错误、网络错误、内部异常全部映射为 success: false。这条由 runtime/execute.ts 的单一 catch 实现,并被源码级测试钉住(断言 catch 子句恰好一处)。

这是相对 v6 的行为变化:v6 里参数校验失败会 throw。依赖 try/catch 的 v6 代码在 v7 下会 静默走进 else 分支。需要恢复抛出语义用 /compat。

四种读法

import , { ,  } from '@ikenxuan/amagi'
const  = ({ : { : '' } })
const  = await ...({ : 'BV1xx411c7mD' })

// ① 收窄
if (.) {
  .
} else {
  ..
}

// ② 类型守卫(适合 filter)
const  = [].().(() => .)

// ③ unwrap:失败时抛 AmagiThrownError
const  = ()

// ④ 不收窄直接读(类型是 T | undefined)
const  = .

AmagiThrownError 把 AmagiError 的全字段平铺成 readonly 实例属性,并把原对象挂在 .error 上(引用相等)。它只在 error 自带 cause 时才传 Error 的 cause —— 不凭空造键。

错误

Prop

Type

ErrorKind 12 个大类(按声明顺序):validation / auth / rate_limit / risk / not_found / forbidden / unavailable / network / timeout / parse / internal / unknown。

AmagiErrorCode 23 个具体码,按类分组:参数(PARAM_INVALID / PARAM_MISSING)、身份(COOKIE_MISSING / COOKIE_EXPIRED / LOGIN_REQUIRED)、风控(RATE_LIMITED / RISK_CONTROL / CAPTCHA_REQUIRED / SIGNATURE_REFUSED)、可见性(NOT_FOUND / DELETED / PRIVATE / GEO_RESTRICTED / PAID_CONTENT)、网络(PLATFORM_ERROR / PLATFORM_UNAVAILABLE / NETWORK_ERROR / TIMEOUT)、解析(EMPTY_RESPONSE / DECODE_FAILED / ANTIBOT_PAGE)、兜底(INTERNAL_ERROR / UNKNOWN_ERROR)。

刻意不用 enum —— 用 as const 数组 + 联合类型,理由写在源码注释里(v6 的两处 enum 缺陷)。

哪些字段什么时候有

字段何时填
kind / code / message / retryable恒有
platform平台给了业务码时。由 runtime 统一从原始响应提取(依次试 code / status_code / statusCode),不是 judge 填的
http有请求才有
issues仅 kind === 'validation'
raw仅 debug: true 且这次确实拿到了响应。默认连键都没有
cause底层 Error,仅用于日志

retryable 默认按 kind 推(rate_limit / risk / unavailable / network / timeout 为真,其余 7 类为假),端点的 judge 可以覆盖。

Judge:全仓唯一判定成败的地方

type Judge = (raw: unknown, http: { status: number; headers?: JudgeHeaders }) => JudgeVerdict

每平台一份纯函数。v6 里四个平台各自为政的判定逻辑在 v7 只剩这一个判定点。端点声明的 judge 优先于平台默认;两者都没有时走 execute 的兜底「HTTP 2xx 即成功」。

http.headers 是本次响应的头(JudgeHeaders 只要求一个大小写不敏感的 get,AmagiHeaders 满足它)。execute 总会传;手写调用 judge 时可以省略,省略就等于「没有任何头」。 它存在的理由是抖音:验证中心弹滑块 / 文字验证时 HTTP 是 200,验证指令只在 bdturing-verify / x-vc-bdturing-parameters 头上,body 可能是空串也可能看着完全正常 —— 只看 raw 与 status 的 judge 必然漏判。所以抖音的 judge 把「2xx + 验证头」排在所有 body 判据之前, 判成 risk / CAPTCHA_REQUIRED(subtype 为 nocaptcha_collect 的无感采集除外)。

请求与响应描述

RequestSpec 是端点 build 的产物:method / url(完整含 query)必填,其余 headers / body / responseType / signPath / tag / extra 可选。

RawResponse 是 transport 的产物:status / headers / body / durationMs / url 必填,statusText / setCookie 可选。

setCookie 单独保留原始数组,因为 headers 里的 set-cookie 是用 '; ' join 成一条的字符串, join 之后无法还原 —— 而游客 cookie 换身份与 B站会话登录都要逐条处理。

AmagiHeaders 是一个大小写不敏感的 header 容器:内部一个 Map,key 是小写化的名字,value 保留最后一次写入的原始大小写与值。

成员行为
get / has / delete大小写不敏感
set(name, value)undefined / null 视为「不写这个 header」;否则值与显示大小写一起被最后一次写入覆盖
merge(input)逐条 set,后来者覆盖
keys / toEntries / toJSON保留最后一次写入的大小写
clone()new AmagiHeaders(this),防下游改写上游

所以不可能出现两条同名 header。

端点契约

EndpointDef 的字段清单与派生关系见端点注册表,这里只补两个类型层的坑:

type<T>() 是幻影令牌。 它的唯一字段是 readonly __type?: T,运行时返回空对象,零开销。TData 就从它推。

只有 compute 参与 TData 推导、可能覆盖 response。 compute 端点常只写 compute 不写 response,类型从返回值推出就够;与 response 并存时要显式标注返回类型,否则 data 退化成宽松推导。normalize / aggregate / paginate.merge 的返回都包 NoInfer<TData>:只被检查、不参与推导 —— 所以 TData 恒由 response 令牌定,钩子返回错形状直接编译报错而不是静默变宽。

三个整形钩子二选一,aggregate 与 normalize 类型层互斥。 收尾三选一:normalize(通用兜底)/ aggregate(同构分段并发)/ paginate.merge(翻页),一个端点最多用一个。同时写 aggregate 与 normalize 会被 defineXxxEndpoint 入参上的 ExclusiveShaping(类型是 { aggregate?: undefined } | { normalize?: undefined })编译报错 —— 因为 execute 只认 aggregate、normalize 会被静默忽略,所以在声明处就用类型挡住;test/contracts/endpoint-paginate.test.ts 另有一道同名门给中文提示兜底(能用类型表达的用类型、不能的用测试钉住,这里两者都用)。

AnyEndpointDef 用的是 any 而不是 unknown,这是必须的 —— TParams(build 等的形参)与 TPage / TItem(paginate.items 的形参)都在逆变位置,unknown 会让具体端点不可赋值;ParsedOf / DataOf 等 helper 的 extends 模式、以及 execute 的 def 形参同理,在这些位都填 any。

三个类型提取器:ParsedOf(校验后的形状)、ParamsSchemaOf、DataOf。SDK fetcher 的参数签名使用 ParsedOf,避免 zod.coerce 的 input 类型 unknown 把数字参数放宽;HTTP 入口仍接收未校验的输入并经 schema coerce,数字字符串仍可转换。

响应类型的来源(2026-09-11 换过一次)

端点的 response 现在引用 @ikenxuan/amagi-response-types 的生成类型(BilibiliCommentsResponse / DouyinVideoWorkResponse 这一族),由 packages/typegen 从录到的真实响应派生,名字带完整平台名前缀。

80 个端点里 66 个有生成类型(含 B站 AV↔BV 两条 compute 的本地声明);14 个没有录制样本, 一律 any(在 response-mapping.test-d.ts 里逐条 toBeAny() 断言登记,补样本 + pnpm gen:types 后 test/contracts/response-source.test.ts 会红着提醒换掉)。类型只有两个来源:生成树,或者 any —— 手写协议类型不在这条链上。唯一例外是 xiaohongshu/userProfile:生成树里那份描述的 是已弃用的 HTML 形状,端点保持 any,等重录样本后由生成树接替。

v6 的实测快照树 src/types/ReturnDataType/(168 文件)仍在导出、仍是 v6 兼容面,但不再是端点声明的来源。

关键机制:每个响应类型的顶层都带索引签名([property: string]: any),嵌套层也带 —— 生成树与 v6 快照树两边都是。所以读平台新增的未声明字段不会产生编译错误,「平台加字段」不算 breaking change。这条承诺由类型测试逐平台钉住。

索引签名的类型是 any,不是 unknown —— 类型测试里直接断言了 toBeAny()。 所以读未声明字段不会有任何类型保护,写的时候自己当心。

文件名里的 _V<n> 不是 API 版本号

目录约定是 <Endpoint>/<Endpoint>_V0.ts 加一个 index.ts barrel(barrel 里 export type KsOneWork = KsOneWork_V0,所以对外名字不带 _V0)。这个后缀长期没有 任何注释解释,而它看起来的含义和它实际的含义不一样:

  • 它不是平台 API 的版本,也不是 amagi 的版本。
  • 它的语义是「同一判别式取值下仍然存在无法合并的形状差异」时的形状序号。

现状里一个 _V1 都没有了。 曾经有两个(转发动态的 DYNAMIC_TYPE_AV 与 DYNAMIC_TYPE_DRAW),但逐字段比对确认它们不是真变体,是抓包漂移 —— 同一个接口两次抓包赶上的数据不一样(一份有 editable,另一份有 decoration_card, topic 一份是对象一份是 null),被当成了两个类型。 2026-09-04 把它们各自合成了一个带可选字段的类型,手写树少了约 990 行。 packages/typegen 的第一个测试用例就是把这两份反推成样本喂进合并器,验证它塌成一个。

所以看到 _V1 时先怀疑它是不是该被合并掉,而不是照着再加一个 _V2。 两个合并后的文件头里各有一张差异表,说明「为什么这些字段是可选的」—— 那是给下一个人看的:不写清,他会以为平台契约本来就这么松。

新增响应类型时不要用 _V<n> 表达「接口改版了」。接口真的改了就直接改 _V0 的内容 —— 那正是「实测快照」该有的行为,旧形状留在 git 历史里。

放宽响应类型的三条逃生舱(索引签名 / 显式泛型 / 类型断言)见响应类型那一页,那里有可编译的示例。

validation/ 是并存的第二套 schema,不在请求路径上

src/validation/ 有 7 个文件、约 1000 行 zod schema,键是 v6 的 methodType 而不是端点短名。它与端点声明的 params 是两套不通的 schema。

  • 请求路径用的是端点声明自己的 params(execute 的 validate 阶段)。validation/ 里那套在整条请求路径上是死路 —— src/ 里没有任何调用点。
  • 但它的 8 个函数(4 个 validateXxxParams + 4 个 assertValidXxxParams)仍在公开导出面上,前者返回 { ok, value } | { ok, issues },后者保留 v6 的抛出行为。
  • 新增端点不需要动它。

两套 schema 的键集合当前恰好一一对应(douyin 19 / bilibili 27 / kuaishou 6 / xiaohongshu 7), 但没有任何测试断言这一点 —— 将来加端点只改一侧不会被 CI 发现。

依赖倒置的两处

contracts 是叶子层,但有两件事必须让上层做,于是在契约里只声明形状:

  • 端点钩子要发请求,contracts 不能依赖 transport。 EndpointCtx 里只声明 send 的形状,实现由 transport 在运行时注入。
  • transport 要上报事件,不能反向依赖 runtime。 事件出口只是一个 emit(event, { trace }),meta 由 runtime 侧在出口补上。

这两处是 dpdm 报 0 环的关键。

最后更新于 2026-10-17

On this page