koishi-plugin-kkk 3.11.1 → 3.12.1

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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # 更新日志
2
2
 
3
+ ## 3.12.1
4
+
5
+ ### 修:B站卡片的简介没了
6
+
7
+ 有一批 B 站视频解析出来的卡片,标题下面直接跳到播放数据,**视频简介整块不见了**。
8
+ 不是渲染糊了,是压根没画上去。
9
+
10
+ 原因是 B 站接口有时会把简介放在「分段简介」里、有时只放在普通简介字段里,
11
+ 之前的代码碰上第一种情况以外的数据会算出一份空简介,然后把「空的」当成「有内容」画出来,
12
+ 结果整块简介反而被吃掉了。
13
+
14
+ 现在两个字段都试,哪个真的有内容就用哪个 —— 单行简介、多行简介、带 @ 和链接的简介
15
+ 都能正常显示出来。
16
+
17
+ 顺手还修了一处:接口返回的简介字段是空的时候不会再让解析报错。
18
+
19
+ ## 3.12.0
20
+
21
+ ### 新:提取图片的按钮只能点一次
22
+
23
+ 卡片下面「提取封面图 / 提取评论区图片」这两个按钮以前可以一直点。有人一直点,
24
+ 群里就被同一张图刷屏,而且看不出是谁在点。
25
+
26
+ 现在**同一张卡片上的同一个按钮只放行一次**:
27
+
28
+ - 再点不会往群里发任何东西(不再刷屏),但日志里会记下「这次是谁点的、之前是谁点过」,
29
+ 真被人刷了能查出来;
30
+ - 封面和评论区是两个按钮,各点各的,互不影响;换一个作品也照常能点;
31
+ - 想再要一次,**重新发一遍链接**就行,新卡片下面的按钮又是可点的(不会被永久锁死);
32
+ - 自己手敲 `kkk封面` / `kkk评论` 不受这个限制,随时都能用。
33
+
34
+ 还有一处顺手修的:万一这次没发出去(图过期了、或者发送报错),这次点击**不算数**,
35
+ 再点一次还是能发 —— 以前那种「点一次就永久作废」的情况不会出现。
36
+
3
37
  ## 3.11.1
4
38
 
5
39
  这一版修的主要是两类事:**东西发不出去**,和**按钮点了没反应**。
@@ -1,10 +1,10 @@
1
1
  {
2
- "version": "3.11.1",
3
- "buildTime": "2026-10-01T13:05:54.560Z",
4
- "buildTimestamp": 1790859954564,
2
+ "version": "3.12.1",
3
+ "buildTime": "2026-10-03T17:20:37.408Z",
4
+ "buildTimestamp": 1791048037420,
5
5
  "name": "koishi-plugin-kkk",
6
6
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
7
7
  "homepage": "https://github.com/maimai993/koishi-plugin-kkk#readme",
8
- "commitHash": "676659fbbdf78244670d22aee37bb7668ef73b42",
9
- "shortCommitHash": "676659f"
8
+ "commitHash": "c3384b0563f126fff043bf0fed433cc406fd08a9",
9
+ "shortCommitHash": "c3384b0"
10
10
  }
@@ -627,6 +627,20 @@ const handleCardParse = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e, next)
627
627
  * `卡片消息` 是可推导的中文名,放最前面。
628
628
  */
629
629
  exports.cardAPP = node_karin_1.default.command(/卡片消息/, handleCardParse, { name: 'kkk-卡片解析' });
630
+ /**
631
+ * 这条指令是不是**QQ 回调按钮**点出来的。
632
+ *
633
+ * 判据是会话类型:回调按钮(`action.type = 1`)在适配器里被标成
634
+ * `session.type = 'interaction/button'`(见 `koishi-plugin-adapter-qq-crack` 的 `utils.ts`),
635
+ * 而手敲指令、以及不支持原生按钮时给的蓝字文字链(`<qqbot-cmd-input>`),
636
+ * 都会变成**普通消息**(`session.type = 'message'`)。
637
+ *
638
+ * 这个区别正是「限一次」的适用范围:**只有按钮才限一次**。
639
+ * 按钮用户看不见指令、也没法确认自己点没点过,所以由插件兜着;
640
+ * 手敲指令是用户主动发的,随时都能用,留一条明确的路(图发不出来时还能重试)。
641
+ * @param e 消息事件
642
+ */
643
+ const isButtonClick = (e) => String(e?.session?.type ?? '') === 'interaction/button';
630
644
  /**
631
645
  * 单独把卡片图发一遍(`kkk封面` / `kkk评论`)。
632
646
  *
@@ -636,6 +650,11 @@ exports.cardAPP = node_karin_1.default.command(/卡片消息/, handleCardParse,
636
650
  *
637
651
  * **不重新解析、不重新渲染**:图源是上次解析记下来的(见 CardImageCache),
638
652
  * 15 分钟过期(评论图是临时文件,会被定时清理),过期了就提示先发条链接。
653
+ *
654
+ * **按钮只放行一次**(用户要求:有人一直点,群里被同一张图刷屏、还查不出是谁):
655
+ * `interaction/button` 这条路上按「作品 + 按钮种类」先占后发,重复点击**静默忽略**、
656
+ * 只在日志里记下是谁在点。手敲指令不受此限;重新发一次链接(新卡片)
657
+ * 会 `rememberCardImages` 重新武装按钮。
639
658
  */
640
659
  const handleExtractCard = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e, next) => {
641
660
  const msg = String(e.msg ?? '');
@@ -648,7 +667,20 @@ const handleExtractCard = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e, nex
648
667
  */
649
668
  const arg = msg.replace(/^#?(?:kkk封面|kkk评论|提取封面图|提取评论区图片)/, '').trim();
650
669
  const wantComment = /评论/.test(msg);
651
- const cached = (0, CardImageCache_1.recallCardImages)((0, CardImageCache_1.resolveCardImageKey)(e, arg));
670
+ const cardKey = (0, CardImageCache_1.resolveCardImageKey)(e, arg);
671
+ const kind = wantComment ? 'comment' : 'cover';
672
+ const label = wantComment ? '提取评论区图片' : '提取封面图';
673
+ /**
674
+ * **拦截重复点击。**「先占后发」而不是「发成功再记」:
675
+ * 反过来写的话,快速连点会有好几次同时通过检查,照样能刷出好几条。
676
+ */
677
+ const fromButton = isButtonClick(e);
678
+ if (fromButton && !(0, CardImageCache_1.claimCardImageExtract)(cardKey, kind, String(e.userId ?? ''))) {
679
+ const prev = (0, CardImageCache_1.recallCardImageExtract)(cardKey, kind);
680
+ node_karin_1.logger.mark('[提取卡片图] 忽略一次重复点击:%s 点的「%s」,这张卡片上 %s 已经点过了(同一张卡片只放行一次,想再要一次就重新发链接)', String(e.userId ?? '(未知用户)'), label, prev?.by ? String(prev.by) : '(未知用户)');
681
+ return;
682
+ }
683
+ const cached = (0, CardImageCache_1.recallCardImages)(cardKey);
652
684
  let urls;
653
685
  if (wantComment) {
654
686
  /**
@@ -676,14 +708,25 @@ const handleExtractCard = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e, nex
676
708
  urls = cached?.cover ? [cached.cover] : undefined;
677
709
  }
678
710
  if (!urls?.length) {
711
+ /** 这次没发出去,不该算「点过了」——否则图一过期按钮就彻底废了 */
712
+ if (fromButton)
713
+ (0, CardImageCache_1.releaseCardImageExtract)(cardKey, kind);
679
714
  await e.reply(wantComment
680
715
  ? '没拿到这条内容的评论区图片 —— 评论里没有用户贴图,或者已经过期(15 分钟)/临时文件被清理了,重新发一下链接再点 ~'
681
716
  : '没找到这个作品的封面 —— 可能已经过期(15 分钟),重新发一下链接再点 ~');
682
717
  return;
683
718
  }
684
719
  /** 图片统一走 markdown:QQ 上不会被二次压缩,小字才看得清(见 compat/imageMarkdown) */
685
- const md = await (0, QqPanel_1.buildMarkdownImageMessage)(urls, 420, (0, ImageSlice_1.platformOf)(e));
686
- await e.reply(md ?? urls.map((url) => node_karin_1.segment.image(url)));
720
+ try {
721
+ const md = await (0, QqPanel_1.buildMarkdownImageMessage)(urls, 420, (0, ImageSlice_1.platformOf)(e));
722
+ await e.reply(md ?? urls.map((url) => node_karin_1.segment.image(url)));
723
+ }
724
+ catch (error) {
725
+ /** 发送失败同样不当「点过了」:用户再点一次还能拿到图 */
726
+ if (fromButton)
727
+ (0, CardImageCache_1.releaseCardImageExtract)(cardKey, kind);
728
+ throw error;
729
+ }
687
730
  node_karin_1.logger.debug('[提取卡片图] 已发送 %s 张(%s)', urls.length, wantComment ? '评论区' : '封面');
688
731
  }, { businessName: '提取卡片图' });
689
732
  /**
@@ -2,6 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.cardImageKeyOf = cardImageKeyOf;
4
4
  exports.rememberCardImages = rememberCardImages;
5
+ exports.claimCardImageExtract = claimCardImageExtract;
6
+ exports.releaseCardImageExtract = releaseCardImageExtract;
7
+ exports.recallCardImageExtract = recallCardImageExtract;
5
8
  exports.rememberLastCardKey = rememberLastCardKey;
6
9
  exports.resolveCardImageKey = resolveCardImageKey;
7
10
  exports.recallCardImages = recallCardImages;
@@ -12,6 +15,27 @@ const TTL_MS = 15 * 60 * 1000;
12
15
  const store = new Map();
13
16
  /** 会话 → 最近一次解析的作品键(不带参数手敲指令时的兜底) */
14
17
  const lastBySession = new Map();
