事件系统
实例级事件总线的 15 个事件、带 meta 的负载,以及 v6 全局单例的去处
事件系统
amagi 把日志、每一条底层请求、每一次调用的结果和扫码登录会话都以事件形式暴露 —— 包本身不依赖任何日志库,落地方式由你决定。
先分清两条总线
事件名两边完全一样,负载不一样。判断依据是总线的来源,而非事件名:
| 总线 | 怎么拿 | 事件名 | 负载 | 状态 |
|---|---|---|---|---|
| 实例总线(v7) | client.events / client.on / client.once | 15 个 | 都带 meta,没有 timestamp | v7 起用这一条 |
| 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:mark | startServer 开始监听时 | 同上 |
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.platform | d.meta.platform |
d.methodType | d.meta.endpoint(值也变了:videoWork → douyin.videoWork) |
d.duration | d.meta.durationMs |
d.statusCode | d.trace.status(http:*)/ d.status(http:error) |
d.errorMessage | d.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.url | d.trace.url |
d.response(api:success) | d.data |
d.timestamp | 没有对应键 —— 归因改用 meta.requestId(一次逻辑调用一个),要时间自己取时钟 |
迁移时可参考迁移页的读法对照表(只改实例总线上的监听器;amagiEvents 上的一行都不用动 —— 那条总线的负载确实一字未变,改了才是把对的改坏)。d.timestamp 没有对应键,删掉或改用自己的时钟,两者按需选。
完整的形状对照与破坏性分级(TS 用户是 B 档、JS 用户是 A 档)见迁移指南。
最后更新于 2026-10-23