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

事件系统

实例级事件总线的 15 个事件、带 meta 的负载,以及 v6 全局单例的去处

事件系统

amagi 把日志、每一条底层请求、每一次调用的结果和扫码登录会话都以事件形式暴露 —— 包本身不依赖任何日志库,落地方式由你决定。

先分清两条总线

事件名两边完全一样,负载不一样。判断依据是总线的来源,而非事件名:

总线怎么拿事件名负载状态
实例总线(v7)client.events / client.on / client.once15 个都带 meta,没有 timestampv7 起用这一条
v6 全局单例amagiEvents(命名导出)、amagi.events / amagi.on(静态面)12 个v6 的 *EventData,带 timestamp: Date形状冻结、v8 移除

amagi(options) 返回的每个实例自带一条总线,实例之间互不串扰 —— 这是 v6「一个 amagiEvents 全库共用」修掉的地方(多实例并发时监听器分不清事件来自谁)。

v7 的执行管线一条事件都不往全局单例发。 还在写全局单例的只有两处 @deprecated 的 v6 路径:低层入口 fetchData / fetchResponse(network:* / log:warn / log:error)与抖音 passport 四方法(api:* / log:debug)。client.<平台>.fetcher.* 的调用只发实例总线;裸 Fetcher(douyinFetcher.*)一条都不发 —— 它的三参签名 是 v6 冻结的,没有挂总线的位置。要观测就用 client 形态,监听器挂在 client.events 上。

基本用法

client.events 是总线本体,client.on / client.once 是它的快捷方式(同一条总线):

import  from '@ikenxuan/amagi'

const  = ({ : { : 'ttwid=...' } })

// 一次逻辑调用成功:data 是信封里的 data,其余信息全在 meta 上
..('api:success', () => {
  .(`[${..}] ${..} 成功,耗时 ${..}ms`)
})

// 失败:error 是标准 AmagiError(kind / code / message / platform 原文码)
.('api:error', () => {
  .(`[${..}] ${..} 失败:${..} ${..}`)
})

// once 只触发一次
.('log:mark', () => .(.))

meta.endpoint 是端点全名(douyin.videoWork),不是 v6 的 methodType(videoWork)。

15 个事件

事件名什么时候发负载
api:success一次逻辑调用成功返回(覆盖全部 80 个端点){ meta, data }
api:error一次逻辑调用失败返回(含参数校验失败,此时 attempts 为 0){ meta, error }
http:request每发出一条底层请求{ meta, trace }
http:response每条底层请求结束(含非 2xx 与传输失败){ meta, trace }
http:error拿到了响应但状态码非 2xx,紧跟在 http:response 之后{ meta, trace, status }
network:retry一次失败即将退避重试{ meta, trace, code, errno?, status?, attempt, maxRetries, delayMs }
network:error请求始终没拿到响应、退避已用尽{ meta, trace, code, errno?, message, attempts }
log:warn每次退避重试一条(文案与 v6 逐字一致){ level, message, args?, meta? }
log:error传输层放弃时一条(文案与 v6 逐字一致)同上
log:markstartServer 开始监听时同上
log:info / log:debug没有 emit 点,见下方「声明了但不发的两个」同上
session:state扫码登录会话推进一步{ meta, state }
session:error会话终止于失败{ meta, error }
session:success会话拿到登录凭证{ meta, credential }

前 12 个名字与 v6 AmagiEventType 逐名对齐(一个都没少,监听写法从全局单例搬到实例总线时不会有事件名静默消失);session:* 三个是 v7 新增,v6 没有。

事件名列表本身也是导出的,写工具时可以直接遍历:

import , {  } from '@ikenxuan/amagi'

const  = ({})

// 15 个名字的只读元组,类型是 AmagiBusEventName
for (const  of ) {
  ..(, () => {
    .(, .?.)
  })
}

声明了但不发的两个