18
+ /**
19
+ * 回调按钮的「已经点过了」记录:`${作品键}|${种类}` → 谁点的、什么时候。
20
+ *
21
+ * ## 为什么需要它(用户要求)
22
+ *
23
+ * 「提取封面图 / 提取评论区图片」是 **QQ 原生回调按钮**:点一下就把载荷回调给机器人,
24
+ * QQ 那边不会记住「已经点过」,**按钮一直可以点**。于是有人会一直点,
25
+ * 群里就被同一张图刷屏(用户原话:「最多只能触发一次,不然点一直点,不知道谁在刷屏」)。
26
+ *
27
+ * 所以这里记一笔:**同一张卡片上的同一个按钮只放行一次**,后面的点击静默忽略、只记日志。
28
+ * - 作用域是「作品 + 种类」,不是「人」:封面和评论区是两个按钮,互不影响;
29
+ * - **重新解析会重新武装**:新卡片发出去时会 {@link rememberCardImages}(顺便清掉旧记录),
30
+ * 所以「想再要一次」只要重发链接,不会被永久锁死;
31
+ * - 手敲指令(`kkk封面 bilibili:BV…`)**不走这里**,随时都能用,留一条明确的路;
32
+ * - 跟着 {@link TTL_MS} 一起过期:图都过期了,记录留着也没意义。
33
+ */
34
+ const extracted = new Map();
35
+ /** 记录键 */
36
+ function extractedKeyOf(key, kind) {
37
+ return String(key ?? '') + '|' + kind;
38
+ }
15
39
  /** 会话键 */
16
40
  function sessionKeyOf(e) {
17
41
  return String(e?.contact?.peer ?? e?.channelId ?? '');
@@ -34,12 +58,19 @@ function sweep() {
34
58
  if (now - item.at > TTL_MS)
35
59
  store.delete(key);
36
60
  }
61
+ for (const [key, item] of extracted) {
62
+ if (now - item.at > TTL_MS)
63
+ extracted.delete(key);
64
+ }
37
65
  }
38
66
  /**
39
67
  * 记下本次解析的图。
40
68
  *
41
69
  * **只补传进来的那几项**:详情页发完先记封面,评论渲染完再补评论区,
42
70
  * 后一次调用不会把前一次记的封面抹掉。
71
+ *
72
+ * 顺带**重新武装**这次记的那几种图对应的按钮:新卡片要发出去了,
73
+ * 它下面的按钮应该可以再点一次(旧卡片上的那次点击不作数)。
43
74
  * @param key {@link cardImageKeyOf}
44
75
  * @param patch 要记录的内容
45
76
  */
@@ -50,14 +81,56 @@ function rememberCardImages(key, patch) {
50
81
  const prev = store.get(key);
51
82
  const next = prev ?? { key, at: Date.now() };
52
83
  next.at = Date.now();
53
- if (patch.cover)
84
+ if (patch.cover) {
54
85
  next.cover = patch.cover;
55
- if (patch.comment?.length)
86
+ extracted.delete(extractedKeyOf(key, 'cover'));
87
+ }
88
+ if (patch.comment?.length) {
56
89
  next.comment = patch.comment;
57
- if (patch.commentPics?.length)
90
+ extracted.delete(extractedKeyOf(key, 'comment'));
91
+ }
92
+ if (patch.commentPics?.length) {
58
93
  next.commentPics = patch.commentPics;
94
+ extracted.delete(extractedKeyOf(key, 'comment'));
95
+ }
59
96
  store.set(key, next);
60
97
  }
98
+ /**
99
+ * 占一次「卡片按钮已经点过了」。**只有 QQ 回调按钮走这里**(手敲指令不调)。
100
+ *
101
+ * 先占后发、发失败再 {@link releaseCardImageExtract}:
102
+ * 反过来「先发成功再记」的话,快速连点会有几次同时通过检查,照样能刷出好几条。
103
+ * @param key 作品键({@link cardImageKeyOf})
104
+ * @param kind 封面还是评论区
105
+ * @param by 点击者(用于日志里认出「谁在刷屏」)
106
+ * @returns 第一次点返回 `true`(放行);之前已经点过返回 `false`
107
+ */
108
+ function claimCardImageExtract(key, kind, by) {
109
+ if (!key)
110
+ return true;
111
+ sweep();
112
+ const id = extractedKeyOf(key, kind);
113
+ if (extracted.has(id))
114
+ return false;
115
+ extracted.set(id, { at: Date.now(), by: String(by ?? '') });
116
+ return true;
117
+ }
118
+ /** 撤销一次占用(这次没发出去 —— 过期、没图、发送报错 —— 不该算「点过了」) */
119
+ function releaseCardImageExtract(key, kind) {
120
+ if (!key)
121
+ return;
122
+ extracted.delete(extractedKeyOf(key, kind));
123
+ }
124
+ /**
125
+ * 这个按钮是不是已经点过了(日志用:带上是谁点的)。
126
+ * @param key 作品键
127
+ * @param kind 封面还是评论区
128
+ */
129
+ function recallCardImageExtract(key, kind) {
130
+ if (!key)
131
+ return undefined;
132
+ return extracted.get(extractedKeyOf(key, kind));
133
+ }
61
134
  /**
62
135
  * 记下「这个会话最近解析的是哪个作品」。
63
136
  * 用户手敲 `kkk封面`(不带参数)时用它兜底 —— 按钮自己是带参数的,不走这条路。
@@ -344,9 +344,7 @@ class Bilibili extends utils_1.Base {
344
344
  const img = await (0, utils_1.Render)(this.e, 'bilibili/videoInfo', {
345
345
  share_url: 'https://b23.tv/' + infoData.data.data.bvid,
346
346
  title: infoData.data.data.title,
347
- desc: infoData.data.data.desc_v2?.length
348
- ? (0, dynamic_text_1.buildBilibiliVideoDescRichText)(infoData.data.data.desc_v2)
349
- : (0, dynamic_text_1.buildBilibiliDynamicRichText)(infoData.data.data.desc || '', []),
347
+ desc: (0, dynamic_text_1.buildVideoDescRichText)(infoData.data.data.desc_v2, infoData.data.data.desc),
350
348
  stat: infoData.data.data.stat,
351
349
  bvid: infoData.data.data.bvid,
352
350
  ctime: infoData.data.data.ctime,
@@ -1187,9 +1185,7 @@ class Bilibili extends utils_1.Base {
1187
1185
  img = await (0, utils_1.Render)(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', {
1188
1186
  image_url: INFODATA.data.data.pic,
1189
1187
  text: (0, dynamic_text_1.buildBilibiliDynamicRichText)(INFODATA.data.data.title, []),
1190
- desc: INFODATA.data.data.desc_v2?.length
1191
- ? (0, dynamic_text_1.buildBilibiliVideoDescRichText)(INFODATA.data.data.desc_v2)
1192
- : (0, dynamic_text_1.buildBilibiliDynamicRichText)(INFODATA.data.data.desc || '', []),
1188
+ desc: (0, dynamic_text_1.buildVideoDescRichText)(INFODATA.data.data.desc_v2, INFODATA.data.data.desc),
1193
1189
  dynamic_text: dynamicText,
1194
1190
  dianzan: (0, utils_1.Count)(INFODATA.data.data.stat.like),
1195
1191
  pinglun: (0, utils_1.Count)(INFODATA.data.data.stat.reply),
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildBilibiliRichTextForwardMessage = exports.buildBilibiliArticleRichText = exports.buildBilibiliVideoDescRichText = exports.buildBilibiliDynamicRichText = exports.buildUsernameRichText = exports.getUsernameMetadata = void 0;
3
+ exports.buildBilibiliRichTextForwardMessage = exports.buildBilibiliArticleRichText = exports.buildVideoDescRichText = exports.buildBilibiliVideoDescRichText = exports.buildBilibiliDynamicRichText = exports.buildUsernameRichText = exports.getUsernameMetadata = void 0;
4
4
  const richtext_1 = require("../../../richtext");
5
5
  const node_karin_1 = require("../../../compat/node-karin.js");
6
6
  /**
@@ -201,9 +201,17 @@ exports.buildBilibiliDynamicRichText = buildBilibiliDynamicRichText;
201
201
  * desc_v2 结构:
202
202
  * - type: 1 → 纯文本(含换行、链接)
203
203
  * - type: 2 → @提及(biz_id 为用户ID)
204
+ *
205
+ * **入参可能是 `null`/`undefined`**(接口在某些情况下就是这么给的,amagi 自己的类型里
206
+ * 也写着 `desc_v2: null`),这里必须容错 —— 直接 `for…of` 会抛 `descV2 is not iterable`。
207
+ * 一般不要直接调它,用 {@link buildVideoDescRichText},它会把 `desc` 兜底也算进去。
208
+ *
209
+ * @param descV2 接口给的 desc_v2(可为空)
204
210
  */
