当前浏览的是 v7 预览版文档(v7 尚未正式发布,API 可能随版本调整)。查看对应的 v6 正式版文档
@ikenxuan/amagi

项目架构

amagi v7 的目录布局、依赖方向与核心概念

项目架构

v7 架构建立在三个决策上:

  1. 一个端点一份声明。 新接口写成一份 defineEndpoint 端点声明,参数类型、运行时校验、HTTP 路由、fetcher 方法与文档全部由它派生 —— 加一个接口从 v6 的「改 11–15 个文件」降到「改 1 个文件」。
  2. 单向依赖。 代码按层组织,方向被 CI(dpdm 0 环)钉死。
  3. 统一信封。 所有平台、所有调用返回同一个 AmagiResult<T>,成功与失败的字段名跨平台一致。

本页是总览:每一层做什么、依赖方向怎么定、端点声明长什么样。 逐子系统的机制细节(管线九个阶段、签名器装配、事件三根线、翻页收敛条件、导出面全貌) 在内部机制那一组里 —— 那几页写给 AI 代理与要改内部结构的人。

目录结构

核心代码在 packages/core/src/,六个职责层(contracts / transport / platforms / runtime / client / server)加一个入口 index.ts,逐层职责见下面各小节。

下面这棵树不是手抄的:构建期用 glob 扫 packages/core/src/**/*.ts 现生成 (<auto-files>),点开即是磁盘上的真实结构。目录一改,这页跟着改,不存在抄漏或过期。

因此它也会如实显示:仓库当前仍处于 v6 → v7 过渡收尾,src/ 下还残留 model/、 validation/、types/、utils/ 等旧目录与 v6 低阶入口(transport/legacy.ts 等 @deprecated 保留点),正按阶段 6 的删除清单清理。v6 的平台实现已收进 platforms/legacy/(原先并列的 platform/ 单数目录已于 2026-09-14 合并掉)。 本文描述的是 v7 的目标结构:新增代码一律进上面六层,不在旧目录里落任何新东西。

contracts —— 零依赖叶子层

谁都可以依赖它,它不依赖仓库内任何其他模块(外部只有 zod 的类型导入)。

文件内容
result.tsAmagiResult<T> / AmagiSuccess<T> / AmagiFailure
error.tsAmagiError / ErrorKind(12 个大类)/ AmagiErrorCode / Judge
meta.tsAmagiMeta / RequestTrace / TraceReason
request.tsRequestSpec / RawResponse / RequestConfig / 大小写不敏感的 AmagiHeaders
cookie.tscookie 解析与序列化(全仓唯一实现)
platform.tsPlatform 联合类型
endpoint.tsEndpointDef / defineEndpoint / Registry
session.ts会话契约:LoginState / LoginSession / QrcodeLoginStrategy
ua.ts浏览器指纹基线(UA / Sec-Ch-Ua,四个平台的版本号集中一处)

transport —— 全仓唯一发请求的地方

文件内容
client.tsHttpClient.send(spec) → RawResponse,收发都在这里
retry.ts重试与退避策略
trace.tsRequestTrace 收集
legacy.tsv6 低层网络入口(fetchData / fetchResponse 等,@deprecated 保留)

「全仓只有 transport 能发请求」是架构约束:端点要发请求(换 guest cookie、取 wbi key)只能通过 ctx.send,由 transport 注入 —— v6 里 wbi.ts 绕过 fetchData 直连 axios 的问题(缺陷 A5)在结构上不再可能发生。

platforms/<platform> —— 平台实现

