koishi-plugin-kkk 3.4.0 → 3.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/lib/compat/node-karin.js +34 -1
- package/lib/karin/module/utils/ErrorReport.js +148 -15
- package/lib/karin/platform/douyin/douyin.js +18 -2
- package/package.json +1 -1
- package/scripts/smoke-douyin.cjs +30 -2
- package/src/compat/node-karin.ts +33 -1
- package/src/karin/module/utils/ErrorReport.ts +138 -15
- package/src/karin/platform/douyin/douyin.ts +18 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 3.4.2
|
|
4
|
+
|
|
5
|
+
### 出错上报的内容更全,也更好看懂
|
|
6
|
+
|
|
7
|
+
- 上报的「来源信息」以前常常是一排「—」:推送任务的会话是合成的,没有群号这些东西。现在改用框架自己的接口补:
|
|
8
|
+
机器人名、群名(用 bot.getGuild 查出来)、用户名都会显示,卡在哪一步(阶段)也会自动标出来 —— 一眼就知道是谁在哪个群、发的什么。
|
|
9
|
+
- **顺便把配置也传上去了**,复现问题时有依据:你改过的设置、画质、发送内容、推送列表都会带过去。
|
|
10
|
+
两条硬规矩:**Cookie 一个都不传**(接口库里的 SESSDATA / bili_jct / 抖音那一串整块丢掉),凡是名字像 token / secret / password 的字段一律显示成 ***。
|
|
11
|
+
- **日志只传本插件(kkk)的行**:以前会把整台机器所有插件的日志尾巴都传上去,现在只挑 kkk 相关的,看的人不用再大海捞针。
|
|
12
|
+
|
|
13
|
+
### 机器人掉线时不再是一句英文报错
|
|
14
|
+
|
|
15
|
+
- 适配器掉线后,机器人的对象还在,但底层连接已经没了 —— 这时推送会让适配器内部炸出一句
|
|
16
|
+
TypeError: this._request is not a function,你只看到一堆英文堆栈。
|
|
17
|
+
现在会提前拦住,并说清楚:机器人 2092582839 当前不在线(status=0),这条消息发不出去;等它重新上线后下次推送会正常发出。
|
|
18
|
+
- 指定的机器人掉线时**不会偷偷换成另一个机器人发**(那样会串号),只会如实报出来。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 3.4.1
|
|
23
|
+
|
|
24
|
+
### 抖音解析失败时的提示看得懂了
|
|
25
|
+
|
|
26
|
+
- 抖音接口没返回数据时(常见原因:Cookie 过期、被风控,或者链接 / 作品已失效),以前会直接抛一个英文错误
|
|
27
|
+
TypeError: Cannot read properties of undefined (reading 'aweme_detail'),你收到的是一张满屏英文的错误卡片。
|
|
28
|
+
现在会明说是哪一类问题:抖音没有返回这条作品的数据(错误码 8:风控校验失败),常见原因:Cookie 失效、被风控,或者这条链接已经失效 / 作品已删除。
|
|
29
|
+
- 日志里会留下接口的原始错误码和返回内容,排查时一眼就能看出是风控还是 Cookie 过期。
|
|
30
|
+
- 顺带:抖音的冒烟测试补了「接口被风控」这个场景,以后不会再退回那种英文报错。
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
3
34
|
## 3.4.0
|
|
4
35
|
|
|
5
36
|
> 这一版修的都是「用起来别扭」的地方:图片不再重复发、面板不再和卡片挤在一起、
|
|
@@ -19,6 +50,7 @@
|
|
|
19
50
|
### 出错的时候:上报照旧,发什么你说了算
|
|
20
51
|
|
|
21
52
|
- 新增开关「**出错只发文字(不渲染错误卡片)**」(面板 → 通用 → 错误上报,默认关闭)。
|
|
53
|
+
想法来自 @Jingmozhiyu 的 PR #1
|
|
22
54
|
打开后出错只在群里发一行:错误信息已上传,ID:xxx —— 上传的东西一点不少,只是省掉十几秒渲染和一张几 MB 的大图。
|
|
23
55
|
- 以前错误提示里那个加群链接是拿群号拼的,点开是 404;现在换成真正的加群链接,能直接点进去。
|
|
24
56
|
- 以前遇到「一行日志里塞了几 MB 的图片数据」这种报错,错误卡片要渲染十几分钟、最后还发不出去;现在这种超长内容会自动折起来,几秒钟出图。
|
package/lib/compat/node-karin.js
CHANGED
|
@@ -644,6 +644,29 @@ const contactGroup = (groupId) => ({ peer: groupId, guildId: groupId, isGroup: t
|
|
|
644
644
|
exports.contactGroup = contactGroup;
|
|
645
645
|
const contactFriend = (userId) => ({ peer: 'private:' + userId, userId, isGroup: false });
|
|
646
646
|
exports.contactFriend = contactFriend;
|
|
647
|
+
/**
|
|
648
|
+
* 这个机器人现在能不能发消息。
|
|
649
|
+
*
|
|
650
|
+
* 线上真实报错(B站推送):
|
|
651
|
+
*
|
|
652
|
+
* TypeError: this._request is not a function
|
|
653
|
+
* at _Internal._get (adapter-onebot/lib/index.js:115)
|
|
654
|
+
* at ... sendGroupMsg ...
|
|
655
|
+
* at async Object.sendMsg (koishi-plugin-kkk/lib/compat/node-karin.js:684)
|
|
656
|
+
*
|
|
657
|
+
* 适配器**掉线后 bot 对象仍然留在 ctx.bots 里**,但底层连接(OneBot 的 _request)已经没了,
|
|
658
|
+
* 于是 sendMessage 一路传到适配器内部才炸成一句莫名其妙的英文。
|
|
659
|
+
* 这里提前认出来,给一句人话(用户要求:日志和错误卡片要能看懂)。
|
|
660
|
+
*
|
|
661
|
+
* Koishi 的 Bot.status:0 离线 / 1 在线 / 2 连接中 / 3 断开 / 4 重连。
|
|
662
|
+
*/
|
|
663
|
+
function isBotOnline(bot) {
|
|
664
|
+
const status = bot?.status;
|
|
665
|
+
// 没有 status 字段的(老适配器 / 测试桩)不拦,免得把好机器人也挡住
|
|
666
|
+
if (typeof status !== 'number')
|
|
667
|
+
return true;
|
|
668
|
+
return status === 1 || status === 2;
|
|
669
|
+
}
|
|
647
670
|
function resolveBot(selfId) {
|
|
648
671
|
const runtime = (0, runtime_1.tryGetRuntime)();
|
|
649
672
|
if (!runtime)
|
|
@@ -652,7 +675,12 @@ function resolveBot(selfId) {
|
|
|
652
675
|
if (!bots?.length)
|
|
653
676
|
return undefined;
|
|
654
677
|
const matched = selfId ? bots.find((bot) => bot.selfId === selfId || bot.user?.id === selfId) : undefined;
|
|
655
|
-
|
|
678
|
+
// 指定了 selfId:就算它掉线也返回它 —— 不能偷偷换一个机器人发,那样会串号(调用方会报「不在线」)
|
|
679
|
+
if (matched)
|
|
680
|
+
return new KkkBot(matched);
|
|
681
|
+
// 没指定:优先挑在线的
|
|
682
|
+
const online = bots.find((bot) => isBotOnline(bot));
|
|
683
|
+
return online ? new KkkBot(online) : (bots[0] ? new KkkBot(bots[0]) : undefined);
|
|
656
684
|
}
|
|
657
685
|
function command(reg, handler, options) {
|
|
658
686
|
const registration = { reg, handler, options, order: commandOrder++ };
|
|
@@ -676,6 +704,11 @@ async function sendMsg(selfId, contact, content, _options) {
|
|
|
676
704
|
const bot = resolveBot(selfId);
|
|
677
705
|
if (!bot)
|
|
678
706
|
throw new Error('[kkk] 没有可用的机器人实例,无法发送消息');
|
|
707
|
+
const rawBot = bot.bot;
|
|
708
|
+
if (!isBotOnline(rawBot)) {
|
|
709
|
+
throw new Error('[kkk] 机器人 ' + String(selfId || rawBot?.selfId || '') + ' 当前不在线(status=' + String(rawBot?.status)
|
|
710
|
+
+ '),这条消息发不出去。等它重新上线后下次推送会正常发出;如果是长期不在线,检查一下适配器连接');
|
|
711
|
+
}
|
|
679
712
|
const channelId = peerOf(contact);
|
|
680
713
|
/** 解析结果合并转发:这条也走漏斗(karin.sendMsg 是业务代码另一条常用出口) */
|
|
681
714
|
if ((0, forward_collect_1.collectForward)(channelId, normalizeContent(content))) {
|
|
@@ -24,6 +24,7 @@ const fold_1 = require("../../../compat/fold");
|
|
|
24
24
|
const runtime_1 = require("../../../compat/runtime");
|
|
25
25
|
const qqOptions_1 = require("../../../qqOptions");
|
|
26
26
|
const root_1 = require("../../root");
|
|
27
|
+
const Config_1 = require("./Config");
|
|
27
28
|
const build_metadata_1 = require("./build-metadata");
|
|
28
29
|
/**
|
|
29
30
|
* 收集站地址:**写死,不开放配置**。
|
|
@@ -186,6 +187,108 @@ const makeReportId = () => {
|
|
|
186
187
|
const stamp = now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate()) + '-' + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds());
|
|
187
188
|
return stamp + '-' + Math.random().toString(36).slice(2, 8);
|
|
188
189
|
};
|
|
190
|
+
/**
|
|
191
|
+
* **用框架原生 API 补齐来源信息**(用户要求:上报里「来源信息」不能是一片「—」)。
|
|
192
|
+
*
|
|
193
|
+
* 以前只读 event 上的几个字段 —— 推送任务(B站推送 / 抖音推送)的 event 是**合成**的,
|
|
194
|
+
* 根本没有 channelId / guildId,于是详情页里群号、频道全是空的。
|
|
195
|
+
* 现在:
|
|
196
|
+
* - 先从 event 上取(消息场景最准);
|
|
197
|
+
* - 取不到就退回 bot 的原生字段(platform / selfId / user.name);
|
|
198
|
+
* - 群名 / 用户名尽量用 bot.getGuild() 这类**原生接口**查出来(查不到就算了,绝不让上报卡住)。
|
|
199
|
+
*/
|
|
200
|
+
const collectSource = async (input, adapterInfo) => {
|
|
201
|
+
const event = input.event ?? {};
|
|
202
|
+
const bot = event?.bot?.bot ?? event?.bot;
|
|
203
|
+
const channelId = String(event?.channelId ?? event?.contact?.peer ?? event?.guildId ?? '');
|
|
204
|
+
const guildId = String(event?.guildId ?? event?.contact?.guildId ?? channelId);
|
|
205
|
+
const userId = String(event?.userId ?? event?.sender?.userId ?? event?.author?.userId ?? '');
|
|
206
|
+
const source = {
|
|
207
|
+
platform: String(bot?.platform ?? event?.platform ?? adapterInfo?.platform ?? ''),
|
|
208
|
+
route: String(input.route ?? ''),
|
|
209
|
+
stage: String(input.stage ?? ''),
|
|
210
|
+
business: String(input.business ?? ''),
|
|
211
|
+
guildId,
|
|
212
|
+
channelId,
|
|
213
|
+
userId,
|
|
214
|
+
selfId: String(event?.selfId ?? bot?.selfId ?? bot?.account?.selfId ?? ''),
|
|
215
|
+
// 机器人名 / 用户名:能取到就填,详情页里比一串数字好认
|
|
216
|
+
botName: String(bot?.user?.name ?? bot?.username ?? bot?.account?.name ?? ''),
|
|
217
|
+
userName: String(event?.sender?.nick ?? event?.author?.nick ?? event?.username ?? event?.sender?.name ?? ''),
|
|
218
|
+
guildName: '',
|
|
219
|
+
command: (0, fold_1.foldAndTruncate)(String(event?.msg ?? event?.content ?? ''), 300)
|
|
220
|
+
};
|
|
221
|
+
// 群名(框架原生接口;取不到就留空,不抛错)
|
|
222
|
+
if (!source.guildName && channelId && typeof bot?.getGuild === 'function') {
|
|
223
|
+
try {
|
|
224
|
+
const guild = await bot.getGuild(channelId);
|
|
225
|
+
source.guildName = String(guild?.name ?? guild?.guildName ?? '');
|
|
226
|
+
}
|
|
227
|
+
catch { /* 频道查不到就算了 */ }
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 阶段(stage):解析链路里抛的错会带一句「步骤「渲染评论区」失败」,
|
|
231
|
+
* 直接抽出来当阶段,详情页上就能看出是卡在哪一步(route 同理,有就带)。
|
|
232
|
+
*/
|
|
233
|
+
if (!source.stage) {
|
|
234
|
+
const message = String(input.error?.message ?? '');
|
|
235
|
+
const step = /步骤「([^」]+)」/.exec(message) ?? /步骤失败[((]([^))]+)[))]/.exec(message);
|
|
236
|
+
if (step)
|
|
237
|
+
source.stage = step[1];
|
|
238
|
+
}
|
|
239
|
+
return source;
|
|
240
|
+
};
|
|
241
|
+
/** 上游配置的几段(与 config.json 的顶层键一致);Config 是只有 get 的 Proxy,只能按名字取 */
|
|
242
|
+
const UPSTREAM_SECTIONS = ['amagi', 'app', 'douyin', 'bilibili', 'kuaishou', 'xiaohongshu', 'pushlist'];
|
|
243
|
+
/** 这些键一律不上传(cookie / 密钥 / 密码之类) */
|
|
244
|
+
const SECRET_KEY = /cookie|token|secret|password|passwd|sessdata|bili_jct|csrf|authorization|apikey|api_key/i;
|
|
245
|
+
/**
|
|
246
|
+
* **上报用的配置快照**:把「改过什么」也带上去,复现问题才有依据。
|
|
247
|
+
*
|
|
248
|
+
* 两条硬规矩:
|
|
249
|
+
* 1. **cookie 一个都不传**(SESSDATA / bili_jct / 抖音那一长串都在 amagi.cookies 里,整块丢掉);
|
|
250
|
+
* 2. 其余凡是键名像密钥/密码的(token / secret / password / apiKey…)也一律换成 '***'。
|
|
251
|
+
*/
|
|
252
|
+
const snapshotConfig = (upstream, qq) => {
|
|
253
|
+
const clean = (value, depth) => {
|
|
254
|
+
if (depth > 6 || value === null || value === undefined)
|
|
255
|
+
return value;
|
|
256
|
+
if (Array.isArray(value))
|
|
257
|
+
return value.slice(0, 200).map((item) => clean(item, depth + 1));
|
|
258
|
+
if (typeof value !== 'object')
|
|
259
|
+
return typeof value === 'string' ? (0, fold_1.foldAndTruncate)(value, 500) : value;
|
|
260
|
+
const out = {};
|
|
261
|
+
for (const [key, item] of Object.entries(value)) {
|
|
262
|
+
if (SECRET_KEY.test(key)) {
|
|
263
|
+
out[key] = '***';
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
out[key] = clean(item, depth + 1);
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
};
|
|
270
|
+
const upstreamClean = clean(upstream ?? {}, 0);
|
|
271
|
+
// cookie 整块不要:键名可能不叫 cookie(amagi.cookies.bilibili 才是),所以直接在源头删掉
|
|
272
|
+
if (upstreamClean?.amagi && typeof upstreamClean.amagi === 'object')
|
|
273
|
+
delete upstreamClean.amagi.cookies;
|
|
274
|
+
return { qq: clean(qq, 0), upstream: upstreamClean };
|
|
275
|
+
};
|
|
276
|
+
/** 日志只留本项目(kkk)的行:日志文件是 JSON 行,控制台那份是人读的文本行,两种都要认 */
|
|
277
|
+
const isOwnLogLine = (line) => {
|
|
278
|
+
const text = String(line ?? '').trim();
|
|
279
|
+
if (!text)
|
|
280
|
+
return false;
|
|
281
|
+
if (text.startsWith('{')) {
|
|
282
|
+
try {
|
|
283
|
+
const record = JSON.parse(text);
|
|
284
|
+
const name = String(record?.name ?? '');
|
|
285
|
+
// 本插件的日志 name 是 kkk;带 paths 的是它的作用域
|
|
286
|
+
return name === 'kkk' || name === root_1.Root.pluginName || (Array.isArray(record?.meta?.paths) && record.meta.paths.includes('uclubw'));
|
|
287
|
+
}
|
|
288
|
+
catch { /* 不是完整 JSON,按文本处理 */ }
|
|
289
|
+
}
|
|
290
|
+
return /\bkkk\b/.test(text);
|
|
291
|
+
};
|
|
189
292
|
/**
|
|
190
293
|
* 上传一份错误报告。
|
|
191
294
|
*
|
|
@@ -203,22 +306,41 @@ const uploadErrorReport = async (input) => {
|
|
|
203
306
|
const id = makeReportId();
|
|
204
307
|
const lines = (input.logs ?? []).slice(-Math.max(config.logLines, 0))
|
|
205
308
|
.map((line) => (0, fold_1.foldAndTruncate)(line, LOG_LINE_LIMIT));
|
|
206
|
-
const
|
|
309
|
+
const envLogsRaw = readLogFileTail(Math.min(config.logLines, 300));
|
|
310
|
+
/** 宿主日志文件里**只留本项目(kkk)的行**(用户要求:别把整台机器所有插件的日志都传上去) */
|
|
311
|
+
const envLogs = { ...envLogsRaw, lines: envLogsRaw.lines.filter((line) => isOwnLogLine(line)) };
|
|
312
|
+
const source = await collectSource(input, adapterInfo);
|
|
313
|
+
const runtimeConfig = ((0, runtime_1.tryGetRuntime)()?.config ?? {});
|
|
314
|
+
const configSnapshot = (() => {
|
|
315
|
+
try {
|
|
316
|
+
/**
|
|
317
|
+
* 上游那份(接口库 / 各平台 / 推送列表)以 **Config** 为准:它是 config.json 的读取代理,
|
|
318
|
+
* 也是实际生效的那份;运行时 config 上的 `upstream` 只在控制台保存过才会有。
|
|
319
|
+
*
|
|
320
|
+
* 注意 Config 是个**只有 get 的 Proxy**(没有 ownKeys),直接 Object.entries 会得到空对象 ——
|
|
321
|
+
* 所以按已知的几段逐个取值再拼。
|
|
322
|
+
*/
|
|
323
|
+
const upstream = {};
|
|
324
|
+
for (const section of UPSTREAM_SECTIONS) {
|
|
325
|
+
try {
|
|
326
|
+
const value = Config_1.Config[section];
|
|
327
|
+
if (value !== undefined)
|
|
328
|
+
upstream[section] = value;
|
|
329
|
+
}
|
|
330
|
+
catch { /* 某一段读不到就跳过 */ }
|
|
331
|
+
}
|
|
332
|
+
return snapshotConfig(upstream, (0, qqOptions_1.readQqOptions)(runtimeConfig));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
node_karin_1.logger.debug('[错误上报] 配置快照生成失败(忽略): ' + String(error?.message ?? error));
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
})();
|
|
207
339
|
const payload = {
|
|
208
340
|
id,
|
|
209
341
|
time: Date.now(),
|
|
210
342
|
plugin: { name: root_1.Root.pluginName, version: root_1.Root.pluginVersion },
|
|
211
|
-
source
|
|
212
|
-
platform: String(input.event?.bot?.platform ?? input.event?.platform ?? adapterInfo?.platform ?? ''),
|
|
213
|
-
route: String(input.route ?? ''),
|
|
214
|
-
stage: String(input.stage ?? ''),
|
|
215
|
-
business: String(input.business ?? ''),
|
|
216
|
-
guildId: String(input.event?.guildId ?? ''),
|
|
217
|
-
channelId: String(input.event?.channelId ?? ''),
|
|
218
|
-
userId: String(input.event?.userId ?? ''),
|
|
219
|
-
selfId: String(input.event?.selfId ?? ''),
|
|
220
|
-
command: (0, fold_1.foldAndTruncate)(String(input.event?.msg ?? input.event?.content ?? ''), 300)
|
|
221
|
-
},
|
|
343
|
+
source,
|
|
222
344
|
error: {
|
|
223
345
|
name: String(error?.name ?? 'Error'),
|
|
224
346
|
message: (0, fold_1.foldAndTruncate)(error?.message ?? String(error), fold_1.MAX_CAPTURED_LOG_CHARS),
|
|
@@ -247,16 +369,27 @@ const uploadErrorReport = async (input) => {
|
|
|
247
369
|
// 宿主日志文件的尾部:整台机器最近的动静
|
|
248
370
|
envFile: envLogs.file ?? '',
|
|
249
371
|
envLines: envLogs.lines,
|
|
250
|
-
envTruncated: envLogs.truncated
|
|
372
|
+
envTruncated: envLogs.truncated,
|
|
373
|
+
// 只留本项目日志这件事要说清楚,免得看的人以为日志被截断了
|
|
374
|
+
envFiltered: 'kkk-only'
|
|
251
375
|
},
|
|
376
|
+
/** 配置快照:cookie 与密钥已剔除(见 snapshotConfig) */
|
|
377
|
+
config: configSnapshot,
|
|
252
378
|
extra: input.extra ?? {}
|
|
253
379
|
};
|
|
254
|
-
|
|
380
|
+
/**
|
|
381
|
+
* 体积兜底:太大就一层层砍(先去掉整份配置快照,再去掉宿主日志,最后砍本次日志)。
|
|
382
|
+
* 配置快照最占地方(几百个字段),但它对复现最有帮助,所以放在最后才丢。
|
|
383
|
+
*/
|
|
255
384
|
let body = Buffer.from(JSON.stringify(payload));
|
|
256
|
-
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
385
|
+
if (body.length > PAYLOAD_LIMIT_BYTES && payload.logs) {
|
|
257
386
|
payload.logs.envLines = [];
|
|
258
387
|
body = Buffer.from(JSON.stringify(payload));
|
|
259
388
|
}
|
|
389
|
+
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
390
|
+
payload.config = { dropped: '配置快照过大,已省略' };
|
|
391
|
+
body = Buffer.from(JSON.stringify(payload));
|
|
392
|
+
}
|
|
260
393
|
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
261
394
|
payload.logs.lines = payload.logs.lines.slice(-50);
|
|
262
395
|
payload.logs.count = payload.logs.lines.length;
|
|
@@ -91,8 +91,24 @@ class DouYin extends utils_1.Base {
|
|
|
91
91
|
const VideoData = await this.amagi.douyin.fetcher.parseWork({
|
|
92
92
|
aweme_id: data.aweme_id
|
|
93
93
|
});
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
/**
|
|
95
|
+
* **接口没给出作品详情时的兜底**(线上真实报错):
|
|
96
|
+
*
|
|
97
|
+
* TypeError: Cannot read properties of undefined (reading 'aweme_detail')
|
|
98
|
+
* at DouYin.DouyinHandler (douyin.js:94)
|
|
99
|
+
*
|
|
100
|
+
* 以前只挡了「aweme_detail === null」这一种 —— 而接口被风控 / Cookie 失效 / 链接失效时,
|
|
101
|
+
* 返回体里**连 data 都没有**,于是直接 TypeError,用户拿到的是一张满屏英文的错误卡片。
|
|
102
|
+
* 现在把整段摊开检查,并给一句人话 + 把原始响应写进日志(排查时才看得到错误码)。
|
|
103
|
+
*/
|
|
104
|
+
if (!VideoData || !VideoData.data || VideoData.data.aweme_detail === null || VideoData.data.aweme_detail === undefined) {
|
|
105
|
+
const raw = VideoData ?? {};
|
|
106
|
+
const code = raw.code ?? raw.data?.code;
|
|
107
|
+
const message = raw.message ?? raw.msg ?? raw.data?.message ?? raw.data?.msg;
|
|
108
|
+
node_karin_2.logger.error('[抖音] 接口没有返回作品详情: ' + JSON.stringify({ code, message, 顶层键: Object.keys(raw) }).slice(0, 300));
|
|
109
|
+
throw new Error('抖音没有返回这条作品的数据'
|
|
110
|
+
+ (code !== undefined ? '(错误码 ' + String(code) + (message ? ':' + String(message) : '') + ')' : '')
|
|
111
|
+
+ ',常见原因:Cookie 失效、被风控,或者这条链接已经失效 / 作品已删除。');
|
|
96
112
|
}
|
|
97
113
|
// 根据 API 返回的数据判断作品类型,而不是依赖 URL
|
|
98
114
|
// aweme_type: 0=视频, 68=图集, 163=文章
|
package/package.json
CHANGED
package/scripts/smoke-douyin.cjs
CHANGED
|
@@ -40,6 +40,9 @@ const LONG_URL = 'https://www.douyin.com/video/7123456789012345678'
|
|
|
40
40
|
axios.get = async () => ({ request: { res: { responseUrl: LONG_URL } }, data: '' })
|
|
41
41
|
|
|
42
42
|
// 2) 替换 amagi 的客户端工厂(每个 Base 实例都会新建 client,必须包住工厂)
|
|
43
|
+
/** 由末尾的第二个场景打开:让 parseWork 返回「风控」响应 */
|
|
44
|
+
let windy = false
|
|
45
|
+
|
|
43
46
|
const amagi = require('@ikenxuan/amagi')
|
|
44
47
|
const realFactory = amagi.default
|
|
45
48
|
|
|
@@ -94,7 +97,10 @@ function makeDetail () {
|
|
|
94
97
|
|
|
95
98
|
amagi.default = function (options) {
|
|
96
99
|
const client = realFactory(options)
|
|
97
|
-
|
|
100
|
+
// windy = true 时模拟「接口被风控」:返回体里连 data 都没有(见文件末尾的第二个场景)
|
|
101
|
+
client.douyin.fetcher.parseWork = async () => (windy
|
|
102
|
+
? { success: false, code: 8, message: '风控校验失败' }
|
|
103
|
+
: { success: true, code: 200, message: 'OK', data: { aweme_detail: makeDetail() } })
|
|
98
104
|
client.douyin.fetcher.fetchWorkComments = async () => ({ success: true, code: 200, message: 'OK', data: { comments: [], cursor: 0, has_more: 0, total: 0 } })
|
|
99
105
|
client.douyin.fetcher.fetchEmojiList = async () => ({ success: true, code: 200, message: 'OK', data: { emoji_list: [] } })
|
|
100
106
|
client.douyin.fetcher.fetchUserProfile = async () => ({
|
|
@@ -134,7 +140,8 @@ const middlewares = []
|
|
|
134
140
|
const originalMiddleware = ctx.middleware.bind(ctx)
|
|
135
141
|
ctx.middleware = (fn, ...rest) => { middlewares.push(fn); return originalMiddleware(fn, ...rest) }
|
|
136
142
|
|
|
137
|
-
|
|
143
|
+
// parseDedupe: false —— 下面有两个场景会解析同一条链接,去重会把第二个场景挡掉
|
|
144
|
+
ctx.plugin(plugin, { dataPath: dataRoot, debug: true, masters: ['12345'], webui: false, parseDedupe: false })
|
|
138
145
|
|
|
139
146
|
const makeSession = (content) => ({
|
|
140
147
|
content, selfId: '10000', userId: '12345', guildId: '456', channelId: '456',
|
|
@@ -170,6 +177,27 @@ setTimeout(async () => {
|
|
|
170
177
|
return '[' + type + '] ' + attrs
|
|
171
178
|
}).join(' | ').slice(0, 400))
|
|
172
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* 第二个场景:**接口被风控**(返回体里连 data 都没有)。
|
|
182
|
+
*
|
|
183
|
+
* 线上真实报错:以前只挡了 aweme_detail === null,风控时直接
|
|
184
|
+
* TypeError: Cannot read properties of undefined (reading 'aweme_detail') —— 用户拿到一张满屏英文的错误卡片。
|
|
185
|
+
* 现在应当给一句人话(并且日志里留下错误码)。
|
|
186
|
+
*/
|
|
187
|
+
console.log('\n=== 抖音接口被风控时的提示 ===')
|
|
188
|
+
windy = true
|
|
189
|
+
const before = sent.length
|
|
190
|
+
try {
|
|
191
|
+
await dispatch('https://www.douyin.com/video/7123456789012345678')
|
|
192
|
+
console.log(' ❌ 期望它抛错(这样才会走错误提示),但它正常跑完了')
|
|
193
|
+
process.exitCode = 1
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const message = String(error?.message ?? error)
|
|
196
|
+
const friendly = /抖音没有返回这条作品的数据/.test(message) && /风控校验失败/.test(message)
|
|
197
|
+
console.log((friendly ? ' ✅' : ' ❌') + ' 提示是人话: ' + message.slice(0, 140))
|
|
198
|
+
if (!friendly) process.exitCode = 1
|
|
199
|
+
if (sent.length !== before) console.log(' (顺带:还多发了 ' + (sent.length - before) + ' 条消息)')
|
|
200
|
+
}
|
|
173
201
|
} catch (error) {
|
|
174
202
|
console.error('抖音冒烟测试失败:', error && error.stack ? error.stack : error)
|
|
175
203
|
process.exitCode = 1
|
package/src/compat/node-karin.ts
CHANGED
|
@@ -656,13 +656,40 @@ let commandOrder = 0
|
|
|
656
656
|
export const contactGroup = (groupId: string): Contact => ({ peer: groupId, guildId: groupId, isGroup: true })
|
|
657
657
|
export const contactFriend = (userId: string): Contact => ({ peer: 'private:' + userId, userId, isGroup: false })
|
|
658
658
|
|
|
659
|
+
/**
|
|
660
|
+
* 这个机器人现在能不能发消息。
|
|
661
|
+
*
|
|
662
|
+
* 线上真实报错(B站推送):
|
|
663
|
+
*
|
|
664
|
+
* TypeError: this._request is not a function
|
|
665
|
+
* at _Internal._get (adapter-onebot/lib/index.js:115)
|
|
666
|
+
* at ... sendGroupMsg ...
|
|
667
|
+
* at async Object.sendMsg (koishi-plugin-kkk/lib/compat/node-karin.js:684)
|
|
668
|
+
*
|
|
669
|
+
* 适配器**掉线后 bot 对象仍然留在 ctx.bots 里**,但底层连接(OneBot 的 _request)已经没了,
|
|
670
|
+
* 于是 sendMessage 一路传到适配器内部才炸成一句莫名其妙的英文。
|
|
671
|
+
* 这里提前认出来,给一句人话(用户要求:日志和错误卡片要能看懂)。
|
|
672
|
+
*
|
|
673
|
+
* Koishi 的 Bot.status:0 离线 / 1 在线 / 2 连接中 / 3 断开 / 4 重连。
|
|
674
|
+
*/
|
|
675
|
+
function isBotOnline (bot: any): boolean {
|
|
676
|
+
const status = bot?.status
|
|
677
|
+
// 没有 status 字段的(老适配器 / 测试桩)不拦,免得把好机器人也挡住
|
|
678
|
+
if (typeof status !== 'number') return true
|
|
679
|
+
return status === 1 || status === 2
|
|
680
|
+
}
|
|
681
|
+
|
|
659
682
|
function resolveBot (selfId?: string): KkkBot | undefined {
|
|
660
683
|
const runtime = tryGetRuntime()
|
|
661
684
|
if (!runtime) return undefined
|
|
662
685
|
const bots = runtime.ctx.bots as unknown as Bot[]
|
|
663
686
|
if (!bots?.length) return undefined
|
|
664
687
|
const matched = selfId ? bots.find((bot) => bot.selfId === selfId || bot.user?.id === selfId) : undefined
|
|
665
|
-
|
|
688
|
+
// 指定了 selfId:就算它掉线也返回它 —— 不能偷偷换一个机器人发,那样会串号(调用方会报「不在线」)
|
|
689
|
+
if (matched) return new KkkBot(matched)
|
|
690
|
+
// 没指定:优先挑在线的
|
|
691
|
+
const online = bots.find((bot) => isBotOnline(bot))
|
|
692
|
+
return online ? new KkkBot(online) : (bots[0] ? new KkkBot(bots[0]) : undefined)
|
|
666
693
|
}
|
|
667
694
|
|
|
668
695
|
function command (reg: RegExp | string, handler: (...args: any[]) => any, options?: Record<string, any>) {
|
|
@@ -688,6 +715,11 @@ function on (event: string, handler: (...args: any[]) => any) {
|
|
|
688
715
|
async function sendMsg (selfId: string, contact: Contact | string, content: any, _options?: any) {
|
|
689
716
|
const bot = resolveBot(selfId)
|
|
690
717
|
if (!bot) throw new Error('[kkk] 没有可用的机器人实例,无法发送消息')
|
|
718
|
+
const rawBot: any = (bot as any).bot
|
|
719
|
+
if (!isBotOnline(rawBot)) {
|
|
720
|
+
throw new Error('[kkk] 机器人 ' + String(selfId || rawBot?.selfId || '') + ' 当前不在线(status=' + String(rawBot?.status)
|
|
721
|
+
+ '),这条消息发不出去。等它重新上线后下次推送会正常发出;如果是长期不在线,检查一下适配器连接')
|
|
722
|
+
}
|
|
691
723
|
const channelId = peerOf(contact)
|
|
692
724
|
/** 解析结果合并转发:这条也走漏斗(karin.sendMsg 是业务代码另一条常用出口) */
|
|
693
725
|
if (collectForward(channelId, normalizeContent(content))) {
|
|
@@ -19,6 +19,7 @@ import { MAX_CAPTURED_LOG_CHARS, foldAndTruncate } from '../../../compat/fold'
|
|
|
19
19
|
import { tryGetRuntime } from '../../../compat/runtime'
|
|
20
20
|
import { readQqOptions } from '../../../qqOptions'
|
|
21
21
|
import { Root } from '../../root'
|
|
22
|
+
import { Config } from './Config'
|
|
22
23
|
import { getBuildMetadata } from './build-metadata'
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -192,6 +193,101 @@ const makeReportId = (): string => {
|
|
|
192
193
|
return stamp + '-' + Math.random().toString(36).slice(2, 8)
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/**
|
|
197
|
+
* **用框架原生 API 补齐来源信息**(用户要求:上报里「来源信息」不能是一片「—」)。
|
|
198
|
+
*
|
|
199
|
+
* 以前只读 event 上的几个字段 —— 推送任务(B站推送 / 抖音推送)的 event 是**合成**的,
|
|
200
|
+
* 根本没有 channelId / guildId,于是详情页里群号、频道全是空的。
|
|
201
|
+
* 现在:
|
|
202
|
+
* - 先从 event 上取(消息场景最准);
|
|
203
|
+
* - 取不到就退回 bot 的原生字段(platform / selfId / user.name);
|
|
204
|
+
* - 群名 / 用户名尽量用 bot.getGuild() 这类**原生接口**查出来(查不到就算了,绝不让上报卡住)。
|
|
205
|
+
*/
|
|
206
|
+
const collectSource = async (input: UploadErrorReportInput, adapterInfo: any): Promise<Record<string, any>> => {
|
|
207
|
+
const event: any = input.event ?? {}
|
|
208
|
+
const bot: any = event?.bot?.bot ?? event?.bot
|
|
209
|
+
const channelId = String(event?.channelId ?? event?.contact?.peer ?? event?.guildId ?? '')
|
|
210
|
+
const guildId = String(event?.guildId ?? event?.contact?.guildId ?? channelId)
|
|
211
|
+
const userId = String(event?.userId ?? event?.sender?.userId ?? event?.author?.userId ?? '')
|
|
212
|
+
const source: Record<string, any> = {
|
|
213
|
+
platform: String(bot?.platform ?? event?.platform ?? adapterInfo?.platform ?? ''),
|
|
214
|
+
route: String(input.route ?? ''),
|
|
215
|
+
stage: String(input.stage ?? ''),
|
|
216
|
+
business: String(input.business ?? ''),
|
|
217
|
+
guildId,
|
|
218
|
+
channelId,
|
|
219
|
+
userId,
|
|
220
|
+
selfId: String(event?.selfId ?? bot?.selfId ?? bot?.account?.selfId ?? ''),
|
|
221
|
+
// 机器人名 / 用户名:能取到就填,详情页里比一串数字好认
|
|
222
|
+
botName: String(bot?.user?.name ?? bot?.username ?? bot?.account?.name ?? ''),
|
|
223
|
+
userName: String(event?.sender?.nick ?? event?.author?.nick ?? event?.username ?? event?.sender?.name ?? ''),
|
|
224
|
+
guildName: '',
|
|
225
|
+
command: foldAndTruncate(String(event?.msg ?? event?.content ?? ''), 300)
|
|
226
|
+
}
|
|
227
|
+
// 群名(框架原生接口;取不到就留空,不抛错)
|
|
228
|
+
if (!source.guildName && channelId && typeof bot?.getGuild === 'function') {
|
|
229
|
+
try {
|
|
230
|
+
const guild: any = await bot.getGuild(channelId)
|
|
231
|
+
source.guildName = String(guild?.name ?? guild?.guildName ?? '')
|
|
232
|
+
} catch { /* 频道查不到就算了 */ }
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* 阶段(stage):解析链路里抛的错会带一句「步骤「渲染评论区」失败」,
|
|
236
|
+
* 直接抽出来当阶段,详情页上就能看出是卡在哪一步(route 同理,有就带)。
|
|
237
|
+
*/
|
|
238
|
+
if (!source.stage) {
|
|
239
|
+
const message = String((input.error as any)?.message ?? '')
|
|
240
|
+
const step = /步骤「([^」]+)」/.exec(message) ?? /步骤失败[((]([^))]+)[))]/.exec(message)
|
|
241
|
+
if (step) source.stage = step[1]
|
|
242
|
+
}
|
|
243
|
+
return source
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 上游配置的几段(与 config.json 的顶层键一致);Config 是只有 get 的 Proxy,只能按名字取 */
|
|
247
|
+
const UPSTREAM_SECTIONS = ['amagi', 'app', 'douyin', 'bilibili', 'kuaishou', 'xiaohongshu', 'pushlist']
|
|
248
|
+
|
|
249
|
+
/** 这些键一律不上传(cookie / 密钥 / 密码之类) */
|
|
250
|
+
const SECRET_KEY = /cookie|token|secret|password|passwd|sessdata|bili_jct|csrf|authorization|apikey|api_key/i
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* **上报用的配置快照**:把「改过什么」也带上去,复现问题才有依据。
|
|
254
|
+
*
|
|
255
|
+
* 两条硬规矩:
|
|
256
|
+
* 1. **cookie 一个都不传**(SESSDATA / bili_jct / 抖音那一长串都在 amagi.cookies 里,整块丢掉);
|
|
257
|
+
* 2. 其余凡是键名像密钥/密码的(token / secret / password / apiKey…)也一律换成 '***'。
|
|
258
|
+
*/
|
|
259
|
+
const snapshotConfig = (upstream: any, qq: Record<string, any>): Record<string, any> => {
|
|
260
|
+
const clean = (value: any, depth: number): any => {
|
|
261
|
+
if (depth > 6 || value === null || value === undefined) return value
|
|
262
|
+
if (Array.isArray(value)) return value.slice(0, 200).map((item) => clean(item, depth + 1))
|
|
263
|
+
if (typeof value !== 'object') return typeof value === 'string' ? foldAndTruncate(value, 500) : value
|
|
264
|
+
const out: Record<string, any> = {}
|
|
265
|
+
for (const [key, item] of Object.entries(value)) {
|
|
266
|
+
if (SECRET_KEY.test(key)) { out[key] = '***'; continue }
|
|
267
|
+
out[key] = clean(item, depth + 1)
|
|
268
|
+
}
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
271
|
+
const upstreamClean = clean(upstream ?? {}, 0) as Record<string, any>
|
|
272
|
+
// cookie 整块不要:键名可能不叫 cookie(amagi.cookies.bilibili 才是),所以直接在源头删掉
|
|
273
|
+
if (upstreamClean?.amagi && typeof upstreamClean.amagi === 'object') delete upstreamClean.amagi.cookies
|
|
274
|
+
return { qq: clean(qq, 0), upstream: upstreamClean }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 日志只留本项目(kkk)的行:日志文件是 JSON 行,控制台那份是人读的文本行,两种都要认 */
|
|
278
|
+
const isOwnLogLine = (line: string): boolean => {
|
|
279
|
+
const text = String(line ?? '').trim()
|
|
280
|
+
if (!text) return false
|
|
281
|
+
if (text.startsWith('{')) {
|
|
282
|
+
try {
|
|
283
|
+
const record = JSON.parse(text)
|
|
284
|
+
const name = String(record?.name ?? '')
|
|
285
|
+
// 本插件的日志 name 是 kkk;带 paths 的是它的作用域
|
|
286
|
+
return name === 'kkk' || name === Root.pluginName || (Array.isArray(record?.meta?.paths) && record.meta.paths.includes('uclubw'))
|
|
287
|
+
} catch { /* 不是完整 JSON,按文本处理 */ }
|
|
288
|
+
}
|
|
289
|
+
return /\bkkk\b/.test(text)
|
|
290
|
+
}
|
|
195
291
|
export interface UploadErrorReportInput {
|
|
196
292
|
error: Error
|
|
197
293
|
/** 业务名(解析 / 推送…) */
|
|
@@ -226,23 +322,39 @@ export const uploadErrorReport = async (input: UploadErrorReportInput): Promise<
|
|
|
226
322
|
const id = makeReportId()
|
|
227
323
|
const lines = (input.logs ?? []).slice(-Math.max(config.logLines, 0))
|
|
228
324
|
.map((line) => foldAndTruncate(line, LOG_LINE_LIMIT))
|
|
229
|
-
const
|
|
325
|
+
const envLogsRaw = readLogFileTail(Math.min(config.logLines, 300))
|
|
326
|
+
/** 宿主日志文件里**只留本项目(kkk)的行**(用户要求:别把整台机器所有插件的日志都传上去) */
|
|
327
|
+
const envLogs = { ...envLogsRaw, lines: envLogsRaw.lines.filter((line) => isOwnLogLine(line)) }
|
|
328
|
+
const source = await collectSource(input, adapterInfo)
|
|
329
|
+
const runtimeConfig: any = (tryGetRuntime()?.config ?? {}) as any
|
|
330
|
+
const configSnapshot = (() => {
|
|
331
|
+
try {
|
|
332
|
+
/**
|
|
333
|
+
* 上游那份(接口库 / 各平台 / 推送列表)以 **Config** 为准:它是 config.json 的读取代理,
|
|
334
|
+
* 也是实际生效的那份;运行时 config 上的 `upstream` 只在控制台保存过才会有。
|
|
335
|
+
*
|
|
336
|
+
* 注意 Config 是个**只有 get 的 Proxy**(没有 ownKeys),直接 Object.entries 会得到空对象 ——
|
|
337
|
+
* 所以按已知的几段逐个取值再拼。
|
|
338
|
+
*/
|
|
339
|
+
const upstream: Record<string, any> = {}
|
|
340
|
+
for (const section of UPSTREAM_SECTIONS) {
|
|
341
|
+
try {
|
|
342
|
+
const value = (Config as any)[section]
|
|
343
|
+
if (value !== undefined) upstream[section] = value
|
|
344
|
+
} catch { /* 某一段读不到就跳过 */ }
|
|
345
|
+
}
|
|
346
|
+
return snapshotConfig(upstream, readQqOptions(runtimeConfig))
|
|
347
|
+
} catch (error: any) {
|
|
348
|
+
logger.debug('[错误上报] 配置快照生成失败(忽略): ' + String(error?.message ?? error))
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
})()
|
|
230
352
|
|
|
231
353
|
const payload: Record<string, any> = {
|
|
232
354
|
id,
|
|
233
355
|
time: Date.now(),
|
|
234
356
|
plugin: { name: Root.pluginName, version: Root.pluginVersion },
|
|
235
|
-
source
|
|
236
|
-
platform: String(input.event?.bot?.platform ?? input.event?.platform ?? adapterInfo?.platform ?? ''),
|
|
237
|
-
route: String(input.route ?? ''),
|
|
238
|
-
stage: String(input.stage ?? ''),
|
|
239
|
-
business: String(input.business ?? ''),
|
|
240
|
-
guildId: String(input.event?.guildId ?? ''),
|
|
241
|
-
channelId: String(input.event?.channelId ?? ''),
|
|
242
|
-
userId: String(input.event?.userId ?? ''),
|
|
243
|
-
selfId: String(input.event?.selfId ?? ''),
|
|
244
|
-
command: foldAndTruncate(String(input.event?.msg ?? input.event?.content ?? ''), 300)
|
|
245
|
-
},
|
|
357
|
+
source,
|
|
246
358
|
error: {
|
|
247
359
|
name: String(error?.name ?? 'Error'),
|
|
248
360
|
message: foldAndTruncate(error?.message ?? String(error), MAX_CAPTURED_LOG_CHARS),
|
|
@@ -271,17 +383,28 @@ export const uploadErrorReport = async (input: UploadErrorReportInput): Promise<
|
|
|
271
383
|
// 宿主日志文件的尾部:整台机器最近的动静
|
|
272
384
|
envFile: envLogs.file ?? '',
|
|
273
385
|
envLines: envLogs.lines,
|
|
274
|
-
envTruncated: envLogs.truncated
|
|
386
|
+
envTruncated: envLogs.truncated,
|
|
387
|
+
// 只留本项目日志这件事要说清楚,免得看的人以为日志被截断了
|
|
388
|
+
envFiltered: 'kkk-only'
|
|
275
389
|
},
|
|
390
|
+
/** 配置快照:cookie 与密钥已剔除(见 snapshotConfig) */
|
|
391
|
+
config: configSnapshot,
|
|
276
392
|
extra: input.extra ?? {}
|
|
277
393
|
}
|
|
278
394
|
|
|
279
|
-
|
|
395
|
+
/**
|
|
396
|
+
* 体积兜底:太大就一层层砍(先去掉整份配置快照,再去掉宿主日志,最后砍本次日志)。
|
|
397
|
+
* 配置快照最占地方(几百个字段),但它对复现最有帮助,所以放在最后才丢。
|
|
398
|
+
*/
|
|
280
399
|
let body = Buffer.from(JSON.stringify(payload))
|
|
281
|
-
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
400
|
+
if (body.length > PAYLOAD_LIMIT_BYTES && payload.logs) {
|
|
282
401
|
payload.logs.envLines = []
|
|
283
402
|
body = Buffer.from(JSON.stringify(payload))
|
|
284
403
|
}
|
|
404
|
+
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
405
|
+
payload.config = { dropped: '配置快照过大,已省略' }
|
|
406
|
+
body = Buffer.from(JSON.stringify(payload))
|
|
407
|
+
}
|
|
285
408
|
if (body.length > PAYLOAD_LIMIT_BYTES) {
|
|
286
409
|
payload.logs.lines = payload.logs.lines.slice(-50)
|
|
287
410
|
payload.logs.count = payload.logs.lines.length
|
|
@@ -115,8 +115,24 @@ export class DouYin extends Base {
|
|
|
115
115
|
aweme_id: data.aweme_id
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
/**
|
|
119
|
+
* **接口没给出作品详情时的兜底**(线上真实报错):
|
|
120
|
+
*
|
|
121
|
+
* TypeError: Cannot read properties of undefined (reading 'aweme_detail')
|
|
122
|
+
* at DouYin.DouyinHandler (douyin.js:94)
|
|
123
|
+
*
|
|
124
|
+
* 以前只挡了「aweme_detail === null」这一种 —— 而接口被风控 / Cookie 失效 / 链接失效时,
|
|
125
|
+
* 返回体里**连 data 都没有**,于是直接 TypeError,用户拿到的是一张满屏英文的错误卡片。
|
|
126
|
+
* 现在把整段摊开检查,并给一句人话 + 把原始响应写进日志(排查时才看得到错误码)。
|
|
127
|
+
*/
|
|
128
|
+
if (!VideoData || !(VideoData as any).data || (VideoData as any).data.aweme_detail === null || (VideoData as any).data.aweme_detail === undefined) {
|
|
129
|
+
const raw: any = VideoData ?? {}
|
|
130
|
+
const code = raw.code ?? raw.data?.code
|
|
131
|
+
const message = raw.message ?? raw.msg ?? raw.data?.message ?? raw.data?.msg
|
|
132
|
+
logger.error('[抖音] 接口没有返回作品详情: ' + JSON.stringify({ code, message, 顶层键: Object.keys(raw) }).slice(0, 300))
|
|
133
|
+
throw new Error('抖音没有返回这条作品的数据'
|
|
134
|
+
+ (code !== undefined ? '(错误码 ' + String(code) + (message ? ':' + String(message) : '') + ')' : '')
|
|
135
|
+
+ ',常见原因:Cookie 失效、被风控,或者这条链接已经失效 / 作品已删除。')
|
|
120
136
|
}
|
|
121
137
|
// 根据 API 返回的数据判断作品类型,而不是依赖 URL
|
|
122
138
|
// aweme_type: 0=视频, 68=图集, 163=文章
|