/** * 解析库(`@ikenxuan/amagi`)的版本要求:**必须 ≥ 7**(当前钉在 7.0.0-beta.5)。 * * 线上故障(用户反馈「抖音解析改炸了,同一个 cookie 上游代码能正常解析」): * 抖音现在要求 web 接口带上 `uifid` 与 `x-secsdk-web-signature`(Argus 风控 SDK 的签名), * 而 npm 上的 **latest 6.6.0 完全没有这套东西**,于是每个抖音接口都被拦成 * `403 Blocked by ArgusSecurityPlugin Uifid Not Found`。amagi 把这个响应包成 * `{code:500,message:"抖音数据获取失败"}`,插件再翻译成一句「Cookie 失效 / 被风控」—— * 用户怎么换 cookie 都没用。 * * secsdk / uifid 的实现只存在于 7.x(上游 karin-plugin-kkk 也是把这份新 amagi 打进包里的), * 所以这里**故意钉在 beta 上**:latest 反而是坏的。升级解析库时请一并确认抖音能解析。 */ import Client, { type AmagiError as AmagiErrorContract, type AmagiFailure, type AmagiSuccess, type SuccessBilibiliFetcher, type SuccessDouyinFetcher, type SuccessKuaishouFetcher, type SuccessXiaohongshuFetcher, // 风控挑战(验证页地址 + 票据):`kind === 'risk'` 且平台认得出这份响应时才有 type RiskChallenge, // 包级 fetcher:抖音的 passport(扫码登录)只在它上面,client 上绑定过的精简版没有这些方法 douyinFetcher as amagiDouyinFetcher } from '@ikenxuan/amagi' import { logger } from 'node-karin' import { Config } from './Config' import { createBilibiliLoginSession, createDouyinLoginSession } from './loginSession' /** v7 客户端实例 */ export type AmagiClient = ReturnType /** 四个平台的键,用来在类型层重建 client 形状 */ type PlatformKey = 'bilibili' | 'douyin' | 'kuaishou' | 'xiaohongshu' /** 把一个平台模块的 `fetcher` 换成「只保留成功分支」的那份类型 */ type WithSuccessFetcher = Omit & { fetcher: F } /** * 失败必抛的 client:四个平台的 `fetcher` 换成成功信封形态,其余键原样。 * * 运行时由 {@link throwOnFailure} 保证「失败一律抛」,类型上必须同步声明成成功分支 —— 否则 * 未收窄的 `AmagiResult` 让每一处 `.data` 都带 `| undefined`。`Success*Fetcher` 只能用 amagi * 提供的那份:fetcher 方法是泛型签名,自己 infer 会按约束实例化类型参数,默认值丢失,`data` 退化成 `unknown`。 */ type ThrowingClient = Omit & { bilibili: WithSuccessFetcher douyin: WithSuccessFetcher kuaishou: WithSuccessFetcher xiaohongshu: WithSuccessFetcher } /** * 平台业务码。v7 的三种码各归各位:平台业务码 `error.platform.code`、HTTP 状态 `error.http.status`、 * amagi 自己的码 `error.code`(字符串)。按平台码分流的地方(B站 `-352` 风控、`-111` csrf 失效、 * `12061` 关闭评论区)要的是第一种;平台没给就退 HTTP 状态,再退 0。 */ const legacyCode = (error: AmagiErrorContract, envelope?: AmagiFailure): number => { const platformCode = error.platform?.code if (typeof platformCode === 'number') return platformCode if (typeof platformCode === 'string' && platformCode.trim() !== '' && Number.isFinite(Number(platformCode))) { return Number(platformCode) } // Koishi 移植补充:已发布的 amagi 6.6.0 失败信封是 // { success:false, error:{ errorDescription, requestType, requestUrl, responseCode }, message, code, data } // —— 平台业务码在 error.responseCode / 顶层 code 上,没有 v7 的 kind/platform/http 字段。 const anyError = error as any if (typeof anyError.responseCode === 'number') return anyError.responseCode if (typeof (envelope as any)?.code === 'number') return (envelope as any).code return error.http?.status ?? 0 } /** * 一行说清一次失败:`[大类/码] 平台原文 (端点 平台码 HTTP requestId attempts)`。 * `message` 会被 `logger.warn(...)` 直接拼进日志行,塞整个信封的转储会把日志顶成一屏 ANSI。 */ const describeFailure = (envelope: AmagiFailure): string => { const error: any = envelope.error ?? {} const meta: any = (envelope as any).meta const kind = error.kind ?? (error.requestType ? 'api' : 'unknown') const code = error.code ?? (envelope as any).code const reason = error.errorDescription ?? error.message ?? (envelope as any).message ?? '未知错误' const facts = [ error.requestType ?? meta?.endpoint, error.requestUrl ? String(error.requestUrl).split('?')[0].slice(0, 80) : undefined, error.responseCode === undefined ? undefined : `平台码 ${error.responseCode}`, error.platform?.code === undefined ? undefined : `平台码 ${error.platform.code}`, error.http?.status === undefined ? undefined : `HTTP ${error.http.status}`, meta?.requestId === undefined ? undefined : `requestId=${meta.requestId}`, meta?.attempts === undefined ? undefined : `attempts=${meta.attempts}` ].filter((part): part is string => typeof part === 'string' && part !== '') return `[${kind}/${code ?? '-'}] ${reason}${facts.length > 0 ? ` (${facts.join(' ')})` : ''}` } /** Amagi 失败异常:`message` 是一行摘要,结构化字段各有属性,完整信封在 {@link AmagiError.envelope} */ export class AmagiError extends Error { /** 平台业务码,见 {@link legacyCode} */ code: number /** 平台原始响应体。v7 只在 `debug: true` 下填 `error.raw`(B站风控要读里面的 `v_voucher`) */ data: any /** v7 错误契约本体,等价于失败信封的 `error` */ rawError: AmagiErrorContract /** 错误大类 */ kind: AmagiErrorContract['kind'] /** amagi 自己的字符串错误码 */ amagiCode: AmagiErrorContract['code'] /** 平台返回的原文,不带前缀与归因 */ reason: string /** 是否值得重试 */ retryable: boolean /** 真实发生的 HTTP 状态 */ httpStatus?: number /** 参数校验的字段级错误,仅 `kind === 'validation'` 时有 */ issues?: AmagiErrorContract['issues'] /** 整条失败信封,`meta.requestId` / `attempts` / `durationMs` 在里面 */ envelope: AmagiFailure /** * 风控挑战:**「怎么过去」这条必要信息**,不受 amagi 的 `debug` 开关影响。 * * ⚠️ 只有**装了提取器**的平台才给。amagi 的 `PLATFORM_RUNTIME` 里目前**只有快手** * 装了 `challenge: parseKuaishouCaptcha` —— 抖音 / B站 / 小红书恒为 `undefined`。 * 所以「有没有地址」要按平台分开看,不能因为 `kind === 'risk'` 就以为一定有验证页。 * * 以前这一项**没有透出到包装类上**(在 `rawError.challenge` 里), * 于是快手明明给了滑块页地址、插件却拿不到,只能回一句「过一会儿再试试」。 */ challenge?: RiskChallenge constructor(envelope: AmagiFailure) { const error: any = (envelope as any).error ?? {} super(describeFailure(envelope)) this.name = 'AmagiError' this.code = legacyCode(error, envelope) this.challenge = error.challenge // 平台原始响应体:v7 在 error.raw,6.6.0 在信封顶层 data(B站风控要读里面的 v_voucher) this.data = error.raw ?? (envelope as any).data this.rawError = error this.kind = (error.kind ?? 'api') as AmagiErrorContract['kind'] this.amagiCode = error.code ?? (envelope as any).code this.reason = error.errorDescription ?? error.message ?? (envelope as any).message ?? '未知错误' this.retryable = typeof error.retryable === 'boolean' ? error.retryable : false this.httpStatus = error.http?.status this.issues = error.issues this.envelope = envelope } } /** * 判断一个值是不是失败信封。**只认 `success`**:v7 信封顶层没有 `code`(三种码各归各位), * 拿 `code` 当特征判别恒假 —— 失败被原样透传、`try/catch` 全失效、取数失败但流程继续,且零编译错误。 */ const isFailureEnvelope = (value: unknown): value is AmagiFailure => { if (!value || typeof value !== 'object') return false const envelope = value as Partial return envelope.success === false && typeof envelope.message === 'string' && !!envelope.error } /** 判断是不是 thenable,用来只包装异步方法 */ const isThenable = (value: unknown): value is PromiseLike => !!value && (typeof value === 'object' || typeof value === 'function') && typeof (value as PromiseLike).then === 'function' const throwOnFailure = (target: T): T => { /** * **摊平成普通对象,而不是再套一层 Proxy。** * * amagi 自己的 fetcher 就是「带 `ownKeys` / `getOwnPropertyDescriptor` trap、方法按需物化」 * 的 Proxy。我们再包一层 Proxy 的话,外面只要做一次 `Object.getOwnPropertyDescriptor` * (打桩时最常见的 `client.douyin.fetcher.parseWork = fn` 就会走到)就会进到里层 trap, * 触发 V8 的 Proxy 不变量检查并抛: * * TypeError: 'getOwnPropertyDescriptor' on proxy: trap returned descriptor for property * 'parseWork' that is incompatible with the existing property in the proxy target * * 结果是所有「用打桩跑一遍链路」的冒烟集体炸掉(2026-09-26 升级 amagi 7 时踩到)。 * 摊平成普通对象既没有这个坑,也顺手把每个方法的包装**缓存**了一次,调用少一层代理。 */ const wrapped: Record = {} for (const key of Reflect.ownKeys(target)) { const value = (target as any)[key] if (typeof value === 'function') { const fn = value.bind(target) wrapped[key] = (...args: unknown[]) => { const returned = fn(...args) // 同步方法原样放行:包成 async 会把返回值套一层 Promise,破坏语义 if (!isThenable(returned)) return returned return returned.then((result: unknown) => { if (isFailureEnvelope(result)) throw new AmagiError(result) return result }) } continue } wrapped[key] = value && typeof value === 'object' && !Array.isArray(value) ? throwOnFailure(value) : value } return wrapped as T } export class AmagiBase { /** * 原始 v7 客户端。 * * `events` / `on` / `once` / `login` / `startServer` 从这里取 —— 它们不是 fetcher, * 不该被「失败必抛」的代理碰(`on` 返回退订函数,包成 async 就废了)。 */ rawAmagi: AmagiClient /** 解析库实例,四个平台的 fetcher 失败即抛 */ amagi: ThrowingClient /** 当前客户端使用的配置快照,用于避免文件监听与显式重载造成重复初始化 */ private configSignature: string constructor() { const client = this.createAmagiClient() this.rawAmagi = client this.amagi = this.wrapAmagiClient(client) this.configSignature = this.getConfigSignature() } /** 获取会影响 Amagi 运行状态的配置快照 */ private getConfigSignature = () => JSON.stringify(Config.amagi) /** 创建解析库实例 */ protected createAmagiClient = (): AmagiClient => { const amagi = Config.amagi return Client({ cookies: amagi.cookies || {}, request: { timeout: amagi.timeout, headers: { 'User-Agent': amagi['User-Agent'] }, proxy: amagi.proxy?.switch ? amagi.proxy : false }, // B站风控要读失败响应里的 `v_voucher`,而 v7 只在 debug 下才填 `error.raw` // —— 不开的话 `AmagiError.data` 连键都没有,整条风控验证流程静默失效 debug: true }) } /** * 重载配置 - 重新创建 Amagi Client 实例 * @returns 配置发生变化并完成重载时返回 true */ reloadConfig() { const nextConfigSignature = this.getConfigSignature() if (nextConfigSignature === this.configSignature) { logger.debug('[AmagiClient] 配置未变化,跳过重复重载') return false } logger.debug('[AmagiClient] 检测到配置变化,正在重载...') const client = this.createAmagiClient() this.rawAmagi = client this.amagi = this.wrapAmagiClient(client) this.configSignature = nextConfigSignature logger.debug('[AmagiClient] 配置重载完成') return true } /** 只把四个平台的 fetcher 换成失败必抛形态,client 的其余键原样带过去 */ protected wrapAmagiClient = (client: AmagiClient): ThrowingClient => ({ ...client, bilibili: { ...client.bilibili, fetcher: throwOnFailure(client.bilibili.fetcher) }, douyin: { ...client.douyin, fetcher: throwOnFailure(client.douyin.fetcher) }, kuaishou: { ...client.kuaishou, fetcher: throwOnFailure(client.kuaishou.fetcher) }, xiaohongshu: { ...client.xiaohongshu, fetcher: throwOnFailure(client.xiaohongshu.fetcher) } }) as ThrowingClient } /** 软错误码:命中这些平台业务码时不抛异常,按失败信封返回。Bilibili `12061` - UP主已关闭评论区 */ export const SOFT_ERROR_CODES = { BILIBILI_COMMENTS_DISABLED: 12061 } as const /** 被 {@link softFetch} 放行的软失败:失败信封 + 压平后的平台业务码 */ export type SoftFailure = AmagiFailure & { code: number } /** {@link softFetch} 的返回:要么成功信封,要么被放行的软失败 */ export type SoftResult = AmagiSuccess | SoftFailure /** * 调用 amagi fetcher 方法,允许特定平台业务码以失败信封返回而不是抛异常。 * @param fn - 经过代理包装的 amagi 方法调用 * @param allowedCodes - 不应抛出异常的平台业务码列表 * @returns 成功信封,或命中 `allowedCodes` 的软失败 */ export const softFetch = async (fn: () => Promise>, allowedCodes: number[]): Promise> => { try { return await fn() } catch (err) { if (err instanceof AmagiError && allowedCodes.includes(err.code)) { return { ...err.envelope, code: err.code } } throw err } } /** * {@link softFetch} 的结果是否命中某个软错误码。 * * 是类型守卫而不是 `result.code === x`:`code` 的类型是 `number` 不是字面量, * 直接比较不会收窄联合,`else` 分支里的 `data` 仍是 `T | undefined`。 */ export const isSoftFailure = (result: SoftResult, ...codes: number[]): result is SoftFailure => !result.success && codes.includes(result.code) const amagiClientInstance = new AmagiBase() type AmagiReloadListener = () => void /** 需要随 Amagi Client 一同刷新的运行时资源,例如已经挂载的 HTTP Router */ const amagiReloadListeners = new Set() /** * 注册 Amagi 配置重载监听器。 * @param listener - 重载后要执行的回调 * @returns 注销当前监听器的函数 */ export const registerAmagiReloadListener = (listener: AmagiReloadListener) => { amagiReloadListeners.add(listener) return () => amagiReloadListeners.delete(listener) } /** * 四个平台的「失败必抛」fetcher。 * * 类型必须显式标注:不标的话 TS 要在声明产物里展开 fetcher 的结构,而响应类型桶只导出 * `BilibiliVideoInfoResponse` 这样的具名别名、不导出底层的 `*_V0`,展开时叫不出名来报 TS2883。 */ export let bilibiliFetcher: SuccessBilibiliFetcher = amagiClientInstance.amagi.bilibili.fetcher export let douyinFetcher: SuccessDouyinFetcher = amagiClientInstance.amagi.douyin.fetcher export let kuaishouFetcher: SuccessKuaishouFetcher = amagiClientInstance.amagi.kuaishou.fetcher export let xiaohongshuFetcher: SuccessXiaohongshuFetcher = amagiClientInstance.amagi.xiaohongshu.fetcher /** * 原始 v7 客户端。扫码登录会话(`douyin.login` / `bilibili.login`)、实例级事件总线 * (`events` / `on` / `once`)与 `startServer` 都从这里取。 * * 返回类型显式写成 {@link AmagiClient}:不写的话 TS 要在声明产物里展开这个结构, * 会碰到 amagi 内部才叫得出名字的类型(如 `SearchNoteType`),报 TS4023。 */ /** * 原始 v7 客户端,并补上 v7 才有的登录会话命名空间(`bilibili.login` / `douyin.login`)。 * * 已发布的 amagi 6.6.0 的 client 上没有 `login`,上游登录代码会直接读崩, * 因此这里用 Proxy 在读取 `bilibili` / `douyin` 时挂上本地实现的会话(见 ./loginSession)。 * 其余属性原样转发,并对函数做 bind,保证 `events` / `on` / `once` / `startServer` 行为不变。 */ const clientWrapperCache = new WeakMap() export const getAmagiClient = (): AmagiClient => { const raw = amagiClientInstance.rawAmagi as unknown as object const cached = clientWrapperCache.get(raw) if (cached) return cached // 登录相关接口在**模块级 fetcher** 上(client.douyin.fetcher 是绑定过的精简版,没有 passport 方法); // 这里用惰性代理,保证 reloadAmagiConfig() 重建 client 后拿到的仍是新实例。 const lazyFetcher = (get: () => any) => new Proxy({}, { get: (_target, prop) => Reflect.get(get(), prop) }) const wrapped = new Proxy(raw, { get (target: any, prop: string | symbol) { if (prop === 'bilibili') { return { ...target.bilibili, login: createBilibiliLoginSession(lazyFetcher(() => bilibiliFetcher)) } } if (prop === 'douyin') { return { ...target.douyin, login: createDouyinLoginSession(lazyFetcher(() => amagiDouyinFetcher)) } } const value = Reflect.get(target, prop, target) return typeof value === 'function' ? value.bind(target) : value } }) as unknown as AmagiClient clientWrapperCache.set(raw, wrapped) return wrapped } export const reloadAmagiConfig = () => { if (!amagiClientInstance.reloadConfig()) return false // ESM 的 `export let` 是实时绑定:Client 重建后要同步替换,否则调用方还持有旧实例截取的引用 bilibiliFetcher = amagiClientInstance.amagi.bilibili.fetcher douyinFetcher = amagiClientInstance.amagi.douyin.fetcher kuaishouFetcher = amagiClientInstance.amagi.kuaishou.fetcher xiaohongshuFetcher = amagiClientInstance.amagi.xiaohongshu.fetcher for (const listener of amagiReloadListeners) { try { listener() } catch (error) { logger.error(`[AmagiClient] 运行时资源重载失败: ${error}`) } } return true }