log:info 与 log:debug 的名字在类型里(on 能编译),但 v7 核心链路没有 emit 点:v7 新管线没有 info 级日志;v6 的 debug 日志在 v7 里由 partial: 'tolerate' + meta.trace 表达,另一处是已 @deprecated 的 passport 路径(它写的是全局单例)。这两个名字留着是因为它们是 log:* 五个级别里的一员,缺一个反而更奇怪。

meta:事件之间怎么关联

一次逻辑调用从头到尾只有一个 requestId,这次调用产生的每条事件都带着它 —— 于是「哪几条 HTTP 请求属于同一次调用」是可算的,不必靠时间戳猜:

import  from '@ikenxuan/amagi'

const  = ({ : { : '' } })
const  = new <string, number>()

..('http:request', () => {
  .(.., (.(..) ?? 0) + 1)
  .(.., .., ..)
})

..('api:success', () => {
  // 这两个数一定相等:meta.attempts 就是这次调用发出的请求数
  .(.., .., .(..))
})

trace.reason 说明这一条请求为什么会发出:initial(首次)/ retry(重试)/ challenge(解出反爬挑战后的重放,不退避、立即重发)/ page(翻页)/ segment(分段并发)/ prepare(前置请求,如换 guest cookie、取 wbi key)。

meta 的完整字段表见响应类型;http:* 负载里那一条请求明细是:

Prop

Type

http:request / http:response 的负载恒带 trace,不受 debug 开关影响 —— 想逐条观测请求就监听这两个事件。createClient({ debug: true }) 只影响 信封上的 meta.trace 快照与失败信封的 error.raw。meta.attempts 与开关无关, 一直是准的。

日志落地

log:warn / log:error 的文案与 v6 逐字一致,所以 v6 里靠它们看重试的监听器搬到实例总线上行为不变。事件负载只给文本,不带颜色(颜色是展示层的事,自己加):

import  from '@ikenxuan/amagi'

const  = ({})

..('log:warn', () => .(`[WARN] ${.}`, ...(. ?? [])))
..('log:error', () => .(`[ERROR] ${.}`, ...(. ?? [])))
..('log:mark', () => .(`[MARK] ${.}`))

log:* 的负载形状:

Prop

Type

meta 在 log:* 上是可选的 —— 不属于任何一次调用的日志(服务启动那条 log:mark)本来就没有 meta。

接给日志库

总线的类型是 EventBus(只导出类型,不导出构造器 —— 总线从 client.events 拿,不用自己造),所以接线函数可以写下来:

import , { type  } from '@ikenxuan/amagi'
import  from 'pino'

const  = ()

const  = (: ): void => {
  .('log:warn', () => .({ : .?. }, .))
  .('log:error', () => .({ : .?. }, .))
  .('api:error', () => .({ ...., : .. }, ..))
}

(({}).)

winston 同理:

import  from '@ikenxuan/amagi'
import winston from 'winston'

const  = winston.({
  : [new winston..()]
})

const  = ({})
..('log:warn', () => .(.))
..('log:error', () => .(.))
..('api:error', () => .(`${..}: ${..}`))

重试与网络失败

network:retry 在每次退避之前发一条,network:error 在彻底放弃时发一条;http:error 是「回来了但状态码不对」,两者的分界是有没有状态码:

import  from '@ikenxuan/amagi'

const  = ({ : { : '' } })

..('network:retry', () => {
  .(`第 ${.}/${.} 次重试,${.}ms 后重发:${.} ${. ?? .}`)
})

..('network:error', () => {
  .(`放弃:${.}(共发了 ${.} 次,最后一条 ${..})`)
})

..('http:error', () => {
  .(`HTTP ${.}:${..}`)
})

Prop

Type

Prop

Type

v6 的 errorCode 在 v7 里拆成两个字段:code 是归因错误码(RATE_LIMITED / TIMEOUT 这类),errno 是传输层原样的 errno(ECONNRESET,拿到了响应就没有)。请求 URL 去 trace.url 读。