路径内容
api.tsURL 构造(纯函数,不发请求)
sign/签名算法(从 v6 原样搬迁,不改逻辑,有逐字节快照测试保护)
judge.ts平台响应判定 → 成功 / ErrorKind
config.ts默认请求头基线
decode/protobuf / multi-JSON / HTML 等特殊解码(douyin / bilibili)
assemble/多请求聚合的归一化(仅 kuaishou)
session/登录会话策略(仅 douyin / bilibili)
endpoints/*.ts每端点一份 defineEndpoint 声明
endpoints/index.ts汇总成平台 registry

runtime —— 管线与横切能力

文件内容
execute.ts端点执行管线
paginate.ts声明式翻页
events.ts事件总线(实例级)
session.ts登录会话引擎(轮询循环 / 退避 / 超时 / challenge 编排)

client 与 server

路径内容
client/createClient.ts门面 createClient(options) —— 默认导出 amagi(options) 与 @deprecated 的 createAmagiClient 都是它,返回 { events, on, once, startServer, douyin, bilibili, kuaishou, xiaohongshu },四个平台都带 login,B站另带 refreshCredential
client/fetcher.ts从 registry 派生绑定 fetcher(cookie 绑在 ctx,方法签名 (options, requestConfig?))
client/static.ts从 registry 派生静态 fetcher(douyinFetcher / amagi.douyinFetcher 形态,cookie 按次传)
client/method-names.ts端点名 → v6 方法名(全仓唯一手写映射)
server/routes.ts从 registry 派生 HTTP 路由
server/auth.ts可选 token 鉴权

依赖方向

contracts 在最底层、server 在最顶层,箭头指向被依赖方:

即 transport 只能依赖 contracts;platforms 只能依赖 transport 与 contracts;越往上能依赖的层越多,任何反向或跨越依赖都是错误 —— CI 用 dpdm 检查,必须报 0 环(v6 的 36 处循环依赖是缺陷 1,直接促成这层设计)。

两个容易破环的难点是这样解决的:

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

数据流

一次 fetcher 调用在管线里的旅程。build 返回数组时 ③ 之后是多请求聚合 / 分段并发, judge 缺省取平台自己的那份:

图里那条虚线就是声明式翻页(paginate):每页都完整走一遍 ③ → ⑦ 并重新签名, 最后由 paginate.merge 把跨页累积的条目收敛成一次结果(分页端点的收尾在这里,不在端点级 normalize)。整段调用从头到尾只有一个 requestId,实际发出几个请求看 meta.attempts。

端点声明

新接口 = 一份端点声明。真实例子(packages/core/src/platforms/douyin/endpoints/videoWork.ts):

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
})

真实声明里用的是平台专属的 defineDouyinEndpoint(就是 defineEndpoint 绑定了抖音签名器名联合,把 sign 收窄成 'a-bogus' | 'x-bogus'、写错编译期即报错,见新增接口);它和 defineEndpoint 运行时都是恒等函数,全部价值在类型推导:TParams 从 params 推出,TData 从 response / normalize / compute 推出。响应类型直接复用 v6 的 types/ReturnDataType 快照类型(映射键与端点短名一一对应),v6 快照自带顶层索引签名,平台加字段不算 breaking。

可选槽位(每种对应一种非常规端点形态):

槽位什么时候用
doc文档元数据:OpenAPI summary / description 的唯一出处(类型上可选,新增端点必写;tags 不在声明里 —— 平台就是 tag,由 name 的平台段派生)
prepare前置请求:换 guest cookie、取 wbi key、bootstrap 指纹
build构造请求描述;返回数组 = 多请求聚合 / 分段并发
sign签名器名(平台专属 defineXxxEndpoint 把它收窄到本平台签名器名联合,写错编译期即报错)/ false(显式不签名)/ 一次性函数
decode响应不是普通 JSON:protobuf / multi-JSON / HTML
paginate声明式翻页:maxPageSize / items / hasMore / nextParams / merge(收尾);一页与最终返回不同形时用 page 令牌覆盖
partial多请求部分失败语义:'tolerate'(缺失部分留空)/ 'fail'
judge覆盖平台默认判定
normalize把响应裁剪 / 整形为最终 data(非分页、非同构分段;分页收尾用 paginate.merge、同构分段收尾用 aggregate)
aggregate同构分段并发(build 返回同形数组)的带类型收尾,取代 normalize;与 normalize 互斥 —— 同时写编译报错,另有测试门兜底
compute纯本地计算,不发请求
response响应类型令牌 type<Foo>()
retryOn命中这些错误码时重试(如 B站 -412 → RISK_CONTROL、抖音 Argus → ANTIBOT_PAGE)
retryFresh重试时重新 build + 重新签名,而不是重放同一个请求(抖音换 msToken / a_bogus 过 Argus)

每个平台的 endpoints/index.ts 把全部声明汇总成 registry 对象。派生时有两条自动保证:fetcher 方法集合自动跟随 registry;路由唯一性在派生路由时校验,同平台内 route 重复会直接抛错(v6 抖音 5 个端点撞同一路由的 #47/#48/#54 就是这么修的)。

声明派生出什么

派生物来源
fetcher 方法(client 绑定形态 + 静态形态)声明 + client/method-names.ts
参数类型与运行时校验params schema 推导(参数类型不再手写第二遍)
HTTP 路由route → server/routes.ts 注册
响应类型response / normalize / aggregate / compute 推导
registry 结构测试test/platforms/<platform>/endpoints.test.ts 锁注册表规模与路由

fetcher 方法名默认按「fetch + 端点短名首字母大写」规则派生;只有不规则名(parseWork、searchContent、convertAvToBv 等 16 个)才需要动 client/method-names.ts。这张表是全仓唯一手写映射 —— 漏一个等于某个方法凭空消失,因此有测试拿四个平台的活 fetcher 对象逐个核对。

统一响应信封

所有对外 API 返回同一个信封:

import type {  } from '@ikenxuan/amagi'

declare const : <{ : string }>

if (.) {
  . // 收窄后是 { id: string }
} else {
  . // 收窄后是 AmagiError
}

三条硬约束:

  1. 顶层没有 code。 v6 用同一个 code 混装 HTTP 状态码、平台业务码与内部码;v7 各归各位 —— HTTP 状态在 error.http.status,平台业务码在 error.platform.code,amagi 自己的错误码在 error.code。成功信封因此没有任何多余字段。
  2. 成功分支没有 error 键,失败分支没有 data 键。 v6 用 undefined as never 声明占位、键却留在运行时对象里;v7 直接不声明。
  3. success 是唯一判别键。 if (r.success) 之后 data 可用、else 之后 error 可用 —— v6 的成功分支代码零改动。

另有运行时保证:信封永不 reject。参数校验失败、平台业务错误、网络错误、内部异常全部映射为 success: false。注意这是行为变化:v6 里参数校验失败会 throw,v7 里不再进 catch。

失败分支的 error 形状:

Prop

Type

成败判定收敛到一处:每个平台一份 judge.ts 纯函数,把原始响应映射为「成功 / 某个 kind」。v6 里 4 个 internal.ts 与 GlobalGetData 各自为政的判定逻辑(缺陷 A2)在 v7 里只剩这一个判定点。

事件总线与 meta

v6 的事件系统有两个问题:总线是全局单例(一个 amagiEvents 全库共用),且事件负载里没有任何关联 id(缺陷 10)—— 多实例并发时监听器分不清事件来自谁。v7 的两个对应变化:

实例级总线。 每个 amagi() 实例自带一条总线:client.events / client.on / client.once,实例之间互不串扰。静态 fetcher(amagi.douyinFetcher.fetchXxx(...),不经过 client 实例)不发事件 —— 它的签名是 v6 冻结的三参形式,没有装总线的位置,要观测就用 client 形态。

所有负载都带 meta。 15 个事件名(12 个与 v6 逐名对齐 + 3 个 v7 独占的 session:*)与各自的负载见事件系统,这里只列最常用的四个:

事件时机负载
api:success一次逻辑调用成功返回{ meta, data }
api:error一次逻辑调用失败返回{ meta, error }
http:request一次底层请求即将发出{ meta, trace }
http:response一次底层请求已经结束{ meta, trace }

meta 挂在每个信封与每条事件负载上:

Prop

Type

trace 里的每条请求还标注 reason:initial(首次)/ retry(重试)/ challenge(解出反爬挑战后的重放)/ page(翻页)/ segment(分段并发)/ prepare(前置请求)。一次「翻 3 页 + 1 次重试」的调用,attempts === 4、trace 里有 4 条记录 —— 重试叠乘(A4)与事件无法归因这两类问题从此有直接可观测的数字。

会话(登录)

扫码登录这类多步有状态流程不是端点:四个平台的 client.<platform>.login 都提供 qrcode()(新建会话)与 resume()(从 serialize() 的产物恢复);B站另有 refreshCredential 做 Cookie 续期。

引擎(runtime/session.ts)负责轮询循环、退避、超时、取消与 challenge 编排;平台策略(platforms/<platform>/session/qrcode.ts)只写协议细节。会话状态以 LoginState 的 phase 判别:pending(二维码就绪)→ scanned(已扫待确认)→ challenge(需应答短信 / 图形验证码)→ success(产出 credential);终止态为 expired / rejected / risk / failed。

消费方式三种:watch() 回调(onQrcode / onScanned / onChallenge / onSuccess 等)、for await(AsyncIterable)、手动单步(start() + next())。v6 抖音 passport 的 4 个顶层方法(requestPassportQrcode 等)作为 @deprecated 保留在静态 fetcher 上,新代码请走 login 会话。

术语

术语含义
端点(endpoint)一个可调用的平台接口,如 douyin.videoWork。v6 里叫 methodType
注册表(registry)一个平台全部端点声明的汇总对象
信封(envelope)AmagiResult<T>,包裹 data 或 error 的统一外层结构
判定(judge)把平台原始响应映射为「成功 / 某种 ErrorKind」的纯函数
规范化(normalize)把平台原始响应裁剪 / 整形为端点声明的返回类型
会话(session)多步有状态流程(如扫码登录),与单次请求的端点区分开
凭证(credential)会话成功后产出的登录态,统一为 { cookie, expiresAt?, raw }

最后更新于 2026-10-23

On this page