契约与信封
contracts 是全仓的词汇表 —— AmagiResult、AmagiError、EndpointDef 的确切形状与三条硬约束
契约与信封
packages/core/src/contracts/ 是零依赖叶子层:谁都可以依赖它,它不依赖仓库内任何其他模块(外部只有 zod 与 axios 的类型导入)。九个文件、约 1300 行,是全仓的词汇表。
读这一层的收益最高 —— 上层几乎所有行为都由这里的形状决定。
九个文件
| 文件 | 内容 |
|---|---|
result.ts | AmagiResult<T> / AmagiSuccess<T> / AmagiFailure / isSuccess / isFailure / unwrap / AmagiThrownError |
error.ts | AmagiError / ErrorKind(12 个大类)/ AmagiErrorCode / Judge |
meta.ts | AmagiMeta / RequestTrace / TraceReason / STATIC_CLIENT_ID |
endpoint.ts | EndpointDef / defineEndpoint / SignDecl<TSign> / type<T>() / PaginateDef / PaginatedValue / AggregatedValue / PageOf / EndpointCtx / Registry |
request.ts | RequestSpec / RawResponse / RequestConfig / AmagiHeaders |
cookie.ts | cookie 解析与序列化 —— 全仓唯一实现 |
platform.ts | PLATFORMS 常量与 Platform 联合类型 |
session.ts | 会话契约:LoginState / LoginSession / QrcodeLoginStrategy |
ua.ts | 浏览器指纹基线(UA / Sec-Ch-Ua),四个平台的版本号集中一处 |
信封
所有对外 API 返回同一个 AmagiResult<T>:
Prop
Type
Prop
Type
三条硬约束
- 顶层没有
code。 v6 用同一个code混装 HTTP 状态码、平台业务码与内部码,读的人无法判断拿到的是哪一种。v7 各归各位 —— HTTP 状态在error.http.status,平台业务码在error.platform.code,amagi 自己的错误码在error.code。 - 成功分支运行时没有
error键,失败分支没有data键。 类型上各留一个?: undefined占位以便未收窄时也能读,但运行时对象里那个键不存在。v6 是undefined as never声明占位、键却留在对象里。 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