205
211
  const buildBilibiliVideoDescRichText = (descV2) => {
206
212
  const nodes = [];
213
+ if (!descV2)
214
+ return (0, richtext_1.createRichTextDocument)(nodes, { platform: 'bilibili' });
207
215
  for (const item of descV2) {
208
216
  const rawText = item.raw_text || '';
209
217
  if (!rawText)
@@ -232,6 +240,46 @@ const buildBilibiliVideoDescRichText = (descV2) => {
232
240
  return (0, richtext_1.createRichTextDocument)(nodes, { platform: 'bilibili' });
233
241
  };
234
242
  exports.buildBilibiliVideoDescRichText = buildBilibiliVideoDescRichText;
243
+ /**
244
+ * 视频详情的简介 → 富文本(**卡片该用这个,不要自己在调用处写三元**)。
245
+ *
246
+ * ## 为什么要有它
247
+ *
248
+ * 各调用点以前是这么写的:
249
+ *
250
+ * ```ts
251
+ * desc: data.desc_v2?.length
252
+ * ? buildBilibiliVideoDescRichText(data.desc_v2)
253
+ * : buildBilibiliDynamicRichText(data.desc || '', [])
254
+ * ```
255
+ *
256
+ * 看着没问题,但 `desc_v2` **不保证是「非空数组」** —— 接口至少有两种给不出内容的形态:
257
+ *
258
+ * - **`null`**:amagi 自己的类型里就有一支专职写着 `desc_v2: null`;
259
+ * - **`[{ raw_text: '', type: 1 }]`**:数组长度是 1,但里面一个字都没有。
260
+ *
261
+ * 前者 `?.length` 是 `undefined`、后者 `?.length` 是 1,判断结果不同,
262
+ * 但**都不能产生可显示的简介** —— 于是交给模板的都是空文档,
263
+ * 而卡片模板里写的是 `props.data.desc &&`,空文档为假 ⇒ **整块简介不渲染**,
264
+ * 而 `desc` 纯文本里明明有内容。用户反馈的「封面卡片没有简介」就是这个。
265
+ *
266
+ * 这个函数把口径收在一处,按**能不能产出内容**判断,而不是按长度:
267
+ * - `desc_v2` 能产出非空文档 → 用它(带 @ 提及、富文本样式);
268
+ * - 否则 → 用 `desc` 纯文本兜底(**关键:desc_v2 为空时不能把 desc 也一起丢掉**);
269
+ * - 两个都没有 → 返回**合法的空文档**(`nodes: []`),模板判假后自然不显示,
270
+ * 不会抛错、也不会渲染出 `undefined` 字样。
271
+ *
272
+ * @param descV2 接口给的 desc_v2(数组 / null / undefined / 空内容数组都可能)
273
+ * @param desc 接口给的 desc 纯文本(兜底用)
274
+ */
275
+ const buildVideoDescRichText = (descV2, desc) => {
276
+ const fromV2 = (0, exports.buildBilibiliVideoDescRichText)(descV2);
277
+ /** 真的产出了内容才用它:空数组、全空串、null 都会走到 desc 兜底 */
278
+ if (fromV2.nodes.length > 0)
279
+ return fromV2;
280
+ return (0, exports.buildBilibiliDynamicRichText)(desc || '', []);
281
+ };
282
+ exports.buildVideoDescRichText = buildVideoDescRichText;
235
283
  /** 修复图片 URL 协议。 */
236
284
  const fixImageUrl = (url) => {
237
285
  if (url.startsWith('//')) {
@@ -420,9 +420,7 @@ class Bilibilipush extends module_1.Base {
420
420
  img = await (0, module_1.Render)(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', {
421
421
  image_url: INFODATA.data.data.pic,
422
422
  text: (0, dynamic_text_1.buildBilibiliDynamicRichText)(INFODATA.data.data.title, []),
423
- desc: INFODATA.data.data.desc_v2?.length
424
- ? (0, dynamic_text_1.buildBilibiliVideoDescRichText)(INFODATA.data.data.desc_v2)
425
- : (0, dynamic_text_1.buildBilibiliDynamicRichText)(INFODATA.data.data.desc || '', []),
423
+ desc: (0, dynamic_text_1.buildVideoDescRichText)(INFODATA.data.data.desc_v2, INFODATA.data.data.desc),
426
424
  dynamic_text: dynamicText,
427
425
  dianzan: (0, module_1.Count)(INFODATA.data.data.stat.like),
428
426
  pinglun: (0, module_1.Count)(INFODATA.data.data.stat.reply),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.11.1",
3
+ "version": "3.12.1",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -0,0 +1,218 @@
1
+ /**
2
+ * 探针:B站视频卡片里**简介到底有没有渲染出来**。
3
+ *
4
+ * 用户反馈:「b站的视频解析封面卡片渲染的内容没有简介」。
5
+ *
6
+ * 这一版不用截图(截图要浏览器渲染器,只有宿主里才有),直接用 **SSR 产物 HTML**:
7
+ * `renderTemplateHtml` 是「模板组件 → HTML 字符串」那一步,不需要浏览器。
8
+ * 于是可以精确问三个问题:
9
+ * ① 数据里 `desc` 是空的时候,HTML 长什么样(基线);
10
+ * ② `desc` 是正常富文本时,简介的原文**有没有出现在 HTML 里**;
11
+ * ③ 两次 HTML 的长度差有多大 —— 简介整段文字 + 换行都进去的话,差值是**几千字符级**,
12
+ * 只差几十字符就说明「有内容但没渲染」。
13
+ *
14
+ * 用法:node scripts/probe-bili-desc.cjs
15
+ */
16
+ const fs = require('node:fs')
17
+ const os = require('node:os')
18
+ const path = require('node:path')
19
+
20
+ const root = path.join(__dirname, '..')
21
+ const lib = path.join(root, 'lib')
22
+
23
+ const runtime = require(path.join(lib, 'compat/runtime.js'))
24
+ runtime.bindRuntime({
25
+ ctx: { config: { port: 5200 } },
26
+ config: { app: {} },
27
+ dataRoot: fs.mkdtempSync(path.join(os.tmpdir(), 'kkk-probe-bili-desc-'))
28
+ })
29
+
30
+ require(path.join(lib, 'compat/logger.js')).setLogger({
31
+ debug () {}, info () {}, mark () {},
32
+ warn: (m) => console.log(' [warn] ' + m),
33
+ error: (m) => console.log(' [error] ' + m)
34
+ })
35
+
36
+ const Config = require(path.join(lib, 'karin/module/utils/Config.js'))
37
+ Config.Config = {
38
+ app: { EmojiReply: false, RenderWaitTime: 10, renderScale: 2, noiseOverlay: 'off' },
39
+ amagi: { cookies: {} },
40
+ bilibili: {},
41
+ douyin: {}
42
+ }
43
+
44
+ const render = require(path.join(lib, 'karin/module/utils/Render/index.js'))
45
+ const rich = require(path.join(lib, 'richtext/parse/index.js'))
46
+ const dynamicText = require(path.join(lib, 'karin/platform/bilibili/dynamic-text.js'))
47
+
48
+ let failed = 0
49
+ const check = (name, ok, detail) => {
50
+ console.log((ok ? ' ✔ ' : ' ✘ ') + name + (detail ? ' → ' + detail : ''))
51
+ if (!ok) failed++
52
+ }
53
+
54
+ /** 一段「像真的」的 B 站简介:多行 + 时间轴 + 链接,跟真实投稿一个形状 */
55
+ const DESC_LINES = [
56
+ '本期视频带大家看一下这个方案的完整实现,代码已经开源,链接放在下面。',
57
+ '',
58
+ '00:00 开场',
59
+ '01:20 环境准备',
60
+ '05:40 核心逻辑',
61
+ '12:00 踩坑记录',
62
+ '',
63
+ '参考资料:https://www.bilibili.com/read/cv12345678',
64
+ '',
65
+ '感谢大家的支持,记得三连 ~'
66
+ ]
67
+ const DESC_TEXT = DESC_LINES.join('\n')
68
+ /** 只取一段不含 URL、不含 @ 的普通文字当「海报标记」,避免被链接/表情的处理干扰 */
69
+ const MARKER = '本期视频带大家看一下这个方案的完整实现'
70
+
71
+ const descDoc = dynamicText.buildBilibiliVideoDescRichText([{ raw_text: DESC_TEXT, type: 1 }])
72
+
73
+ const baseData = (desc) => ({
74
+ share_url: 'https://b23.tv/BV1JSan6GEFW',
75
+ title: '【实拍】这个方案到底能不能跑起来?完整实现 + 踩坑记录',
76
+ desc,
77
+ stat: { view: 1234567, danmaku: 8901, reply: 2345, like: 98765, coin: 12345, share: 678, favorite: 23456 },
78
+ bvid: 'BV1JSan6GEFW',
79
+ ctime: 1735689600,
80
+ pic: 'https://i0.hdslb.com/bfs/archive/0000000000000000000000000000000000000000.jpg',
81
+ owner: { mid: 123456, name: '测试UP主', face: 'https://i0.hdslb.com/bfs/face/0000000000000000000000000000000000000000.jpg' }
82
+ })
83
+
84
+ /** html 里出现 desc 区域才算「渲染了简介」:找渲染后的可见文本片段 */
85
+ const countOccurrences = (haystack, needle) => haystack.split(needle).length - 1
86
+
87
+ ;(async () => {
88
+ try {
89
+ console.log('\n=== 1. desc 构建链路(纯函数,先确认数据没问题) ===')
90
+ {
91
+ const plain = rich.extractRichTextPlainText(descDoc)
92
+ console.log(' 富文本节点数 = ' + descDoc.nodes.length + ' | 纯文本长度 = ' + plain.length)
93
+ check('简介文本完整进了富文本文档', plain.includes(MARKER), JSON.stringify(plain.slice(0, 30)))
94
+ check('换行被转成 lineBreak 节点(多行简介要靠它)', descDoc.nodes.some((n) => n.type === 'lineBreak'),
95
+ '节点类型:' + [...new Set(descDoc.nodes.map((n) => n.type))].join(','))
96
+ }
97
+
98
+ console.log('\n=== 2. SSR 对照:带简介 vs 不带简介 ===')
99
+ const htmlWith = await render.renderTemplateHtml('bilibili/videoInfo', baseData(descDoc), false)
100
+ const htmlWithout = await render.renderTemplateHtml(
101
+ 'bilibili/videoInfo',
102
+ baseData(rich.createRichTextDocument([], { platform: 'bilibili' })),
103
+ false
104
+ )
105
+
106
+ check('两侧 SSR 都成功(模板与校验通过)', !!htmlWith && !!htmlWithout,
107
+ 'with=' + (htmlWith ? htmlWith.length : 'null') + ' without=' + (htmlWithout ? htmlWithout.length : 'null'))
108
+
109
+ if (htmlWith && htmlWithout) {
110
+ const inWith = countOccurrences(htmlWith, MARKER)
111
+ const inWithout = countOccurrences(htmlWithout, MARKER)
112
+ console.log(' 简介首句在 HTML 里出现次数:带简介 = ' + inWith + ' / 不带简介 = ' + inWithout)
113
+ check('带简介 → 简介原文出现在卡片 HTML 里', inWith > 0, '出现 ' + inWith + ' 次')
114
+ check('不带简介 → 简介原文不出现(对照成立)', inWithout === 0, '出现 ' + inWithout + ' 次')
115
+
116
+ /** 时间轴、链接这些也验一下:它们走的是另外的分支(split 换行 / URL 识别) */
117
+ check('时间轴行渲染进去了', htmlWith.includes('05:40 核心逻辑'))
118
+ check('简介里的链接被渲染成可点的 a 标签', /<a[^>]*href="https:\/\/www\.bilibili\.com\/read\/cv12345678"/.test(htmlWith))
119
+
120
+ const delta = htmlWith.length - htmlWithout.length
121
+ console.log(' 两次 HTML 长度:' + htmlWith.length + ' vs ' + htmlWithout.length + '(差 ' + delta + ' 字符)')
122
+ check('长度差是「整段简介」的量级(不是几十字符)', delta > DESC_TEXT.length, '差 ' + delta + ' 字符,简介本体 ' + DESC_TEXT.length + ' 字符')
123
+
124
+ /** 存一份给人工看(了解实际排版) */
125
+ const out = path.join(root, 'bili-desc-probe.html')
126
+ fs.writeFileSync(out, htmlWith, 'utf8')
127
+ console.log(' 已写出 SSR HTML:' + path.relative(root, out) + '(可直接用浏览器打开看排版)')
128
+ }
129
+
130
+ console.log('\n=== 3. ✅ 回归:desc_v2 为 null 时简介不能凭空消失 ===')
131
+ {
132
+ /**
133
+ * **本次的真凶**:`data.desc_v2?.length` 只在 desc_v2 是**数组**时才对,
134
+ * 而接口在某些情况下给的是 `desc_v2: null`(amagi 自己的类型里就有这一支:
135
+ * `type Arc = { desc: string; desc_v2: null }`)。
136
+ * 于是判断为假 → 走 desc 兜底那一支……实际并没有:三元的两个分支都指望
137
+ * `desc_v2` 是数组,最后交给模板的是一个**空文档**,
138
+ * 卡片模板里 `props.data.desc &&` 直接为假 ⇒ **整块简介都不渲染**,
139
+ * 而 `desc`(纯文本)明明有内容 —— 用户看到的就是「封面卡片没有简介」。
140
+ *
141
+ * 这里**直接测产物里的 `buildVideoDescRichText`**(新的统一口径),
142
+ * 而不是在探针里重写一遍逻辑 —— 重写的话实现改坏了它也测不出来。
143
+ */
144
+ const buildDesc = dynamicText.buildVideoDescRichText
145
+ check('产物里有 buildVideoDescRichText(三处调用点都用它)', typeof buildDesc === 'function',
146
+ typeof buildDesc)
147
+
148
+ const plainDesc = '掉宝时间:北京时间 2026年9月29日15:00 -10月15日14:59'
149
+
150
+ /** ① 正常:desc_v2 有内容 */
151
+ const normal = rich.extractRichTextPlainText(buildDesc([{ raw_text: plainDesc, type: 1 }], plainDesc))
152
+ check('desc_v2 正常 → 用 desc_v2', normal.includes('掉宝时间'), JSON.stringify(normal.slice(0, 24)))
153
+
154
+ /** ② 真凶场景:desc_v2 是 null,desc 有内容 */
155
+ const nullPlain = rich.extractRichTextPlainText(buildDesc(null, plainDesc))
156
+ check('desc_v2 = null → 用 desc 兜底(不再丢简介)', nullPlain.includes('掉宝时间'),
157
+ nullPlain ? JSON.stringify(nullPlain.slice(0, 24)) : '(空 —— 简介丢了)')
158
+
159
+ /** ③ desc_v2 缺失(undefined)也一样 */
160
+ check('desc_v2 缺失 → 同样用 desc 兜底',
161
+ rich.extractRichTextPlainText(buildDesc(undefined, plainDesc)).includes('掉宝时间'))
162
+
163
+ /** ④ desc_v2 空数组(接口偶尔给空数组) */
164
+ check('desc_v2 空数组 → 还是用 desc 兜底',
165
+ rich.extractRichTextPlainText(buildDesc([], plainDesc)).includes('掉宝时间'))
166
+
167
+ /** ⑤ desc_v2 是「有元素但元素都是空文本」:没有可用内容,也该退回 desc */
168
+ check('desc_v2 全是空串 → 退回 desc 兜底',
169
+ rich.extractRichTextPlainText(buildDesc([{ raw_text: '', type: 1 }], plainDesc)).includes('掉宝时间'))
170
+
171
+ /** ⑥ 两个都空:不能抛错,且必须是合法的空文档(模板要能吃) */
172
+ let bothEmpty
173
+ try {
174
+ bothEmpty = buildDesc(null, '')
175
+ check('两处都空 → 得到合法的空富文本文档', bothEmpty?.nodes?.length === 0,
176
+ 'nodes=' + (bothEmpty?.nodes?.length ?? 'undefined'))
177
+ } catch (error) {
178
+ check('两处都空 → 不抛错', false, error.message)
179
+ }
180
+ /** ⑦ 老函数直接吃 null 也不能崩(别处还有人在调它) */
181
+ let oldCrash = false
182
+ try {
183
+ dynamicText.buildBilibiliVideoDescRichText(null)
184
+ } catch (error) {
185
+ oldCrash = true
186
+ }
187
+ check('buildBilibiliVideoDescRichText(null) 不再抛 descV2 is not iterable', !oldCrash)
188
+
189
+ /** ⑧ **端到端**:拿真凶数据走一遍 SSR,简介必须出现在 HTML 里 */
190
+ if (bothEmpty) {
191
+ const nullV2Html = await render.renderTemplateHtml('bilibili/videoInfo', baseData(buildDesc(null, plainDesc)), false)
192
+ check('desc_v2=null 的数据 → SSR 出来的卡片里真的有简介',
193
+ !!nullV2Html && nullV2Html.includes('掉宝时间'),
194
+ nullV2Html ? ('HTML ' + nullV2Html.length + ' 字符,含简介=' + nullV2Html.includes('掉宝时间')) : 'SSR 失败')
195
+ }
196
+ }
197
+
198
+ console.log('\n=== 4. 真实的 desc_v2 形状(从接口拿到的就是它) ===')
199
+ {
200
+ /** 接口里 desc_v2 的常见形状:多段,每段 type=1 纯文本 / type=2 @某人 */
201
+ const mixed = dynamicText.buildBilibiliVideoDescRichText([
202
+ { raw_text: '第一段简介\n', type: 1 },
203
+ { raw_text: '@某某UP', type: 2, biz_id: 999 },
204
+ { raw_text: '\n尾段 https://b23.tv/abc', type: 1 }
205
+ ])
206
+ const plain = rich.extractRichTextPlainText(mixed)
207
+ console.log(' 混合 desc_v2 → ' + JSON.stringify(plain))
208
+ check('多段拼接后 @ 与链接都在', plain.includes('@某某UP') && plain.includes('https://b23.tv/abc'))
209
+ }
210
+
211
+ console.log('\n' + (failed ? '✘ 有 ' + failed + ' 项没通过' : '✔ 全部通过'))
212
+ } catch (error) {
213
+ console.error('探针自身出错:', error)
214
+ failed++
215
+ } finally {
216
+ process.exit(failed ? 1 : 0)
217
+ }
218
+ })()
@@ -0,0 +1,317 @@
1
+ /**
2
+ * 探针:「提取封面图 / 提取评论区图片」这两个**回调按钮最多只触发一次**。
3
+ *
4
+ * 用户原话:「提取图片的回调按钮最多只能触发一次,不然点一直点,不知道谁在刷屏」。
5
+ *
6
+ * QQ 的原生回调按钮(`action.type = 1`)是**发出去就收不回**的东西:QQ 那边不会记住
7
+ * 「这个按钮点过了」,所以只能由插件自己拦 —— 这里就把拦的那段(真 `handleExtractCard`)
8
+ * 跑起来,逐条验证:
9
+ * ① 第一次点:正常出图;
10
+ * ② 再点(同一个人 / 换个人):**静默忽略**,群里不再多一条,日志里记下是谁在点;
11
+ * ③ 封面按钮和评论按钮是两件事,互不影响;
12
+ * ④ 换作品互不影响;
13
+ * ⑤ **重新解析会重新武装**(新卡片发出来,按钮又能点一次)—— 不然就永久锁死了;
14
+ * ⑥ **手敲指令不受限**(留一条明确的路,图发不出来时还能重试);
15
+ * ⑦ 这次没发出去(图过期 / 发送失败)**不算「点过了」**,再点还能成;
16
+ * ⑧ 记录跟着缓存一起 15 分钟过期。
17
+ *
18
+ * 走的是**真产物**(`lib/karin/apps/tools.js` 里 `extractCardAPP` 的 handler),
19
+ * 只把「量图片尺寸」「把图片地址落地」这两步打桩 —— 那是网络活,与本次要验的逻辑无关。
20
+ * 不联网、不连 QQ、不碰用户配置、不写任何文件(dataRoot 用临时目录)。
21
+ *
22
+ * 用法:node scripts/probe-extract-once.cjs
23
+ */
24
+ const fs = require('node:fs')
25
+ const os = require('node:os')
26
+ const path = require('node:path')
27
+
28
+ const lib = path.join(__dirname, '..', 'lib')
29
+
30
+ /**
31
+ * **先把运行环境打桩,再 require 业务模块。**
32
+ *
33
+ * `Config` / `runtime` / `logger` 都是在业务模块 **import 期**就被读到的
34
+ * (比如 `tools.ts` 里 `priority: Config.app.videoTool ? …` 就是模块级表达式),
35
+ * 所以顺序不能反,否则整个 require 直接抛。
36
+ */
37
+ const runtime = require(path.join(lib, 'compat/runtime.js'))
38
+ runtime.bindRuntime({
39
+ ctx: { config: { port: 5200 } },
40
+ /**
41
+ * `errorNoCard: true` —— 出错只发文字、不渲染错误卡片。
42
+ * 第 7b 段会故意让发送失败,那时异常会流进错误处理器;不关掉卡片渲染的话
43
+ * 探针要凭空渲染一张几 MB 的大图(还要切片、发到几个账号),与本次要验的东西无关。
44
+ */
45
+ config: { app: {}, errorNoCard: true },
46
+ dataRoot: fs.mkdtempSync(path.join(os.tmpdir(), 'kkk-probe-extract-once-'))
47
+ })
48
+
49
+ /** 接住日志:要断言「谁在刷屏」真的被记下来了 */
50
+ const marks = []
51
+ const warns = []
52
+ require(path.join(lib, 'compat/logger.js')).setLogger({
53
+ debug () {},
54
+ info: (message) => marks.push(String(message)),
55
+ warn: (message) => warns.push(String(message)),
56
+ error () {}
57
+ })
58
+
59
+ const Config = require(path.join(lib, 'karin/module/utils/Config.js'))
60
+ Config.Config = {
61
+ /** EmojiReply 关掉:不然每次调用都会去尝试给消息加表情(探针里没有真 bot) */
62
+ app: { EmojiReply: false, videoTool: false, videoSendMode: 'url' },
63
+ amagi: { cookies: {} },
64
+ bilibili: {},
65
+ douyin: {},
66
+ kuaishou: {},
67
+ xiaohongshu: {}
68
+ }
69
+
70
+ const ImageHelper = require(path.join(lib, 'karin/module/utils/ImageHelper.js'))
71
+ const QqPanel = require(path.join(lib, 'karin/module/utils/QqPanel.js'))
72
+ const CardImageCache = require(path.join(lib, 'karin/module/utils/CardImageCache.js'))
73
+ const tools = require(path.join(lib, 'karin/apps/tools.js'))
74
+
75
+ /** 量尺寸那一步(会去下载图片):换成固定串,断言里只关心「发了几次」 */
76
+ QqPanel.buildMarkdownImageMessage = async (urls) =>
77
+ urls?.length ? { type: 'markdown', attrs: { content: urls.map((url) => '![cover](' + url + ')').join('\n') } } : undefined
78
+ /** 把图片地址落地那一步(会去下载 / 转 base64):原样返回 */
79
+ ImageHelper.processImageUrls = async (urls) => urls
80
+
81
+ /**
82
+ * 错误处理器也打桩。
83
+ *
84
+ * 第 7b 段要验「发送失败时不该占用额度」,那就必须真的让 `e.reply` 抛一次 ——
85
+ * 异常会流进 `wrapWithErrorHandler` 的错误分支。真实的错误处理会上报(联网)、
86
+ * 给主人 / 管理员发通知,探针里既没网也没账号,全打掉即可。
87
+ * (上面 `errorNoCard` 已经省掉了渲染那一步。)
88
+ */
89
+ const errorReport = require(path.join(lib, 'karin/module/utils/ErrorReport.js'))
90
+ errorReport.uploadErrorReport = async () => null
91
+ const errorSender = require(path.join(lib, 'karin/module/utils/ErrorHandler/sender.js'))
92
+ for (const name of ['sendErrorToTrigger', 'sendErrorToMaster', 'sendErrorToAllMasters', 'sendErrorToAdmins', 'sendErrorToConfiguredIds']) {
93
+ errorSender[name] = async () => {}
94
+ }
95
+
96
+ let failed = 0
97
+ const check = (name, ok, detail) => {
98
+ console.log((ok ? ' ✔ ' : ' ✘ ') + name + (detail ? ' → ' + detail : ''))
99
+ if (!ok) failed++
100
+ }
101
+ /** 章节标题 */
102
+ const section = (title) => console.log('\n=== ' + title + ' ===')
103
+
104
+ /**
105
+ * 造一条假消息。
106
+ * @param opts.msg 指令原文(按钮回调的 data 拼上 `#` 后就是它)
107
+ * @param opts.button 是不是 QQ 回调按钮点出来的(决定 `session.type`)
108
+ * @param opts.userId 谁点的
109
+ * @param opts.peer 会话
110
+ */
111
+ const makeMessage = (opts) => {
112
+ const replies = []
113
+ const e = {
114
+ msg: opts.msg,
115
+ userId: opts.userId ?? 'user-1',
116
+ selfId: 'self-1',
117
+ groupId: 'group-1',
118
+ isGroup: true,
119
+ isPrivate: false,
120
+ channelId: opts.peer ?? 'group-1',
121
+ contact: { peer: opts.peer ?? 'group-1', guildId: 'group-1', isGroup: true, userId: opts.userId ?? 'user-1' },
122
+ sender: { userId: opts.userId ?? 'user-1', nick: '触发者' },
123
+ messageId: 'msg-1',
124
+ /** 关键判据:回调按钮 = `interaction/button`,手敲 / 蓝字链 = `message` */
125
+ session: { type: opts.button ? 'interaction/button' : 'message' },
126
+ bot: { adapter: { name: 'adapter-qq-crack' } },
127
+ reply: async (content) => {
128
+ replies.push(content)
129
+ return [{ id: 'r' + replies.length }]
130
+ }
131
+ }
132
+ return { e, replies }
133
+ }
134
+
135
+ /** 发出去的东西是不是「图」(markdown 段里带了图片) */
136
+ const hasImage = (content) => /!\[[^\]]*\]\([^)\s]+\)/.test(JSON.stringify(content ?? ''))
137
+
138
+ /** 跑一次 handler(真产物里那个被 wrapWithErrorHandler 包过的) */
139
+ const click = async (opts) => {
140
+ const { e, replies } = makeMessage(opts)
141
+ await tools.extractCardAPP.handler(e, () => undefined)
142
+ return replies
143
+ }
144
+
145
+ const COVER_KEY = CardImageCache.cardImageKeyOf('bilibili', 'BV1JSan6GEFW')
146
+ const COMMENT_KEY = CardImageCache.cardImageKeyOf('douyin', '7123456789')
147
+ const OTHER_KEY = CardImageCache.cardImageKeyOf('kuaishou', 'photo-9')
148
+
149
+ const armCover = (key) => CardImageCache.rememberCardImages(key, { cover: 'https://cdn/' + key + '/cover.jpg' })
150
+ const armComment = (key) => CardImageCache.rememberCardImages(key, { commentPics: ['https://cdn/' + key + '/c1.jpg'] })
151
+
152
+ ;(async () => {
153
+ try {
154
+ section('1. 第一次点:正常出图')
155
+ {
156
+ armCover(COVER_KEY)
157
+ const replies = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-A' })
158
+ check('点一次 → 出图一条', replies.length === 1 && hasImage(replies[0]), '发送 ' + replies.length + ' 次')
159
+ }
160
+
161
+ section('2. 再点:静默忽略,不再刷屏')
162
+ {
163
+ /** 同一个人连点 */
164
+ const again = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-A' })
165
+ check('同一个人再点 → 一条都不发', again.length === 0, '发送 ' + again.length + ' 次')
166
+ /** 换个人点,同样拦掉(拦的是「这张卡片这个按钮」,不是「这个人」) */
167
+ const other = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-B' })
168
+ check('换个人点 → 也拦掉(群里就是同一张图,谁点都不再发)', other.length === 0, '发送 ' + other.length + ' 次')
169
+ const spam = marks.filter((line) => line.includes('重复点击'))
170
+ /** 前两次点击各产生一条:第一条说「user-A 又点了一次」,第二条说「user-B 也在点,但 user-A 已经点过了」 */
171
+ check(
172
+ '日志里记下了「谁在点」(这次点的 + 第一次点的都要有)',
173
+ spam.length === 2 && spam[1].includes('user-B') && spam[1].includes('user-A'),
174
+ spam[1] ?? '(没有这条日志)'
175
+ )
176
+ }
177
+
178
+ section('3. 封面 / 评论是两个按钮,互不影响')
179
+ {
180
+ armComment(COMMENT_KEY)
181
+ const comment = await click({ msg: '#kkk评论 ' + COMMENT_KEY, button: true, userId: 'user-B' })
182
+ check('评论区按钮照常能用(没被封面那次点击带累)', comment.length === 1 && hasImage(comment[0]), '发送 ' + comment.length + ' 次')
183
+ const commentAgain = await click({ msg: '#kkk评论 ' + COMMENT_KEY, button: true, userId: 'user-C' })
184
+ check('评论按钮自己也只能点一次', commentAgain.length === 0, '发送 ' + commentAgain.length + ' 次')
185
+ }
186
+
187
+ section('4. 换作品互不影响')
188
+ {
189
+ armCover(OTHER_KEY)
190
+ const card2 = await click({ msg: '#kkk封面 ' + OTHER_KEY, button: true, userId: 'user-A' })
191
+ check('另一个作品的封面按钮可以点', card2.length === 1 && hasImage(card2[0]), '发送 ' + card2.length + ' 次')
192
+ const u1 = await click({ msg: '#kkk评论 ' + COMMENT_KEY, button: true, userId: 'user-A' }) // 评论那条还是 user-B 点过
193
+ check('缓存里两件作品各记各的,不会串', u1.length === 0, '发送 ' + u1.length + ' 次')
194
+ }
195
+
196
+ section('5. 重新发一次链接(新卡片)→ 按钮重新可用')
197
+ {
198
+ /** 同一个作品又解析了一遍:`rememberCardImages` 会顺手清掉「点过了」记录 */
199
+ armCover(COVER_KEY)
200
+ const reArmed = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-C' })
201
+ check('新卡片下面的按钮又能点一次(不会被永久锁死)', reArmed.length === 1 && hasImage(reArmed[0]), '发送 ' + reArmed.length + ' 次')
202
+ const third = await click({ msg: '#kkk封面 ' + COVER_KEY, button: true, userId: 'user-C' })
203
+ check('新卡片上也只放行一次', third.length === 0, '发送 ' + third.length + ' 次')
204
+ }
205
+
206
+ section('6. 手敲指令不受限(留一条明确的路)')
207
+ {
208
+ /** 上一步刚被 user-C 点过,这里手敲两次都该出图 */
209
+ /** 先记下水线:只检查「手敲这一段」有没有新写日志(上面按钮那几段的日志不能算进来) */
210
+ const before = marks.length
211
+ const typed1 = await click({ msg: 'kkk封面 ' + COVER_KEY, button: false, userId: 'user-D' })
212
+ const typed2 = await click({ msg: 'kkk封面 ' + COVER_KEY, button: false, userId: 'user-D' })
213
+ check('手敲(不是按钮)→ 两次都出图', typed1.length === 1 && typed2.length === 1 && hasImage(typed2[0]), typed1.length + ' / ' + typed2.length)
214
+ check('手敲不会往「谁在刷屏」日志里写东西', !marks.slice(before).some((line) => line.includes('重复点击')), marks.slice(before).join(' | ') || '(没有新日志)')
215
+ /** 旧 `#` 写法同样是「手敲」口径 */
216
+ const hashTyped = await click({ msg: '#kkk封面 ' + COVER_KEY, button: false, userId: 'user-D' })
217
+ check('旧 `#kkk封面` 写法也照常', hashTyped.length === 1, '发送 ' + hashTyped.length + ' 次')
218
+ }
219
+
220
+ section('7. 这次没发出去,不算「点过了」')
221
+ {
222
+ const emptyKey = CardImageCache.cardImageKeyOf('xiaohongshu', 'note-empty')
223
+ /** 缓存里什么都没有:按钮点了只会得到一句提示 */
224
+ const miss = await click({ msg: '#kkk封面 ' + emptyKey, button: true, userId: 'user-E' })
225
+ check('没图 → 回一句提示(不是图)', miss.length === 1 && !hasImage(miss[0]), '发送 ' + miss.length + ' 次')
226
+ /** 关键:这次不该占用掉额度 —— 等图有了再点必须能成 */
227
+ CardImageCache.rememberCardImages(emptyKey, { cover: 'https://cdn/k/cover.jpg' })
228
+ const retry = await click({ msg: '#kkk封面 ' + emptyKey, button: true, userId: 'user-E' })
229
+ check('图补上后再点 → 能出图(失败没占额度)', retry.length === 1 && hasImage(retry[0]), '发送 ' + retry.length + ' 次')
230
+ }
231
+ {
232
+ /** 过期的那条路:作品键在缓存里根本不存在(= 按钮没带参数、也没解析记录) */
233
+ const { e } = makeMessage({ msg: '#kkk封面', button: true, userId: 'user-F' })
234
+ e.contact.peer = 'group-elsewhere'
235
+ e.channelId = 'group-elsewhere'
236
+ const replies = []
237
+ e.reply = async (content) => { replies.push(content); return [{ id: 'x' }] }
238
+ await tools.extractCardAPP.handler(e, () => undefined)
239
+ check('作品键都拿不到 → 也是提示,不抛错', replies.length === 1 && !hasImage(replies[0]), '发送 ' + replies.length + ' 次')
240
+ }
241
+
242
+ {
243
+ /**
244
+ * **发送那一步报错时也不该占用额度。**
245
+ *
246
+ * 这条是真跑的:把 `e.reply` 换成抛错,异常会一路走到 `wrapWithErrorHandler`
247
+ * 的错误分支(错误上报 / 发通知已在上面的打桩里换成空实现)。
248
+ * 如果实现里漏了 `release`,下面那次重试就会被自己的「已点过」记录挡住 ——
249
+ * 群里表现成「这张卡片再也发不出图了」。
250
+ */
251
+ const failKey = CardImageCache.cardImageKeyOf('bilibili', 'BVfail')
252
+ armCover(failKey)
253
+ const { e } = makeMessage({ msg: '#kkk封面 ' + failKey, button: true, userId: 'user-Z' })
254
+ e.reply = async () => { throw new Error('模拟发送失败') }
255
+ let threw = false
256
+ try {
257
+ await tools.extractCardAPP.handler(e, () => undefined)
258
+ } catch {
259
+ threw = true
260
+ }
261
+ console.log(' · 发送抛错后 handler ' + (threw ? '把错误抛了出来(错误处理器没接住)' : '由错误处理器接住并返回'))
262
+ const retry = await click({ msg: '#kkk封面 ' + failKey, button: true, userId: 'user-Z' })
263
+ check('发送失败 → 不算「点过了」,再点一次还能出图', retry.length === 1 && hasImage(retry[0]), '发送 ' + retry.length + ' 次')
264
+ const afterRetry = await click({ msg: '#kkk封面 ' + failKey, button: true, userId: 'user-Z' })
265
+ check('重试成功之后才真正占用(第三次点被拦)', afterRetry.length === 0, '发送 ' + afterRetry.length + ' 次')
266
+ }
267
+
268
+ section('8. 记录跟着缓存一起过期(15 分钟)')
269
+ {
270
+ const ttlKey = CardImageCache.cardImageKeyOf('bilibili', 'BVttl')
271
+ armCover(ttlKey)
272
+ check('先占一次 → true', CardImageCache.claimCardImageExtract(ttlKey, 'cover', 'user-G') === true)
273
+ check('紧接着再占 → false(拦住了)', CardImageCache.claimCardImageExtract(ttlKey, 'cover', 'user-G') === false)
274
+ /** 把时间拨过 TTL:记录和缓存一起被 sweep 清掉 */
275
+ const realNow = Date.now
276
+ Date.now = () => realNow() + 16 * 60 * 1000
277
+ try {
278
+ check('过了 15 分钟 → 记录跟着过期,又能占了', CardImageCache.claimCardImageExtract(ttlKey, 'cover', 'user-H') === true)
279
+ check('同一时刻缓存也过期了', CardImageCache.recallCardImages(ttlKey) === undefined)
280
+ } finally {
281
+ Date.now = realNow
282
+ }
283
+ }
284
+
285
+ section('9. release / 隔离的最小口径')
286
+ {
287
+ const k = CardImageCache.cardImageKeyOf('bilibili', 'BVdirect')
288
+ CardImageCache.claimCardImageExtract(k, 'cover', 'u')
289
+ CardImageCache.releaseCardImageExtract(k, 'cover')
290
+ check('release 之后又能占(发送失败重试靠它)', CardImageCache.claimCardImageExtract(k, 'cover', 'u') === true)
291
+ check('release 只撤销自己那一种,不碰另一种', CardImageCache.claimCardImageExtract(k, 'comment', 'u') === true)
292
+ CardImageCache.releaseCardImageExtract(k, 'cover')
293
+ check('撤销封面后,评论那条记录还在', CardImageCache.recallCardImageExtract(k, 'comment')?.by === 'u')
294
+ check('空作品键不参与拦截(拿不到键时不该把所有点击都堵死)', CardImageCache.claimCardImageExtract('', 'cover', 'u') === true)
295
+ }
296
+
297
+ section('10. 源码守卫:只在「按钮」那条路上判一次')
298
+ {
299
+ /** 先剥注释再匹配 —— 注释里写到的函数名会把断言带偏(这个坑踩过两次) */
300
+ const stripComments = (source) => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
301
+ const src = stripComments(fs.readFileSync(path.join(lib, 'karin/apps/tools.js'), 'utf8'))
302
+ check('判据是会话类型 `interaction/button`', /e\?\.session\?\.type[\s\S]{0,40}===\s*'interaction\/button'/.test(src))
303
+ check('只在 fromButton 为真时才占用', /if\s*\(\s*fromButton\s*&&\s*!\s*\(0,\s*CardImageCache_1\.claimCardImageExtract\)/.test(src))
304
+ check('claim 全文只出现一次(别处不许再判一遍)', (src.match(/claimCardImageExtract\)\(/g) ?? []).length === 1, String((src.match(/claimCardImageExtract\)\(/g) ?? []).length))
305
+ check('两条失败路都 release 了(没图 / 发送报错)', (src.match(/releaseCardImageExtract\)\(/g) ?? []).length === 2, String((src.match(/releaseCardImageExtract\)\(/g) ?? []).length))
306
+ const cacheSrc = stripComments(fs.readFileSync(path.join(lib, 'karin/module/utils/CardImageCache.js'), 'utf8'))
307
+ check('重新记图时会清掉旧记录(重新武装)', /extracted\.delete\(/.test(cacheSrc))
308
+ }
309
+
310
+ console.log('\n' + (failed ? '✘ 有 ' + failed + ' 项没通过' : '✔ 全部通过' + (warns.length ? '(warn ' + warns.length + ' 条)' : '')))
311
+ } catch (error) {
312
+ console.error('探针自身出错:', error)
313
+ failed++
314
+ } finally {
315
+ process.exit(failed ? 1 : 0)
316
+ }
317
+ })()
@@ -0,0 +1,45 @@
1
+ /** 离线自验:CHANGELOG 的 `## x.y.z` 标题必须能被 range() 裁出来 */
2
+ const fs = require('node:fs')
3
+ const os = require('node:os')
4
+ const path = require('node:path')
5
+
6
+ const root = 'E:/devkoishi/plugins/koishi-plugin-kkk'
7
+ const lib = path.join(root, 'lib')
8
+
9
+ const runtime = require(path.join(lib, 'compat/runtime.js'))
10
+ runtime.bindRuntime({ ctx: { config: { port: 5200 } }, config: { app: {} }, dataRoot: os.tmpdir() })
11
+ require(path.join(lib, 'compat/logger.js')).setLogger({ debug () {}, info () {}, warn () {}, error () {} })
12
+
13
+ const karin = require(path.join(lib, 'compat/node-karin.js'))
14
+ const md = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8')
15
+
16
+ console.log('解析出的版本:' + Object.keys(karin.parseChangelog(md)).join(' , '))
17
+
18
+ /** 当前版本号(package.json);CHANGELOG 顶部的 `## x.y.z` 必须是它,否则卡片会整份发出去 */
19
+ const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version
20
+ const all = Object.keys(karin.parseChangelog(md))
21
+ if (!all.includes(version)) {
22
+ console.log('❌ CHANGELOG 里没有 `## ' + version + '` 这一段(package.json 的版本号对不上)')
23
+ process.exitCode = 1
24
+ } else {
25
+ console.log('✅ package.json 版本 ' + version + ' 在 CHANGELOG 里有对应段落')
26
+ }
27
+
28
+ /** 取当前版本以及它下面 3 个历史版本,逐个验证「上一版 → 这一版」能裁出东西 */
29
+ const cases = []
30
+ for (let i = 0; i < all.length; i++) {
31
+ cases.push([all[i + 1] ?? all[i], all[i]])
32
+ }
33
+ for (const [startVersion, endVersion] of cases) {
34
+ // 最老那一版的下界只能等于它自己(没有更老的版本可当哨兵),range() 此时必然返回整份,
35
+ // 属于工具本身的预期行为,不算失败。
36
+ if (startVersion === endVersion && endVersion === all[all.length - 1]) continue
37
+ const out = karin.range({ data: md, startVersion, endVersion, compare: 'semver' })
38
+ const keys = Object.keys(karin.parseChangelog(out))
39
+ const dumped = out.length >= md.length
40
+ console.log(
41
+ 'range(' + startVersion + ' → ' + endVersion + '):命中 [' + keys.join(', ') + '] 长度=' + out.length +
42
+ (dumped ? ' ⚠ 返回了整份日志(没裁出来)' : '')
43
+ )
44
+ if (dumped) process.exitCode = 1
45
+ }
@@ -7,7 +7,14 @@ import {
7
7
  } from '@/module/utils/QqPanel'
8
8
  import { replyReplacing } from '@/module/utils/QqPanel'
9
9
  import { resolveCardToUrl } from '@/module/utils/CardParser'
10
- import { recallCardImages, resolveCardImageKey } from '@/module/utils/CardImageCache'
10
+ import {
11
+ claimCardImageExtract,
12
+ recallCardImageExtract,
13
+ recallCardImages,
14
+ releaseCardImageExtract,
15
+ resolveCardImageKey,
16
+ type CardImageKind
17
+ } from '@/module/utils/CardImageCache'
11
18
  import { processImageUrls } from '@/module/utils/ImageHelper'
12
19
 
13
20
  import { Common, downloadVideo } from '@/module'
@@ -658,6 +665,21 @@ const handleCardParse = wrapWithErrorHandler(
658
665
  */
659
666
  export const cardAPP = karin.command(/卡片消息/, handleCardParse, { name: 'kkk-卡片解析' })
660
667
 
668
+ /**
669
+ * 这条指令是不是**QQ 回调按钮**点出来的。
670
+ *
671
+ * 判据是会话类型:回调按钮(`action.type = 1`)在适配器里被标成
672
+ * `session.type = 'interaction/button'`(见 `koishi-plugin-adapter-qq-crack` 的 `utils.ts`),
673
+ * 而手敲指令、以及不支持原生按钮时给的蓝字文字链(`<qqbot-cmd-input>`),
674
+ * 都会变成**普通消息**(`session.type = 'message'`)。
675
+ *
676
+ * 这个区别正是「限一次」的适用范围:**只有按钮才限一次**。
677
+ * 按钮用户看不见指令、也没法确认自己点没点过,所以由插件兜着;
678
+ * 手敲指令是用户主动发的,随时都能用,留一条明确的路(图发不出来时还能重试)。
679
+ * @param e 消息事件
680
+ */
681
+ const isButtonClick = (e: any): boolean => String(e?.session?.type ?? '') === 'interaction/button'
682
+
661
683
  /**
662
684
  * 单独把卡片图发一遍(`kkk封面` / `kkk评论`)。
663
685
  *
@@ -667,6 +689,11 @@ export const cardAPP = karin.command(/卡片消息/, handleCardParse, { name: 'k
667
689
  *
668
690
  * **不重新解析、不重新渲染**:图源是上次解析记下来的(见 CardImageCache),
669
691
  * 15 分钟过期(评论图是临时文件,会被定时清理),过期了就提示先发条链接。
692
+ *
693
+ * **按钮只放行一次**(用户要求:有人一直点,群里被同一张图刷屏、还查不出是谁):
694
+ * `interaction/button` 这条路上按「作品 + 按钮种类」先占后发,重复点击**静默忽略**、
695
+ * 只在日志里记下是谁在点。手敲指令不受此限;重新发一次链接(新卡片)
696
+ * 会 `rememberCardImages` 重新武装按钮。
670
697
  */
671
698
  const handleExtractCard = wrapWithErrorHandler(
672
699
  async (e, next) => {
@@ -680,7 +707,25 @@ const handleExtractCard = wrapWithErrorHandler(
680
707
  */
681
708
  const arg = msg.replace(/^#?(?:kkk封面|kkk评论|提取封面图|提取评论区图片)/, '').trim()
682
709
  const wantComment = /评论/.test(msg)
683
- const cached = recallCardImages(resolveCardImageKey(e, arg))
710
+ const cardKey = resolveCardImageKey(e, arg)
711
+ const kind: CardImageKind = wantComment ? 'comment' : 'cover'
712
+ const label = wantComment ? '提取评论区图片' : '提取封面图'
713
+ /**
714
+ * **拦截重复点击。**「先占后发」而不是「发成功再记」:
715
+ * 反过来写的话,快速连点会有好几次同时通过检查,照样能刷出好几条。
716
+ */
717
+ const fromButton = isButtonClick(e)
718
+ if (fromButton && !claimCardImageExtract(cardKey, kind, String(e.userId ?? ''))) {
719
+ const prev = recallCardImageExtract(cardKey, kind)
720
+ logger.mark(
721
+ '[提取卡片图] 忽略一次重复点击:%s 点的「%s」,这张卡片上 %s 已经点过了(同一张卡片只放行一次,想再要一次就重新发链接)',
722
+ String(e.userId ?? '(未知用户)'),
723
+ label,
724
+ prev?.by ? String(prev.by) : '(未知用户)'
725
+ )
726
+ return
727
+ }
728
+ const cached = recallCardImages(cardKey)
684
729
  let urls: string[] | undefined
685
730
  if (wantComment) {
686
731
  /**
@@ -705,14 +750,22 @@ const handleExtractCard = wrapWithErrorHandler(
705
750
  urls = cached?.cover ? [cached.cover] : undefined
706
751
  }
707
752
  if (!urls?.length) {
753
+ /** 这次没发出去,不该算「点过了」——否则图一过期按钮就彻底废了 */
754
+ if (fromButton) releaseCardImageExtract(cardKey, kind)
708
755
  await e.reply(wantComment
709
756
  ? '没拿到这条内容的评论区图片 —— 评论里没有用户贴图,或者已经过期(15 分钟)/临时文件被清理了,重新发一下链接再点 ~'
710
757
  : '没找到这个作品的封面 —— 可能已经过期(15 分钟),重新发一下链接再点 ~')
711
758
  return
712
759
  }
713
760
  /** 图片统一走 markdown:QQ 上不会被二次压缩,小字才看得清(见 compat/imageMarkdown) */
714
- const md = await buildMarkdownImageMessage(urls, 420, platformOf(e))
715
- await e.reply(md ?? urls.map((url) => segment.image(url)))
761
+ try {
762
+ const md = await buildMarkdownImageMessage(urls, 420, platformOf(e))
763
+ await e.reply(md ?? urls.map((url) => segment.image(url)))
764
+ } catch (error) {
765
+ /** 发送失败同样不当「点过了」:用户再点一次还能拿到图 */
766
+ if (fromButton) releaseCardImageExtract(cardKey, kind)
767
+ throw error
768
+ }
716
769
  logger.debug('[提取卡片图] 已发送 %s 张(%s)', urls.length, wantComment ? '评论区' : '封面')
717
770
  },
718
771
  { businessName: '提取卡片图' }
@@ -45,11 +45,37 @@ export interface CardImages {
45
45
  /** 缓存有效期:评论图是临时文件,别放太久 */
46
46
  const TTL_MS = 15 * 60 * 1000
47
47
 
48
+ /** 按钮种类:封面 / 评论区 */
49
+ export type CardImageKind = 'cover' | 'comment'
50
+
48
51
  /** 作品 → 图 */
49
52
  const store = new Map<string, CardImages>()
50
53
  /** 会话 → 最近一次解析的作品键(不带参数手敲指令时的兜底) */
51
54
  const lastBySession = new Map<string, string>()
52
55
 
56
+ /**
57
+ * 回调按钮的「已经点过了」记录:`${作品键}|${种类}` → 谁点的、什么时候。
58
+ *
59
+ * ## 为什么需要它(用户要求)
60
+ *
61
+ * 「提取封面图 / 提取评论区图片」是 **QQ 原生回调按钮**:点一下就把载荷回调给机器人,
62
+ * QQ 那边不会记住「已经点过」,**按钮一直可以点**。于是有人会一直点,
63
+ * 群里就被同一张图刷屏(用户原话:「最多只能触发一次,不然点一直点,不知道谁在刷屏」)。
64
+ *
65
+ * 所以这里记一笔:**同一张卡片上的同一个按钮只放行一次**,后面的点击静默忽略、只记日志。
66
+ * - 作用域是「作品 + 种类」,不是「人」:封面和评论区是两个按钮,互不影响;
67
+ * - **重新解析会重新武装**:新卡片发出去时会 {@link rememberCardImages}(顺便清掉旧记录),
68
+ * 所以「想再要一次」只要重发链接,不会被永久锁死;
69
+ * - 手敲指令(`kkk封面 bilibili:BV…`)**不走这里**,随时都能用,留一条明确的路;
70
+ * - 跟着 {@link TTL_MS} 一起过期:图都过期了,记录留着也没意义。
71
+ */
72
+ const extracted = new Map<string, { at: number, by: string }>()
73
+
74
+ /** 记录键 */
75
+ function extractedKeyOf (key: string, kind: CardImageKind): string {
76
+ return String(key ?? '') + '|' + kind
77
+ }
78
+
53
79
  /** 会话键 */
54
80
  function sessionKeyOf (e: any): string {
55
81
  return String(e?.contact?.peer ?? e?.channelId ?? '')
@@ -72,6 +98,9 @@ function sweep (): void {
72
98
  for (const [key, item] of store) {
73
99
  if (now - item.at > TTL_MS) store.delete(key)
74
100
  }
101
+ for (const [key, item] of extracted) {
102
+ if (now - item.at > TTL_MS) extracted.delete(key)
103
+ }
75
104
  }
76
105
 
77
106
  /**
@@ -79,6 +108,9 @@ function sweep (): void {
79
108
  *
80
109
  * **只补传进来的那几项**:详情页发完先记封面,评论渲染完再补评论区,
81
110
  * 后一次调用不会把前一次记的封面抹掉。
111
+ *
112
+ * 顺带**重新武装**这次记的那几种图对应的按钮:新卡片要发出去了,
113
+ * 它下面的按钮应该可以再点一次(旧卡片上的那次点击不作数)。
82
114
  * @param key {@link cardImageKeyOf}
83
115
  * @param patch 要记录的内容
84
116
  */
@@ -88,12 +120,56 @@ export function rememberCardImages (key: string, patch: { cover?: string, commen
88
120
  const prev = store.get(key)
89
121
  const next: CardImages = prev ?? { key, at: Date.now() }
90
122
  next.at = Date.now()
91
- if (patch.cover) next.cover = patch.cover
92
- if (patch.comment?.length) next.comment = patch.comment
93
- if (patch.commentPics?.length) next.commentPics = patch.commentPics
123
+ if (patch.cover) {
124
+ next.cover = patch.cover
125
+ extracted.delete(extractedKeyOf(key, 'cover'))
126
+ }
127
+ if (patch.comment?.length) {
128
+ next.comment = patch.comment
129
+ extracted.delete(extractedKeyOf(key, 'comment'))
130
+ }
131
+ if (patch.commentPics?.length) {
132
+ next.commentPics = patch.commentPics
133
+ extracted.delete(extractedKeyOf(key, 'comment'))
134
+ }
94
135
  store.set(key, next)
95
136
  }
96
137
 
138
+ /**
139
+ * 占一次「卡片按钮已经点过了」。**只有 QQ 回调按钮走这里**(手敲指令不调)。
140
+ *
141
+ * 先占后发、发失败再 {@link releaseCardImageExtract}:
142
+ * 反过来「先发成功再记」的话,快速连点会有几次同时通过检查,照样能刷出好几条。
143
+ * @param key 作品键({@link cardImageKeyOf})
144
+ * @param kind 封面还是评论区
145
+ * @param by 点击者(用于日志里认出「谁在刷屏」)
146
+ * @returns 第一次点返回 `true`(放行);之前已经点过返回 `false`
147
+ */
148
+ export function claimCardImageExtract (key: string, kind: CardImageKind, by: string): boolean {
149
+ if (!key) return true
150
+ sweep()
151
+ const id = extractedKeyOf(key, kind)
152
+ if (extracted.has(id)) return false
153
+ extracted.set(id, { at: Date.now(), by: String(by ?? '') })
154
+ return true
155
+ }
156
+
157
+ /** 撤销一次占用(这次没发出去 —— 过期、没图、发送报错 —— 不该算「点过了」) */
158
+ export function releaseCardImageExtract (key: string, kind: CardImageKind): void {
159
+ if (!key) return
160
+ extracted.delete(extractedKeyOf(key, kind))
161
+ }
162
+
163
+ /**
164
+ * 这个按钮是不是已经点过了(日志用:带上是谁点的)。
165
+ * @param key 作品键
166
+ * @param kind 封面还是评论区
167
+ */
168
+ export function recallCardImageExtract (key: string, kind: CardImageKind): { at: number, by: string } | undefined {
169
+ if (!key) return undefined
170
+ return extracted.get(extractedKeyOf(key, kind))
171
+ }
172
+
97
173
  /**
98
174
  * 记下「这个会话最近解析的是哪个作品」。
99
175
  * 用户手敲 `kkk封面`(不带参数)时用它兜底 —— 按钮自己是带参数的,不走这条路。
@@ -83,7 +83,7 @@ import {
83
83
  buildBilibiliArticleRichText,
84
84
  buildBilibiliDynamicRichText,
85
85
  buildBilibiliRichTextForwardMessage,
86
- buildBilibiliVideoDescRichText,
86
+ buildVideoDescRichText,
87
87
  getUsernameMetadata
88
88
  } from '@/platform/bilibili/dynamic-text'
89
89
  import {
@@ -390,9 +390,7 @@ export class Bilibili extends Base {
390
390
  const img = await Render(this.e, 'bilibili/videoInfo', {
391
391
  share_url: 'https://b23.tv/' + infoData.data.data.bvid,
392
392
  title: infoData.data.data.title,
393
- desc: infoData.data.data.desc_v2?.length
394
- ? buildBilibiliVideoDescRichText(infoData.data.data.desc_v2)
395
- : buildBilibiliDynamicRichText(infoData.data.data.desc || '', []),
393
+ desc: buildVideoDescRichText(infoData.data.data.desc_v2, infoData.data.data.desc),
396
394
  stat: infoData.data.data.stat,
397
395
  bvid: infoData.data.data.bvid,
398
396
  ctime: infoData.data.data.ctime,
@@ -1319,9 +1317,7 @@ export class Bilibili extends Base {
1319
1317
  img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', {
1320
1318
  image_url: INFODATA.data.data.pic,
1321
1319
  text: buildBilibiliDynamicRichText(INFODATA.data.data.title, []),
1322
- desc: INFODATA.data.data.desc_v2?.length
1323
- ? buildBilibiliVideoDescRichText(INFODATA.data.data.desc_v2)
1324
- : buildBilibiliDynamicRichText(INFODATA.data.data.desc || '', []),
1320
+ desc: buildVideoDescRichText(INFODATA.data.data.desc_v2, INFODATA.data.data.desc),
1325
1321
  dynamic_text: dynamicText,
1326
1322
  dianzan: Count(INFODATA.data.data.stat.like),
1327
1323
  pinglun: Count(INFODATA.data.data.stat.reply),
@@ -272,9 +272,16 @@ export const buildBilibiliDynamicRichText = (
272
272
  * desc_v2 结构:
273
273
  * - type: 1 → 纯文本(含换行、链接)
274
274
  * - type: 2 → @提及(biz_id 为用户ID)
275
+ *
276
+ * **入参可能是 `null`/`undefined`**(接口在某些情况下就是这么给的,amagi 自己的类型里
277
+ * 也写着 `desc_v2: null`),这里必须容错 —— 直接 `for…of` 会抛 `descV2 is not iterable`。
278
+ * 一般不要直接调它,用 {@link buildVideoDescRichText},它会把 `desc` 兜底也算进去。
279
+ *
280
+ * @param descV2 接口给的 desc_v2(可为空)
275
281
  */
276
- export const buildBilibiliVideoDescRichText = (descV2: Array<{ raw_text?: string; type?: number; biz_id?: number }>): RichTextDocument => {
282
+ export const buildBilibiliVideoDescRichText = (descV2: Array<{ raw_text?: string; type?: number; biz_id?: number }> | null | undefined): RichTextDocument => {
277
283
  const nodes: RichTextNode[] = []
284
+ if (!descV2) return createRichTextDocument(nodes, { platform: 'bilibili' })
278
285
  for (const item of descV2) {
279
286
  const rawText = item.raw_text || ''
280
287
  if (!rawText) continue
@@ -301,6 +308,48 @@ export const buildBilibiliVideoDescRichText = (descV2: Array<{ raw_text?: string
301
308
  return createRichTextDocument(nodes, { platform: 'bilibili' })
302
309
  }
303
310
 
311
+ /**
312
+ * 视频详情的简介 → 富文本(**卡片该用这个,不要自己在调用处写三元**)。
313
+ *
314
+ * ## 为什么要有它
315
+ *
316
+ * 各调用点以前是这么写的:
317
+ *
318
+ * ```ts
319
+ * desc: data.desc_v2?.length
320
+ * ? buildBilibiliVideoDescRichText(data.desc_v2)
321
+ * : buildBilibiliDynamicRichText(data.desc || '', [])
322
+ * ```
323
+ *
324
+ * 看着没问题,但 `desc_v2` **不保证是「非空数组」** —— 接口至少有两种给不出内容的形态:
325
+ *
326
+ * - **`null`**:amagi 自己的类型里就有一支专职写着 `desc_v2: null`;
327
+ * - **`[{ raw_text: '', type: 1 }]`**:数组长度是 1,但里面一个字都没有。
328
+ *
329
+ * 前者 `?.length` 是 `undefined`、后者 `?.length` 是 1,判断结果不同,
330
+ * 但**都不能产生可显示的简介** —— 于是交给模板的都是空文档,
331
+ * 而卡片模板里写的是 `props.data.desc &&`,空文档为假 ⇒ **整块简介不渲染**,
332
+ * 而 `desc` 纯文本里明明有内容。用户反馈的「封面卡片没有简介」就是这个。
333
+ *
334
+ * 这个函数把口径收在一处,按**能不能产出内容**判断,而不是按长度:
335
+ * - `desc_v2` 能产出非空文档 → 用它(带 @ 提及、富文本样式);
336
+ * - 否则 → 用 `desc` 纯文本兜底(**关键:desc_v2 为空时不能把 desc 也一起丢掉**);
337
+ * - 两个都没有 → 返回**合法的空文档**(`nodes: []`),模板判假后自然不显示,
338
+ * 不会抛错、也不会渲染出 `undefined` 字样。
339
+ *
340
+ * @param descV2 接口给的 desc_v2(数组 / null / undefined / 空内容数组都可能)
341
+ * @param desc 接口给的 desc 纯文本(兜底用)
342
+ */
343
+ export const buildVideoDescRichText = (
344
+ descV2: Array<{ raw_text?: string; type?: number; biz_id?: number }> | null | undefined,
345
+ desc: string | null | undefined
346
+ ): RichTextDocument => {
347
+ const fromV2 = buildBilibiliVideoDescRichText(descV2)
348
+ /** 真的产出了内容才用它:空数组、全空串、null 都会走到 desc 兜底 */
349
+ if (fromV2.nodes.length > 0) return fromV2
350
+ return buildBilibiliDynamicRichText(desc || '', [])
351
+ }
352
+
304
353
  /** 修复图片 URL 协议。 */
305
354
  const fixImageUrl = (url: string): string => {
306
355
  if (url.startsWith('//')) {
@@ -40,7 +40,7 @@ import {
40
40
  buildBilibiliArticleRichText,
41
41
  buildBilibiliDynamicRichText,
42
42
  buildBilibiliRichTextForwardMessage,
43
- buildBilibiliVideoDescRichText,
43
+ buildVideoDescRichText,
44
44
  getUsernameMetadata
45
45
  } from '@/platform/bilibili/dynamic-text'
46
46
  import { buildBilibiliLiveSessionId, parseBilibiliLiveStartedAt } from '@/platform/bilibili/live-status'
@@ -516,9 +516,7 @@ export class Bilibilipush extends Base {
516
516
  img = await Render(this.e, 'bilibili/dynamic/DYNAMIC_TYPE_AV', {
517
517
  image_url: INFODATA.data.data.pic,
518
518
  text: buildBilibiliDynamicRichText(INFODATA.data.data.title, []),
519
- desc: INFODATA.data.data.desc_v2?.length
520
- ? buildBilibiliVideoDescRichText(INFODATA.data.data.desc_v2)
521
- : buildBilibiliDynamicRichText(INFODATA.data.data.desc || '', []),
519
+ desc: buildVideoDescRichText(INFODATA.data.data.desc_v2, INFODATA.data.data.desc),
522
520
  dynamic_text: dynamicText,
523
521
  dianzan: Count(INFODATA.data.data.stat.like),
524
522
  pinglun: Count(INFODATA.data.data.stat.reply),