登录会话事件

client.<平台>.login(四个平台都有)的扫码会话把每一步都发到同一条总线上(一个会话一个 requestId,meta.endpoint 形如 bilibili.login):

import  from '@ikenxuan/amagi'

const  = ({ : { : '' } })

// state.phase:pending → scanned → challenge → success,终止态 expired / rejected / risk / failed
..('session:state', () => .(.., ..))
..('session:success', () => .(..))
..('session:error', () => .(.., ..))

事件是旁路观测,不是消费会话的方式 —— 获取凭证请用 watch() 回调、for await 或手动单步,见项目架构的「会话(登录)」一节。

移除监听器

EventBus 的方法集与 EventEmitter 同名,但事件名与负载是类型安全的。监听器类型可以用 AmagiBusEventMap 的索引访问写下来(15 个负载类型不逐个占公开名):

import , { type AmagiBusEventMap } from '@ikenxuan/amagi'

const  = ({})

const  = (: AmagiBusEventMap['api:success']): void => {
  .(..)
}

..('api:success', )
.(..('api:success'))

..('api:success', )
..('api:success')

不传事件名的 removeAllListeners() 清空这条总线上的全部监听器 —— 因为总线是实例级的,这不会影响别的 client 实例。

监控示例

import  from '@ikenxuan/amagi'

const  = ({ : { : '' } })
const  = { : 0, : 0, : 0 }

..('api:success', () => {
  .++
  . += ..
})

..('api:error', () => {
  .++
  .(`✗ [${..}] ${..} - ${..}`)
})

(() => {
  const  = . > 0 ? . / . : 0
  .(`成功 ${.},失败 ${.},平均耗时 ${.(2)}ms`)
}, 60000)

v6 全局单例 amagiEvents

它一个字都没改:12 个事件名、*EventData 负载、timestamp: Date 全在,12 个 emit* 自由函数也全在。因此只有两种情况需要用到它:仍有未迁移完的 v6 监听代码,或你自己在用 emitLog* 向它发送事件。

import , {  } from '@ikenxuan/amagi'

// v6 负载:顶层平铺 + timestamp,没有 meta
.('network:retry', () => {
  .(., ., ..())
})

// 构造函数上的静态面就是这条总线(不是实例总线)
.('log:warn', () => .(., .))

别把这条总线上的读法照搬到 client.events 上(也别反过来):两边事件名一样, 但 v6 负载是顶层平铺的 platform / methodType / duration / timestamp, 实例总线是 meta + error / data / trace。TS 会当场报错,JS 只会读到 undefined。

从 v6 迁移:读法对照

把监听器从全局单例搬到 client.events 时,改的只是读法:

v6 读法(全局单例)v7 读法(实例总线)
d.platformd.meta.platform
d.methodTyped.meta.endpoint(值也变了:videoWork → douyin.videoWork)
d.durationd.meta.durationMs
d.statusCoded.trace.status(http:*)/ d.status(http:error)
d.errorMessaged.error.message(d.error 是完整的 AmagiError)
d.errorCode(api:*)d.error.platform?.code ?? d.error.code
d.errorCode(network:*)d.code + d.errno 两个字段
d.retries(network:error)d.attempts(语义是「一共发了几次」)
d.urld.trace.url
d.response(api:success)d.data
d.timestamp没有对应键 —— 归因改用 meta.requestId(一次逻辑调用一个),要时间自己取时钟

迁移时可参考迁移页的读法对照表(只改实例总线上的监听器;amagiEvents 上的一行都不用动 —— 那条总线的负载确实一字未变,改了才是把对的改坏)。d.timestamp 没有对应键,删掉或改用自己的时钟,两者按需选。

完整的形状对照与破坏性分级(TS 用户是 B 档、JS 用户是 A 档)见迁移指南。

最后更新于 2026-10-23

On this page