koishi-plugin-kkk 3.2.0 → 3.2.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 +11 -2
- package/assets/web/assets/index-BRROK0vh.js +1 -1
- package/lib/index.js +33 -11
- package/lib/karin/module/utils/CardParser.js +115 -25
- package/lib/karin/module/utils/ErrorReport.js +34 -21
- package/lib/qqFields.json +0 -43
- package/package.json +1 -1
- package/scripts/smoke-cardparse.cjs +58 -0
- package/src/index.ts +33 -11
- package/src/karin/module/utils/CardParser.ts +105 -23
- package/src/karin/module/utils/ErrorReport.ts +33 -20
- package/src/qqFields.json +2 -44
package/lib/index.js
CHANGED
|
@@ -704,18 +704,40 @@ function registerCommands(ctx, logger, autoParse) {
|
|
|
704
704
|
return;
|
|
705
705
|
}
|
|
706
706
|
if (resolved && resolved.candidates && resolved.candidates.length) {
|
|
707
|
-
const
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
707
|
+
const top = resolved.candidates.slice(0, 6);
|
|
708
|
+
const tip = '没能唯一确定这个作品(识别到:' + ((resolved.upName) || '未知') + '),下面是候选:';
|
|
709
|
+
const linkOf = (item) => item.platform === 'bilibili'
|
|
710
|
+
? 'https://www.bilibili.com/video/' + item.id
|
|
711
|
+
: 'https://www.douyin.com/video/' + item.id;
|
|
712
|
+
/**
|
|
713
|
+
* **按平台分成两种发法**。
|
|
714
|
+
*
|
|
715
|
+
* QQ 官方机器人认 markdown,候选直接排成表格、操作列是按钮,点一下就走解析(最省事)。
|
|
716
|
+
* 个人号(OneBot / NapCat)**不渲染 markdown**:实测这条候选消息发过去整条都发不出来 ——
|
|
717
|
+
* 用户只看到「正在提取卡片信息…」然后再无音讯,六个候选白搜。那边改成纯文本 + 完整链接,
|
|
718
|
+
* QQ 客户端会把裸链接变成可点的蓝色链接,复制粘贴也方便。
|
|
719
|
+
*/
|
|
720
|
+
const officialQq = /^qq/i.test(String(session.platform ?? ''));
|
|
721
|
+
if (officialQq) {
|
|
722
|
+
const { cmdInput } = await Promise.resolve().then(() => __importStar(require('./karin/module/utils/QqPanel')));
|
|
723
|
+
const table = ['| # | 标题 | UP / 作者 | 操作 |', '| :---: | :--- | :--- | :---: |'];
|
|
724
|
+
top.forEach((item, index) => {
|
|
725
|
+
const title = String(item.title || '(无标题)').replace(/[|\n]/g, ' ').slice(0, 26);
|
|
726
|
+
const author = String(item.author || '-').replace(/[|\n]/g, ' ').slice(0, 12);
|
|
727
|
+
table.push('| ' + (index + 1) + ' | ' + title + ' | ' + author + ' | ' + cmdInput('解析 ' + linkOf(item), '解析') + ' |');
|
|
728
|
+
});
|
|
729
|
+
await send([{ type: 'markdown', attrs: { content: tip + '点按钮直接解析' + String.fromCharCode(10) + table.join(String.fromCharCode(10)) } }]);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const lines = [tip];
|
|
733
|
+
top.forEach((item, index) => {
|
|
734
|
+
const title = String(item.title || '(无标题)').replace(/\s+/g, ' ').slice(0, 40);
|
|
735
|
+
const author = String(item.author || '-').replace(/\s+/g, ' ').slice(0, 16);
|
|
736
|
+
lines.push((index + 1) + '. ' + title + ' —— ' + author);
|
|
737
|
+
lines.push(linkOf(item));
|
|
716
738
|
});
|
|
717
|
-
|
|
718
|
-
await send(
|
|
739
|
+
lines.push('把想看的那个链接发给我就能解析。');
|
|
740
|
+
await send(lines.join(String.fromCharCode(10)));
|
|
719
741
|
return;
|
|
720
742
|
}
|
|
721
743
|
await send('没能从这张卡片里认出作品,直接发链接给我吧');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.resolveCardToUrl = exports.searchDouyinWorks = exports.searchBiliVideos = exports.extractUpName = exports.ocrImageText = exports.extractCardInfo = void 0;
|
|
3
|
+
exports.resolveCardToUrl = exports.searchDouyinWorks = exports.searchBiliVideos = exports.extractUpNames = exports.extractUpName = exports.ocrImageText = exports.extractCardInfo = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* QQ 卡片消息解析(小程序卡片 / 分享卡片)。
|
|
6
6
|
*
|
|
@@ -239,19 +239,66 @@ exports.ocrImageText = ocrImageText;
|
|
|
239
239
|
* 从 OCR 文本里认 UP 主名。
|
|
240
240
|
* B站个人卡片的排版是「昵称 / UP主 / 粉丝数…」,所以拿「UP主」上一行最稳。
|
|
241
241
|
*/
|
|
242
|
-
const extractUpName = (text) =>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
242
|
+
const extractUpName = (text) => (0, exports.extractUpNames)(text)[0] ?? '';
|
|
243
|
+
exports.extractUpName = extractUpName;
|
|
244
|
+
/** 明显不是昵称的标签词 */
|
|
245
|
+
const NICK_LABELS = new Set(['up', 'up主', 'upzhu', 'v', '粉丝', '关注', '获赞', '播放', '点赞', '弹幕', '投币', '收藏', '转发', '评论', '分享', '投稿', '作品', '简介', '主页', '更多', '展开', '未知', '半身像']);
|
|
246
|
+
/**
|
|
247
|
+
* 从 OCR 文本里认**所有可能**的 UP 主名(按可能性排序)。
|
|
248
|
+
*
|
|
249
|
+
* 为什么要多个:B站个人卡片实测长这样(OCR 把结构压扁了、顺序也不一定)——
|
|
250
|
+
*
|
|
251
|
+
* 雾小霜暗区突围 1,052 半身像 UP主 4993粉丝 1,087 *未知" 5.3万播放1806点赞 11弹幕
|
|
252
|
+
*
|
|
253
|
+
* 真名是**第一行的「雾小霜暗区突围」**,而「UP主」前一行是「半身像」(封面上的字)。
|
|
254
|
+
* 只取「UP主前一行」这条老规则就会拿「半身像」去搜,标题又对不上,于是六个候选一个都不敢选。
|
|
255
|
+
* 现在两条都当候选(首行 + UP主前后行),搜索端**任一命中**就算作者对上,
|
|
256
|
+
* 谁真的搜得到就用谁 —— 不再赌某一种排版。
|
|
257
|
+
*/
|
|
258
|
+
const extractUpNames = (text) => {
|
|
259
|
+
const raw = String(text ?? '');
|
|
260
|
+
// OCR 有时整段只有一行(空格分隔),这时按空白切
|
|
261
|
+
const byLine = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
262
|
+
const tokens = byLine.length >= 2 ? byLine : raw.split(/[\s\u3000|/]+/).map((item) => item.trim()).filter(Boolean);
|
|
263
|
+
const isStat = (line) => /(粉丝|播放|点赞|弹幕|投币|收藏|关注|转发|评论|分享|投稿)/.test(line) ||
|
|
264
|
+
/^[\d,.]+(\.\d+)?[万亿]?$/.test(line) ||
|
|
265
|
+
/^[**·.]+$/.test(line);
|
|
266
|
+
const clean = (line) => line
|
|
267
|
+
.replace(/^[**·\s]+/, '')
|
|
268
|
+
.replace(/[**·\s]+$/, '')
|
|
269
|
+
.replace(/["“”'']/g, '')
|
|
270
|
+
.trim();
|
|
271
|
+
const candidates = [];
|
|
272
|
+
const push = (line) => {
|
|
273
|
+
if (!line)
|
|
274
|
+
return;
|
|
275
|
+
const value = clean(line);
|
|
276
|
+
if (value.length < 2 || value.length > 20)
|
|
277
|
+
return;
|
|
278
|
+
if (isStat(value))
|
|
279
|
+
return;
|
|
280
|
+
if (NICK_LABELS.has(value.toLowerCase()))
|
|
281
|
+
return;
|
|
282
|
+
if (/^[\d,.万]+$/.test(value))
|
|
283
|
+
return;
|
|
284
|
+
if (!candidates.includes(value))
|
|
285
|
+
candidates.push(value);
|
|
286
|
+
};
|
|
287
|
+
// ① UP主 前后各一行/一个词
|
|
288
|
+
const idx = tokens.findIndex((token) => /^(up主|up|upzhu)$/i.test(clean(token)));
|
|
248
289
|
if (idx > 0)
|
|
249
|
-
|
|
250
|
-
if (idx
|
|
251
|
-
|
|
252
|
-
|
|
290
|
+
push(tokens[idx - 1]);
|
|
291
|
+
if (idx >= 0)
|
|
292
|
+
push(tokens[idx + 1]);
|
|
293
|
+
// ② 第一行(B站卡片昵称就在最上面)
|
|
294
|
+
push(tokens[0]);
|
|
295
|
+
// ③ 任何一行像「粉丝数」这种统计的**前面**一行
|
|
296
|
+
const statIdx = tokens.findIndex((token) => /粉丝|关注/.test(token));
|
|
297
|
+
if (statIdx > 0)
|
|
298
|
+
push(tokens[statIdx - 1]);
|
|
299
|
+
return candidates;
|
|
253
300
|
};
|
|
254
|
-
exports.
|
|
301
|
+
exports.extractUpNames = extractUpNames;
|
|
255
302
|
/* ------------------------------------------------------------------ *
|
|
256
303
|
* B站搜索(自带 Wbi 签名)
|
|
257
304
|
* ------------------------------------------------------------------ */
|
|
@@ -331,7 +378,13 @@ const searchBiliVideos = async (keyword, title = '', author = '', limit = 8) =>
|
|
|
331
378
|
}
|
|
332
379
|
const raw = Array.isArray(json?.data?.result) ? json.data.result : [];
|
|
333
380
|
const titleKey = normalizeText(title || keyword);
|
|
334
|
-
|
|
381
|
+
/**
|
|
382
|
+
* 作者可以有**多个候选**(卡片摘要里的那个 + OCR 认出来的那几个),
|
|
383
|
+
* 结果只要命中任意一个就算作者对上了,用命中的那个算分。
|
|
384
|
+
*/
|
|
385
|
+
const authorKeys = (Array.isArray(author) ? author : [author])
|
|
386
|
+
.map((item) => normalizeText(item))
|
|
387
|
+
.filter((item, index, list) => item.length >= 2 && list.indexOf(item) === index);
|
|
335
388
|
return raw
|
|
336
389
|
.filter((item) => item?.bvid)
|
|
337
390
|
.map((item, index) => {
|
|
@@ -342,13 +395,22 @@ const searchBiliVideos = async (keyword, title = '', author = '', limit = 8) =>
|
|
|
342
395
|
let score = 0;
|
|
343
396
|
let titleMatch = false;
|
|
344
397
|
let authorMatch = false;
|
|
345
|
-
|
|
398
|
+
let matchedAuthor = '';
|
|
399
|
+
for (const authorKey of authorKeys) {
|
|
400
|
+
if (!a)
|
|
401
|
+
break;
|
|
346
402
|
if (a === authorKey) {
|
|
347
403
|
score += 120;
|
|
348
404
|
authorMatch = true;
|
|
405
|
+
matchedAuthor = authorKey;
|
|
406
|
+
break;
|
|
349
407
|
}
|
|
350
|
-
|
|
351
|
-
|
|
408
|
+
if (a.includes(authorKey) || authorKey.includes(a)) {
|
|
409
|
+
// 模糊命中:分少一点,并且只取最好的那次
|
|
410
|
+
if (!authorMatch) {
|
|
411
|
+
score += 70;
|
|
412
|
+
matchedAuthor = authorKey;
|
|
413
|
+
}
|
|
352
414
|
authorMatch = true;
|
|
353
415
|
}
|
|
354
416
|
}
|
|
@@ -368,7 +430,7 @@ const searchBiliVideos = async (keyword, title = '', author = '', limit = 8) =>
|
|
|
368
430
|
score += Math.round(sim * 40);
|
|
369
431
|
}
|
|
370
432
|
}
|
|
371
|
-
return { bvid: String(item.bvid), title: itemTitle, author: itemAuthor, score: score - index, titleMatch, authorMatch };
|
|
433
|
+
return { bvid: String(item.bvid), title: itemTitle, author: itemAuthor, score: score - index, titleMatch, authorMatch, matchedAuthor };
|
|
372
434
|
})
|
|
373
435
|
.sort((left, right) => right.score - left.score)
|
|
374
436
|
.slice(0, Math.max(1, Math.min(20, limit)));
|
|
@@ -382,7 +444,7 @@ exports.searchBiliVideos = searchBiliVideos;
|
|
|
382
444
|
/* ------------------------------------------------------------------ *
|
|
383
445
|
* 抖音搜索(直接用 amagi 的 search 端点)
|
|
384
446
|
* ------------------------------------------------------------------ */
|
|
385
|
-
const searchDouyinWorks = async (keyword, limit = 8) => {
|
|
447
|
+
const searchDouyinWorks = async (keyword, limit = 8, authors = []) => {
|
|
386
448
|
try {
|
|
387
449
|
// 这版接口库的抖音 fetcher 不一定有 search(实测 6.6.0 上没有),没有就干脆跳过
|
|
388
450
|
const fetcher = amagiClient_1.douyinFetcher;
|
|
@@ -393,16 +455,30 @@ const searchDouyinWorks = async (keyword, limit = 8) => {
|
|
|
393
455
|
const res = await fetcher.search({ query: String(keyword ?? '').trim(), type: 'video', number: limit });
|
|
394
456
|
const list = res?.data?.data?.aweme_list ?? res?.data?.aweme_list ?? res?.aweme_list ?? [];
|
|
395
457
|
const titleKey = normalizeText(keyword);
|
|
458
|
+
const authorKeys = authors.map((item) => normalizeText(item)).filter((item) => item.length >= 2);
|
|
396
459
|
return list
|
|
397
460
|
.filter((item) => item?.aweme_id)
|
|
398
461
|
.map((item, index) => {
|
|
399
462
|
const desc = String(item.desc ?? '');
|
|
400
463
|
const sim = titleSimilarity(normalizeText(desc), titleKey);
|
|
464
|
+
// 作者对上一样加分:抖音搜索噪声大,光靠标题常常分不出是哪一条
|
|
465
|
+
const authorKey = normalizeText(String(item.author?.nickname ?? ''));
|
|
466
|
+
let authorScore = 0;
|
|
467
|
+
for (const key of authorKeys) {
|
|
468
|
+
if (!authorKey)
|
|
469
|
+
break;
|
|
470
|
+
if (authorKey === key) {
|
|
471
|
+
authorScore = 60;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
if (authorKey.includes(key) || key.includes(authorKey))
|
|
475
|
+
authorScore = Math.max(authorScore, 35);
|
|
476
|
+
}
|
|
401
477
|
return {
|
|
402
478
|
aweme_id: String(item.aweme_id),
|
|
403
479
|
desc,
|
|
404
480
|
author: String(item.author?.nickname ?? ''),
|
|
405
|
-
score: Math.round(sim * 100) - index
|
|
481
|
+
score: Math.round(sim * 100) + authorScore - index
|
|
406
482
|
};
|
|
407
483
|
})
|
|
408
484
|
.sort((left, right) => right.score - left.score)
|
|
@@ -429,7 +505,17 @@ const resolveCardToUrl = async (content) => {
|
|
|
429
505
|
}
|
|
430
506
|
// ② OCR 封面拿文字线索
|
|
431
507
|
const ocrText = await (0, exports.ocrImageText)(card.cover);
|
|
432
|
-
|
|
508
|
+
/**
|
|
509
|
+
* UP 主名可以有好几个来源:卡片摘要里的 author、OCR 里「UP主」前后行、OCR 首行。
|
|
510
|
+
*
|
|
511
|
+
* 实测踩过的坑:卡片摘要给的是「半身像」(封面上的字),OCR 首行才是真昵称
|
|
512
|
+
* 「雾小霜暗区突围」—— 只认一个来源时,作者永远匹配不上,六个候选一个都不敢选。
|
|
513
|
+
* 这里全部当候选,谁匹配上算谁的。
|
|
514
|
+
*/
|
|
515
|
+
const upNames = [card.author, ...(0, exports.extractUpNames)(ocrText)]
|
|
516
|
+
.map((item) => String(item ?? '').trim())
|
|
517
|
+
.filter((item, index, list) => item.length >= 2 && list.indexOf(item) === index);
|
|
518
|
+
const upName = upNames[0] ?? '';
|
|
433
519
|
const keyword = card.title || upName || ocrText.replace(/\s+/g, ' ').slice(0, 40);
|
|
434
520
|
if (!keyword) {
|
|
435
521
|
node_karin_1.logger.mark('[卡片解析] OCR 没有给出可用关键词');
|
|
@@ -441,19 +527,21 @@ const resolveCardToUrl = async (content) => {
|
|
|
441
527
|
const looksBili = /bilibili|哔哩|B站|UP主/i.test(hint);
|
|
442
528
|
// ③ 先按最可能的平台搜,命中就返回
|
|
443
529
|
const tryBili = async () => {
|
|
444
|
-
const hits = await (0, exports.searchBiliVideos)(keyword, card.title,
|
|
530
|
+
const hits = await (0, exports.searchBiliVideos)(keyword, card.title, upNames, 8);
|
|
445
531
|
const candidates = hits.slice(0, 6).map((item) => ({
|
|
446
532
|
platform: 'bilibili', id: item.bvid, title: item.title, author: item.author, score: item.score
|
|
447
533
|
}));
|
|
448
534
|
const strict = hits.filter((item) => item.authorMatch || item.titleMatch);
|
|
449
535
|
// 只有「标题和作者都命中」才敢自动继续,否则交给用户挑
|
|
450
536
|
const best = (strict.length ? strict : hits)[0];
|
|
451
|
-
if (best && best.titleMatch && best.authorMatch)
|
|
452
|
-
|
|
537
|
+
if (best && best.titleMatch && best.authorMatch) {
|
|
538
|
+
node_karin_1.logger.mark('[卡片解析] 作者命中「' + (best.matchedAuthor || '') + '」:' + best.author + ',标题: ' + best.title);
|
|
539
|
+
return { url: 'https://www.bilibili.com/video/' + best.bvid, candidates, matchedAuthor: best.author };
|
|
540
|
+
}
|
|
453
541
|
return { candidates };
|
|
454
542
|
};
|
|
455
543
|
const tryDouyin = async () => {
|
|
456
|
-
const hits = await (0, exports.searchDouyinWorks)(keyword || card.title, 8);
|
|
544
|
+
const hits = await (0, exports.searchDouyinWorks)(keyword || card.title, 8, upNames);
|
|
457
545
|
const candidates = hits.slice(0, 6).map((item) => ({
|
|
458
546
|
platform: 'douyin', id: item.aweme_id, title: item.desc, author: item.author, score: item.score
|
|
459
547
|
}));
|
|
@@ -471,7 +559,9 @@ const resolveCardToUrl = async (content) => {
|
|
|
471
559
|
candidates.push(...hit.candidates);
|
|
472
560
|
if (hit.url) {
|
|
473
561
|
node_karin_1.logger.mark('[卡片解析] 定位成功: ' + hit.url);
|
|
474
|
-
|
|
562
|
+
// 提示语里报「真正匹配上的那个 UP 名」,而不是卡片摘要里那个不准的
|
|
563
|
+
const matched = hit.matchedAuthor || upName;
|
|
564
|
+
return { url: hit.url, platform: hit.url.includes('bilibili') ? 'bilibili' : 'douyin', card, candidates, ocrText, upName: matched };
|
|
475
565
|
}
|
|
476
566
|
}
|
|
477
567
|
}
|
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.errorHelpSegments = exports.uploadErrorReport = exports.groupLinkOf = exports.
|
|
6
|
+
exports.errorHelpSegments = exports.uploadErrorReport = exports.groupLinkOf = exports.REPORT_LOG_LINES = exports.REPORT_GROUP = exports.REPORT_TOKEN = exports.REPORT_URL = void 0;
|
|
7
7
|
exports.reportConfig = reportConfig;
|
|
8
8
|
/**
|
|
9
9
|
* 错误上报。
|
|
@@ -25,37 +25,50 @@ const runtime_1 = require("../../../compat/runtime");
|
|
|
25
25
|
const qqOptions_1 = require("../../../qqOptions");
|
|
26
26
|
const root_1 = require("../../root");
|
|
27
27
|
const build_metadata_1 = require("./build-metadata");
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
/**
|
|
29
|
+
* 收集站地址:**写死,不开放配置**。
|
|
30
|
+
* 自建的人改这一行重新构建即可 —— 面板里只留「是否上传」一个开关,少一个能填错的地方。
|
|
31
|
+
*/
|
|
32
|
+
exports.REPORT_URL = 'https://err.tangbot.xyz';
|
|
33
|
+
/**
|
|
34
|
+
* 上报令牌:**写死**。
|
|
35
|
+
* 服务端 \`KKK_ERR_TOKEN\` 的默认值就是它,所以两边开箱即用、什么都不用配。
|
|
36
|
+
* 它写在开源插件里,等于公开 —— 服务端那边另有 IP 限流兜着,挡的是随机刷接口的流量。
|
|
37
|
+
*/
|
|
38
|
+
exports.REPORT_TOKEN = 'kkk-err-9f3c1d7a5b2e4c80';
|
|
39
|
+
/** 反馈群:写死(QQ 客户端点群链接就能进) */
|
|
40
|
+
exports.REPORT_GROUP = '1050229473';
|
|
41
|
+
/** 上报多少行日志:写死(本次请求的日志 + 宿主日志文件尾部各取这么多行) */
|
|
42
|
+
exports.REPORT_LOG_LINES = 200;
|
|
43
|
+
/**
|
|
44
|
+
* 上传超时(毫秒):超了就放弃,不拖住错误卡片。
|
|
45
|
+
*
|
|
46
|
+
* 上报是**在渲染错误卡片之前**做的(编号要印在卡片上),所以站点挂掉时这 5 秒会加到
|
|
47
|
+
* 报错送达上 —— 正常情况一次上传 50 毫秒左右,只有连不上站点才会等到超时。
|
|
48
|
+
*/
|
|
49
|
+
const UPLOAD_TIMEOUT_MS = 5000;
|
|
34
50
|
/** 上报日志单行最长字符数 */
|
|
35
51
|
const LOG_LINE_LIMIT = 2000;
|
|
36
52
|
/** 整份上报最多多大(压缩前),超了继续砍日志 */
|
|
37
53
|
const PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024;
|
|
38
54
|
/** 读取宿主日志文件时最多读多少字节 */
|
|
39
55
|
const LOG_FILE_TAIL_BYTES = 512 * 1024;
|
|
40
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* 上报配置。
|
|
58
|
+
*
|
|
59
|
+
* 面板里**只有「上传错误信息」一个开关**(通用 → 错误上报):地址、令牌、群号、日志行数全部写死,
|
|
60
|
+
* 用户没有可以填错的地方。关掉开关就完全不上传。
|
|
61
|
+
*/
|
|
41
62
|
function reportConfig() {
|
|
42
|
-
let
|
|
63
|
+
let enabled = true;
|
|
43
64
|
try {
|
|
44
|
-
options = (0, qqOptions_1.readQqOptions)(((0, runtime_1.tryGetRuntime)()?.config ?? {}));
|
|
65
|
+
const options = (0, qqOptions_1.readQqOptions)(((0, runtime_1.tryGetRuntime)()?.config ?? {}));
|
|
66
|
+
enabled = options.errorReportUpload !== false;
|
|
45
67
|
}
|
|
46
68
|
catch {
|
|
47
|
-
|
|
69
|
+
enabled = (0, runtime_1.tryGetRuntime)()?.config?.errorReportUpload !== false;
|
|
48
70
|
}
|
|
49
|
-
|
|
50
|
-
const logLines = Number(options.errorReportLogLines);
|
|
51
|
-
return {
|
|
52
|
-
enabled: options.errorReportUpload !== false,
|
|
53
|
-
// 末尾斜杠统一去掉,拼 /api/v1/reports 时不会出现双斜杠
|
|
54
|
-
url: url.replace(/\/+$/, ''),
|
|
55
|
-
token: String(options.errorReportToken ?? '').trim(),
|
|
56
|
-
logLines: Number.isFinite(logLines) ? Math.min(1000, Math.max(0, Math.trunc(logLines))) : 200,
|
|
57
|
-
group: String(options.errorReportGroup ?? '').trim() || exports.DEFAULT_REPORT_GROUP
|
|
58
|
-
};
|
|
71
|
+
return { enabled, url: exports.REPORT_URL, token: exports.REPORT_TOKEN, logLines: exports.REPORT_LOG_LINES, group: exports.REPORT_GROUP };
|
|
59
72
|
}
|
|
60
73
|
/** 反馈群链接:QQ 官方的加群链接格式,面板里只填群号 */
|
|
61
74
|
const groupLinkOf = (group) => 'https://qm.qq.com/q/' + group;
|
package/lib/qqFields.json
CHANGED
|
@@ -224,48 +224,5 @@
|
|
|
224
224
|
"default": true,
|
|
225
225
|
"label": "上传错误信息",
|
|
226
226
|
"description": "插件出错时,自动把**错误信息、运行环境(Koishi 版本、装了哪些插件、适配器)、最近的日志**上传到收集站,站长在网页上就能查到,不用你截图、也不用翻日志文件。**默认开启**。上传失败不影响报错本身(错误卡片照常发)。不想要就在这里关掉。"
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
"key": "errorReportUrl",
|
|
230
|
-
"group": "通用",
|
|
231
|
-
"renderIn": "app",
|
|
232
|
-
"section": "错误上报",
|
|
233
|
-
"type": "string",
|
|
234
|
-
"default": "https://err.tangbot.xyz",
|
|
235
|
-
"label": "上报服务器地址",
|
|
236
|
-
"description": "错误收集站的地址,**默认 https://err.tangbot.xyz**。自建的话填自己的域名(不要带结尾斜杠)。上报内容会 POST 到「地址 + /api/v1/reports」。"
|
|
237
|
-
},
|
|
238
|
-
{
|
|
239
|
-
"key": "errorReportToken",
|
|
240
|
-
"group": "通用",
|
|
241
|
-
"renderIn": "app",
|
|
242
|
-
"section": "错误上报",
|
|
243
|
-
"type": "string",
|
|
244
|
-
"default": "",
|
|
245
|
-
"secret": true,
|
|
246
|
-
"label": "上报令牌",
|
|
247
|
-
"description": "收集站要求的令牌,要和服务器端的 KKK_ERR_TOKEN 一致;填错会返回 401,日志里会写「上传失败 HTTP 401」。服务器启动时会打印这个令牌(没设就随机生成一个)。"
|
|
248
|
-
},
|
|
249
|
-
{
|
|
250
|
-
"key": "errorReportLogLines",
|
|
251
|
-
"group": "通用",
|
|
252
|
-
"renderIn": "app",
|
|
253
|
-
"section": "错误上报",
|
|
254
|
-
"type": "number",
|
|
255
|
-
"default": 200,
|
|
256
|
-
"min": 0,
|
|
257
|
-
"max": 1000,
|
|
258
|
-
"label": "上报日志行数",
|
|
259
|
-
"description": "上报时带多少行日志:本次请求捕获的日志 + 宿主日志文件的尾部(宿主那份最多 300 行)。0 = 完全不上传日志。单行超长的(比如 OneBot 把 4.6MB 的 base64 塞进报错)会自动折叠,不会把上报撑爆。"
|
|
260
|
-
},
|
|
261
|
-
{
|
|
262
|
-
"key": "errorReportGroup",
|
|
263
|
-
"group": "通用",
|
|
264
|
-
"renderIn": "app",
|
|
265
|
-
"section": "错误上报",
|
|
266
|
-
"type": "string",
|
|
267
|
-
"default": "1050229473",
|
|
268
|
-
"label": "问题反馈群号",
|
|
269
|
-
"description": "错误消息里带上这个群号,用户拿着上报编号进群提问。QQ 官方机器人会把它渲染成**可点的加群链接**(https://qm.qq.com/q/群号),个人号(NapCat 等)直接给纯文本链接,QQ 客户端同样会变成可点。"
|
|
270
227
|
}
|
|
271
228
|
]
|
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 卡片解析(OCR 找 UP 主)冒烟测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖线上踩过的那个坑:B站卡片的 OCR 文本里「UP主」**上一行不是昵称**,
|
|
5
|
+
* 真名在第一行 —— 老规则只看「UP主前一行」,于是拿封面上的「半身像」去搜,
|
|
6
|
+
* 标题又对不上,六个候选一个都不敢选(用户看到的「没能唯一定位…候选 6 条」)。
|
|
7
|
+
*
|
|
8
|
+
* 这里只测纯函数(不联网):候选名的顺序、噪声过滤、去重。
|
|
9
|
+
* 用法:node scripts/smoke-cardparse.cjs
|
|
10
|
+
*/
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
|
|
13
|
+
const pluginRoot = path.resolve(__dirname, '..')
|
|
14
|
+
const libRoot = path.join(pluginRoot, 'lib')
|
|
15
|
+
|
|
16
|
+
/** 兼容层要先绑定运行时,CardParser 读配置/日志都走它 */
|
|
17
|
+
const runtime = require(path.join(libRoot, 'compat', 'runtime.js'))
|
|
18
|
+
const noop = () => {}
|
|
19
|
+
runtime.bindRuntime({
|
|
20
|
+
ctx: { get: () => undefined, logger: () => ({ info: noop, warn: noop, error: noop, debug: noop, mark: noop }), bots: [], registry: new Map() },
|
|
21
|
+
config: { app: {} },
|
|
22
|
+
dataRoot: path.join(pluginRoot, 'data-smoke-cardparse'),
|
|
23
|
+
master: () => []
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const { extractUpNames, extractUpName } = require(path.join(libRoot, 'karin/module/utils/CardParser.js'))
|
|
27
|
+
|
|
28
|
+
const results = []
|
|
29
|
+
const check = (name, ok, detail) => {
|
|
30
|
+
results.push({ name, ok })
|
|
31
|
+
console.log((ok ? ' ✅ ' : ' ❌ ') + name + (detail ? ' —— ' + detail : ''))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log('\n[1] 线上那条真实 OCR(真名在第一行、「UP主」前一行是封面文字)')
|
|
35
|
+
const live = '雾小霜暗区突围\n1,052\n半身像\nUP主\n4993粉丝\n1,087\n*未知"\n5.3万播放1806点赞\n11弹幕'
|
|
36
|
+
const liveNames = extractUpNames(live)
|
|
37
|
+
check('首选就是真昵称「雾小霜暗区突围」', liveNames[0] === '雾小霜暗区突围', liveNames.join(' / '))
|
|
38
|
+
check('封面文字「半身像」不会混进来', !liveNames.includes('半身像'), liveNames.join(' / '))
|
|
39
|
+
check('统计行(4993粉丝 / 5.3万播放…)不当昵称', !liveNames.some((item) => /粉丝|播放|点赞|弹幕|^[\d,.]+$/.test(item)), liveNames.join(' / '))
|
|
40
|
+
check('旧入口 extractUpName 与首选一致', extractUpName(live) === '雾小霜暗区突围', extractUpName(live))
|
|
41
|
+
|
|
42
|
+
console.log('\n[2] OCR 把整段压成一行(空格分隔)也要认出来')
|
|
43
|
+
const flat = '雾小霜暗区突围 1,052 半身像 UP主 4993粉丝 1,087 *未知" 5.3万播放1806点赞 11弹幕'
|
|
44
|
+
check('单行也能拿到真昵称', extractUpNames(flat)[0] === '雾小霜暗区突围', JSON.stringify(extractUpNames(flat)))
|
|
45
|
+
|
|
46
|
+
console.log('\n[3] 标准 B站排版(昵称在「UP主」上一行)保持原样')
|
|
47
|
+
const classic = '影视飓风\nUP主\n123.4万粉丝\n1.2亿播放'
|
|
48
|
+
check('昵称「影视飓风」', extractUpNames(classic)[0] === '影视飓风', JSON.stringify(extractUpNames(classic)))
|
|
49
|
+
|
|
50
|
+
console.log('\n[4] 边界:空文本 / 只有统计 / 重复行')
|
|
51
|
+
check('空文本返回空数组', extractUpNames('').length === 0)
|
|
52
|
+
check('只有统计行时没有候选', extractUpNames('1.2万粉丝\n3,456\n7.8万播放').length === 0, JSON.stringify(extractUpNames('1.2万粉丝\n3,456\n7.8万播放')))
|
|
53
|
+
check('大小写不同的 UP主 都能识别', extractUpNames('某某某\nup主\n100粉丝')[0] === '某某某', JSON.stringify(extractUpNames('某某某\nup主\n100粉丝')))
|
|
54
|
+
check('候选不重复', (() => { const list = extractUpNames('小明明\nUP主\n小明明\n99粉丝'); return list.length === new Set(list).size })(), JSON.stringify(extractUpNames('小明明\nUP主\n小明明\n99粉丝')))
|
|
55
|
+
|
|
56
|
+
const failed = results.filter((item) => !item.ok)
|
|
57
|
+
console.log('\n=== ' + (results.length - failed.length) + '/' + results.length + ' 通过 ===')
|
|
58
|
+
process.exitCode = failed.length ? 1 : 0
|
package/src/index.ts
CHANGED
|
@@ -705,18 +705,40 @@ function registerCommands (
|
|
|
705
705
|
if (await runTextCommand(session, '#解析 ' + resolved.url)) return
|
|
706
706
|
}
|
|
707
707
|
if (resolved && resolved.candidates && resolved.candidates.length) {
|
|
708
|
-
const
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
708
|
+
const top = resolved.candidates.slice(0, 6)
|
|
709
|
+
const tip = '没能唯一确定这个作品(识别到:' + ((resolved.upName) || '未知') + '),下面是候选:'
|
|
710
|
+
const linkOf = (item: any) => item.platform === 'bilibili'
|
|
711
|
+
? 'https://www.bilibili.com/video/' + item.id
|
|
712
|
+
: 'https://www.douyin.com/video/' + item.id
|
|
713
|
+
/**
|
|
714
|
+
* **按平台分成两种发法**。
|
|
715
|
+
*
|
|
716
|
+
* QQ 官方机器人认 markdown,候选直接排成表格、操作列是按钮,点一下就走解析(最省事)。
|
|
717
|
+
* 个人号(OneBot / NapCat)**不渲染 markdown**:实测这条候选消息发过去整条都发不出来 ——
|
|
718
|
+
* 用户只看到「正在提取卡片信息…」然后再无音讯,六个候选白搜。那边改成纯文本 + 完整链接,
|
|
719
|
+
* QQ 客户端会把裸链接变成可点的蓝色链接,复制粘贴也方便。
|
|
720
|
+
*/
|
|
721
|
+
const officialQq = /^qq/i.test(String((session as any).platform ?? ''))
|
|
722
|
+
if (officialQq) {
|
|
723
|
+
const { cmdInput } = await import('./karin/module/utils/QqPanel')
|
|
724
|
+
const table = ['| # | 标题 | UP / 作者 | 操作 |', '| :---: | :--- | :--- | :---: |']
|
|
725
|
+
top.forEach((item: any, index: number) => {
|
|
726
|
+
const title = String(item.title || '(无标题)').replace(/[|\n]/g, ' ').slice(0, 26)
|
|
727
|
+
const author = String(item.author || '-').replace(/[|\n]/g, ' ').slice(0, 12)
|
|
728
|
+
table.push('| ' + (index + 1) + ' | ' + title + ' | ' + author + ' | ' + cmdInput('解析 ' + linkOf(item), '解析') + ' |')
|
|
729
|
+
})
|
|
730
|
+
await send([{ type: 'markdown', attrs: { content: tip + '点按钮直接解析' + String.fromCharCode(10) + table.join(String.fromCharCode(10)) } }])
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
const lines = [tip]
|
|
734
|
+
top.forEach((item: any, index: number) => {
|
|
735
|
+
const title = String(item.title || '(无标题)').replace(/\s+/g, ' ').slice(0, 40)
|
|
736
|
+
const author = String(item.author || '-').replace(/\s+/g, ' ').slice(0, 16)
|
|
737
|
+
lines.push((index + 1) + '. ' + title + ' —— ' + author)
|
|
738
|
+
lines.push(linkOf(item))
|
|
717
739
|
})
|
|
718
|
-
|
|
719
|
-
await send(
|
|
740
|
+
lines.push('把想看的那个链接发给我就能解析。')
|
|
741
|
+
await send(lines.join(String.fromCharCode(10)))
|
|
720
742
|
return
|
|
721
743
|
}
|
|
722
744
|
await send('没能从这张卡片里认出作品,直接发链接给我吧')
|