koishi-plugin-kkk 3.9.1 → 3.10.0

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,50 @@
1
1
  # 更新日志
2
2
 
3
+ ## 3.10.0
4
+
5
+ > 解析消息底部的「打开原站」链接换成了**代码块形式的跳转卡片**:
6
+ > 整段复制后打开对应 App 会直接跳到那条作品,不再依赖 QQ 会不会把链接渲染成可点的东西。
7
+ > 快手和小红书以前完全没有这条链接,现在补上了。
8
+
9
+ ### 原站跳转改成「复制码」
10
+
11
+ - 发出的东西从 `[打开原站](链接)` 变成代码块:
12
+
13
+ ````
14
+ ```复制后打开抖音自动跳转 →
15
+ https://v.douyin.com/iQabcdef/ :1pm Mai:/ 2237886846@qq.com
16
+ ```
17
+ ````
18
+
19
+ 各家 App 都认「剪贴板里有一条自家链接」——复制整段文本打开 App 会弹直达询问,
20
+ 比点链接稳;官方 QQ 对 markdown 里的链接也经常直接拒收整条消息,代码块没有这个问题。
21
+ - 提示文案放在**围栏的语种位**(` ``` ` 后面那截),不占正文一行,
22
+ 代码块里就只有链接本身,全选复制不会带上多余文字。
23
+ - **抖音附带唤起口令**:光有链接 App 不一定弹跳转,接一段口令才稳。
24
+ 这是抖音 App 生成的固定串(每条作品都一样),不是模板。
25
+ - **快手、小红书单独发一条**:这两个平台没有清晰度面板——原来的跳转代码挂在画质表格下面,
26
+ 它们压根走不到,等于一条都没发过。现在在解析内容发完后补一条同格式的消息。
27
+ - 开关沿用 WebUI 的「原站跳转」(QQ 适配器分组,`qqPanelSourceLink`,默认开);
28
+ 跳转发送失败只记日志,**不影响解析结果**。
29
+
30
+ ### 跳转链接用用户发的那条
31
+
32
+ - 一开始让跳转链接和卡片右下角的二维码保持一致(`b23.tv/<bvid>`、抖音 `share_url`),
33
+ 实测行不通:那种链接带 `xsec_token` / `u_code` 一串参数,**太长,App 剪贴板识别不了**。
34
+ - 改成优先用用户原样发的那条(`b23.tv/xxx`、`v.douyin.com/xxx`、`v.kuaishou.com/xxx`、
35
+ `xhslink.com/o/xxx`)——那本来就是各家 App 自己生成的分享形态,最短也最容易被识别。
36
+ 取不到时才退回插件拼的标准链接。
37
+ - 卡片二维码**没动**:扫码不吃 URL 长度,换个链接反而要重测,保持原样。
38
+ - 顺带修了快手卡片二维码:以前填的是视频 CDN 直链,扫出来是段播放地址而不是作品页,
39
+ 现在指向作品页(`photoId` 取不到时仍退回原逻辑)。
40
+
41
+ ### markdown 只对 QQ / QQ 频道发
42
+
43
+ - 能渲染 markdown 的只有官方 QQ 适配器和 QQ 频道,其它平台(OneBot / Discord / Telegram /
44
+ KOOK…)会把代码块的 ` ``` ` 围栏原样显示成一串反引号。
45
+ - 判定从 OneBot 黑名单改成**平台白名单**(`qq` / `qqguild`):这两个发 markdown 代码块,
46
+ 其余一律发去围栏的纯文本(提示一行 + 链接一行),照样整段复制、App 一样认。
47
+
3
48
  ## 3.9.1
4
49
 
5
50
  > 修 MySQL 上启动直接报 `ER_TOO_LONG_KEY`、插件起不来的问题。
@@ -1,10 +1,10 @@
1
1
  {
2
- "version": "3.9.1",
3
- "buildTime": "2026-09-29T10:19:42.069Z",
4
- "buildTimestamp": 1790677182093,
2
+ "version": "3.10.0",
3
+ "buildTime": "2026-09-29T18:43:09.611Z",
4
+ "buildTimestamp": 1790707389633,
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": "0dde6fc426986698fb0e937768eb4aa86d24f2d4",
9
- "shortCommitHash": "0dde6fc"
8
+ "commitHash": "ec43387a40b337a0c1430c7b2eef36668fa92fc5",
9
+ "shortCommitHash": "ec43387"
10
10
  }
@@ -229,7 +229,13 @@ const handleDouyin = (0, ParseForward_1.withParseForward)((0, ErrorHandler_1.wra
229
229
  * 面板按钮里继续用**原始分享链接**:`getDouyinID` 是靠跟随跳转拿 aweme_id 的,
230
230
  * 换成 `www.douyin.com/video/{id}` 反而可能撞上抖音的验证页。
231
231
  */
232
- if (iddata.type === 'one_work' && await tryQqPanel(e, { platform: 'douyin', url, id: String(iddata.aweme_id) }, flags)) {
232
+ if (iddata.type === 'one_work' && await tryQqPanel(e, {
233
+ platform: 'douyin',
234
+ url,
235
+ id: String(iddata.aweme_id),
236
+ // 跳转块用用户原样发的那条(App 分享出来的短链,识别率最高)
237
+ jumpUrl: url
238
+ }, flags)) {
233
239
  return;
234
240
  }
235
241
  // 真的开始解析了才提示「本部署烧不了弹幕」——切换面板时要重发面板,那时提示是多余的
@@ -320,7 +326,12 @@ const handleBilibili = (0, ParseForward_1.withParseForward)((0, ErrorHandler_1.w
320
326
  platform: 'bilibili',
321
327
  url: 'https://www.bilibili.com/video/' + iddata.bvid + (iddata.p ? '?p=' + iddata.p : ''),
322
328
  id: String(iddata.bvid),
323
- page: iddata.p
329
+ page: iddata.p,
330
+ /**
331
+ * 跳转块用**用户发出来的那条**(通常是 b23.tv 短链):
332
+ * 上面那条规范链接带 ?p= 参数,长度上去了 App 剪贴板就认不出来。
333
+ */
334
+ jumpUrl: String(url)
324
335
  }, flags)) {
325
336
  return;
326
337
  }
@@ -385,7 +396,13 @@ const handleKuaishou = (0, ParseForward_1.withParseForward)((0, ErrorHandler_1.w
385
396
  }
386
397
  const iddata = await (0, kuaishou_1.getKuaishouID)(String(kuaishouUrl));
387
398
  const WorkData = await (0, kuaishou_1.fetchKuaishouData)(iddata.type, iddata);
388
- const kuaishou = new kuaishou_1.Kuaishou(e, iddata);
399
+ /**
400
+ * 跳转块要用**用户发出来的那条**:`match(...g)` 拿到的是数组,
401
+ * `String(数组)` 会把多条链接用逗号拼成一串(一条链接时碰巧正确),
402
+ * 这里显式取第一个,保证只发出一条干净的原链接。
403
+ */
404
+ const kuaishouOrigin = Array.isArray(kuaishouUrl) && kuaishouUrl.length ? String(kuaishouUrl[0]) : String(kuaishouUrl ?? '');
405
+ const kuaishou = new kuaishou_1.Kuaishou(e, iddata, kuaishouOrigin);
389
406
  await (0, ParseOverride_1.runWithParseOverride)({
390
407
  ...flags.override,
391
408
  /** 面板按钮点出来的:只回「收到请求,开始下载」,不再发「检测到链接,开始解析」 */
@@ -427,7 +444,7 @@ const handleXiaohongshu = (0, ParseForward_1.withParseForward)((0, ErrorHandler_
427
444
  return;
428
445
  }
429
446
  const iddata = await (0, xiaohongshu_1.getXiaohongshuID)(url);
430
- const xiaohongshu = new xiaohongshu_1.Xiaohongshu(e, iddata);
447
+ const xiaohongshu = new xiaohongshu_1.Xiaohongshu(e, iddata, url);
431
448
  await (0, ParseOverride_1.runWithParseOverride)({
432
449
  ...flags.override,
433
450
  fromPanel: flags.panelToken !== undefined || flags.panel !== undefined
@@ -36,12 +36,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.buildImageMessageFor = exports.buildMarkdownImageMessage = exports.toMarkdownImage = exports.sendParseTip = void 0;
39
+ exports.buildImageMessageFor = exports.buildMarkdownImageMessage = exports.toMarkdownImage = exports.sendParseTip = exports.supportsMarkdown = void 0;
40
40
  exports.rememberPanelRequest = rememberPanelRequest;
41
41
  exports.resolvePanelQualitySize = resolvePanelQualitySize;
42
42
  exports.resolvePanelToken = resolvePanelToken;
43
43
  exports.isQqPlatform = isQqPlatform;
44
44
  exports.sourceLink = sourceLink;
45
+ exports.copyJumpBlock = copyJumpBlock;
46
+ exports.douyinShareUrl = douyinShareUrl;
47
+ exports.bilibiliShareUrl = bilibiliShareUrl;
48
+ exports.kuaishouShareUrl = kuaishouShareUrl;
49
+ exports.xiaohongshuShareUrl = xiaohongshuShareUrl;
50
+ exports.sendCopyJumpMessage = sendCopyJumpMessage;
45
51
  exports.cmdInput = cmdInput;
46
52
  exports.rememberPanelMessage = rememberPanelMessage;
47
53
  exports.recallLastPanel = recallLastPanel;
@@ -341,6 +347,152 @@ function sourceLink(label, url) {
341
347
  const safe = String(url).replace(/[()\s]/g, (char) => encodeURIComponent(char));
342
348
  return '[' + label + '](' + safe + ')';
343
349
  }
350
+ /**
351
+ * 「复制后打开 XX 自动跳转」代码块:
352
+ *
353
+ * ```复制后打开b站自动跳转 →
354
+ * https://b23.tv/BV…
355
+ * ```
356
+ *
357
+ * 提示文案放在**围栏的信息位**(` ``` ` 后面那段,也就是 markdown 写语言的地方),
358
+ * 不占正文一行 —— 代码块里只有那条链接,全选复制时不会带上多余文字,
359
+ * 渲染出来提示仍是显示在代码块顶上的。
360
+ *
361
+ * 不用 `[打开原站](url)` 是因为那种链接能不能点取决于 adapter —— 官方 QQ 接口实测会拒收
362
+ * (见 `sourceLink` 的说明)。而各家 App 都认「剪贴板里有一条自家链接」:
363
+ * 复制整段文本后打开 App 会弹出打开询问,比点链接稳。
364
+ * @param appName App 名字,写进提示文案里(b站 / 抖音)
365
+ * @param url 目标地址(放进代码块的是纯文本,不做 markdown 编码)
366
+ * @returns 代码块的行数组;没地址时是空数组,调用方直接展开即可
367
+ */
368
+ /**
369
+ * 抖音 App 的唤起口令。
370
+ *
371
+ * 抖音认的是「剪贴板里有一段带口令的文本」:光有一条链接,App 不一定弹跳转询问,
372
+ * 链接后接着这段口令才能保证复制后打开抖音直达作品。
373
+ * 这是抖音 App 生成的固定串(每条作品都一样,接口里没有对应字段),别当成模板去替换。
374
+ */
375
+ const DOUYIN_JUMP_TOKEN = ':1pm Mai:/ 2237886846@qq.com';
376
+ /**
377
+ * @param appName App 名字,写进提示文案里(b站 / 抖音)
378
+ * @param url 目标地址(放进代码块的是纯文本,不做 markdown 编码)
379
+ * @param tail 追加在链接后面的文字(抖音的唤起口令走这里)
380
+ */
381
+ function copyJumpBlock(appName, url, tail = '') {
382
+ if (!url)
383
+ return [];
384
+ // 链接和口令之间留两个空格:抖音的口令是独立一段,贴着链接会被当成 URL 的一部分
385
+ // 提示末尾的箭头指向代码块右上角的「复制」按钮 —— 复制的是整段(这里只有链接),App 才会跳转
386
+ return ['```' + '复制后打开' + appName + '自动跳转 →', String(url) + (tail ? ' ' + tail : ''), '```'];
387
+ }
388
+ /**
389
+ * 抖音作品的分享链接(**给卡片右下角二维码用的**)。
390
+ *
391
+ * 优先取接口返回的 `share_url`:那是抖音 App 生成的分享链接,后面带一串参数,
392
+ * 扫码场景没问题(二维码不吃 URL 长度),比自己拼的裸链接更接近官方形态;
393
+ * 拿不到(接口改版 / 风控)才退回标准链接。
394
+ *
395
+ * 注意它**不适合作为「复制跳转」的链接** —— 参数太长,App 的剪贴板识别不了,
396
+ * 那种场景要用用户原样发的那条(见 `sendParsePanel` 里 `request.jumpUrl`)。
397
+ * @param awemeId 作品 id
398
+ * @param detailShareUrl 作品详情里的 `share_url`
399
+ */
400
+ function douyinShareUrl(awemeId, detailShareUrl) {
401
+ return String(detailShareUrl ?? '') || ('https://www.douyin.com/video/' + String(awemeId));
402
+ }
403
+ /**
404
+ * B站作品的跳转链接。
405
+ *
406
+ * 用 **b23.tv 短链**(`https://b23.tv/<bvid>`):卡片右下角二维码走的就是这条,
407
+ * 扫二维码和复制跳转是同一件事,两处链接必须一致。
408
+ */
409
+ function bilibiliShareUrl(bvid) {
410
+ return 'https://b23.tv/' + String(bvid);
411
+ }
412
+ /** 快手作品页链接(App 认的是自家域名下的链接,短链最终也是跳到这里) */
413
+ function kuaishouShareUrl(photoId) {
414
+ return 'https://www.kuaishou.com/short-video/' + String(photoId);
415
+ }
416
+ /**
417
+ * 小红书笔记页链接。
418
+ *
419
+ * 和详情卡片的二维码**逐字符对齐**(含 `xsec_token` 那串参数)——
420
+ * 卡片那儿一直是这么拼的,跳转块换用同一个函数即可,别再单独拼一遍。
421
+ * @param noteId 笔记 id
422
+ * @param xsecToken 访问令牌;没有就退回到不带参数的短形式
423
+ */
424
+ function xiaohongshuShareUrl(noteId, xsecToken = '') {
425
+ const base = 'https://www.xiaohongshu.com/discovery/item/' + String(noteId);
426
+ return xsecToken
427
+ ? base + '?source=webshare&xhsshare=pc_web&xsec_token=' + String(xsecToken) + '&xsec_source=pc_share'
428
+ : base;
429
+ }
430
+ /**
431
+ * **能真正渲染 markdown 的平台** —— 只有官方 QQ 和 QQ 频道,别的一律发纯文本。
432
+ *
433
+ * 白名单而不是黑名单:markdown 是腾讯那两个 adapter 的私货,
434
+ * OneBot(NapCat / Lagrange / go-cqhttp / Chronocat)、Discord、Telegram、KOOK…
435
+ * 都不认这东西。代码块的 ` ``` ` 围栏在它们那里会原样露出来,
436
+ * 用户看到的是一串反引号而不是代码块。
437
+ *
438
+ * 这些平台发**去围栏的纯文本**:提示一行 + 链接一行,照样能整段复制,App 一样认。
439
+ * @param platform `platformOf(e)` 拿到的适配器名
440
+ */
441
+ const supportsMarkdown = (platform) => {
442
+ const name = String(platform ?? '').toLowerCase();
443
+ return name === 'qq' || name === 'qqguild';
444
+ };
445
+ exports.supportsMarkdown = supportsMarkdown;
446
+ /**
447
+ * **没有清晰度面板的平台**:单独发一条「复制后打开 XX 自动跳转」。
448
+ *
449
+ * B站 / 抖音那条 repeat 代码块挂在清晰度表格下面(`sendParsePanel`),
450
+ * 而快手、小红书压根没有可选画质的面板——本来也不发任何原站链接。
451
+ * 这里给它们补一条同格式的独立消息,行为和开关都跟面板里的那行一致。
452
+ *
453
+ * 发送失败**不影响解析**:跳转是锦上添花,主流程(卡片 / 图片 / 视频)都已经发出去了。
454
+ * @param appName App 名字(快手 / 小红书)
455
+ * @param url 目标地址;空则直接跳过
456
+ * @param tail 追加在链接后面的文字(抖音走 QqPanel 内部的口令,其它平台不用)
457
+ * @returns 是否发出去了
458
+ */
459
+ async function sendCopyJumpMessage(e, appName, url, tail = '') {
460
+ if (!url)
461
+ return false;
462
+ if ((0, runtime_1.tryGetRuntime)()?.config.qqPanelSourceLink === false)
463
+ return false;
464
+ const lines = copyJumpBlock(appName, url, tail);
465
+ /**
466
+ * 去掉围栏就是同内容的纯文本 —— 给不渲染 markdown 的适配器兜底。
467
+ * 提示行现在是 ``` 开头的(信息位写法),去掉 ``` 正好还原成一句提示。
468
+ */
469
+ const plain = lines
470
+ .map((line) => (line.startsWith('```') ? line.slice(3) : line))
471
+ .filter(Boolean)
472
+ .join(String.fromCharCode(10));
473
+ const content = (0, exports.supportsMarkdown)((0, ImageSlice_1.platformOf)(e))
474
+ ? node_karin_1.segment.markdown(lines.join(String.fromCharCode(10)))
475
+ : plain;
476
+ try {
477
+ await e.reply(content);
478
+ return true;
479
+ }
480
+ catch (error) {
481
+ /**
482
+ * 官方 QQ 有时因为消息里的链接形式拒收整条 markdown(见 `sourceLink` 的说明),
483
+ * 这里退回纯文本再试一次;连纯文本都发不出去就跳过,不影响主流程。
484
+ */
485
+ node_karin_1.logger.debug('[原站跳转] ' + appName + ' markdown 发送失败,退回纯文本: ' + String(error?.message ?? error).slice(0, 120));
486
+ try {
487
+ await e.reply(plain);
488
+ return true;
489
+ }
490
+ catch (retryError) {
491
+ node_karin_1.logger.warn('[原站跳转] ' + appName + ' 发送失败,已跳过: ' + String(retryError?.message ?? retryError).slice(0, 120));
492
+ return false;
493
+ }
494
+ }
495
+ }
344
496
  function cmdInput(command, show) {
345
497
  const text = encodeURIComponent(command).replace(/'/g, '%27');
346
498
  const label = encodeURIComponent(show ?? command).replace(/'/g, '%27');
@@ -473,6 +625,10 @@ async function showLoadingTip(e) {
473
625
  *
474
626
  * 单独认出来是为了**发送失败时能摘掉它重发**:链接只是锦上添花,
475
627
  * 不能因为它(不同 adapter / 版本的链接限制不一样)把整条面板甚至整个解析搞挂。
628
+ *
629
+ * 「复制跳转」代码块(`copyJumpBlock`)也是链接行,但它跨 4 行带 ``` 围栏,
630
+ * 按行内容匹配容易漏(漏掉围栏会把后面的文字全包进代码块),所以那几行由调用方
631
+ * 用**下标范围**传给 `sendPanelMarkdown`。
476
632
  */
477
633
  const isSourceLinkLine = (line) => /^\[[^\]]+\]\([^)]+\)\s*$/.test(line.trim());
478
634
  /**
@@ -484,14 +640,18 @@ const isSourceLinkLine = (line) => /^\[[^\]]+\]\([^)]+\)\s*$/.test(line.trim());
484
640
  * 先照常发,失败且内容里确实有链接行时,去掉链接行再发一次,成功就当没事发生。
485
641
  * @param lines 面板的 markdown 行
486
642
  * @param send 真正发送的函数(第一次/重发都走它)
643
+ * @param linkRange 「打开原站」那几行在 lines 里的下标范围 [起, 止);
644
+ * 不传时退回到按内容匹配 `[label](url)` 那一种写法
487
645
  * @returns 最后一次发送的结果
488
646
  */
489
- async function sendPanelMarkdown(lines, send) {
647
+ async function sendPanelMarkdown(lines, send, linkRange) {
490
648
  try {
491
649
  return { sent: await send(node_karin_1.segment.markdown(lines.join(String.fromCharCode(10)))), droppedLink: false };
492
650
  }
493
651
  catch (error) {
494
- const withoutLink = lines.filter((line) => !isSourceLinkLine(line));
652
+ const withoutLink = linkRange
653
+ ? [...lines.slice(0, linkRange[0]), ...lines.slice(linkRange[1])]
654
+ : lines.filter((line) => !isSourceLinkLine(line));
495
655
  if (withoutLink.length === lines.length)
496
656
  throw error;
497
657
  node_karin_1.logger.warn('[QQ面板] 带链接的面板发送失败(' + String(error?.message ?? error).slice(0, 120) + '),去掉链接行重发一次');
@@ -572,7 +732,8 @@ async function uploadPanelCard(e, request, detail, hotDanmaku = []) {
572
732
  e,
573
733
  Detail_Data: detail,
574
734
  create_time: Number(detail?.create_time) || Math.floor(Date.now() / 1000),
575
- shareLink: 'https://www.douyin.com/video/' + String(detail?.aweme_id ?? request.id),
735
+ // 和右下角二维码同一个链接:抖音那条带一串分享参数,App 才认
736
+ shareLink: douyinShareUrl(String(detail?.aweme_id ?? request.id), detail?.share_url),
576
737
  // 还没选档,所以不显示分辨率块(和分享信息图一致)
577
738
  videoSource: undefined
578
739
  });
@@ -602,7 +763,7 @@ async function uploadPanelCard(e, request, detail, hotDanmaku = []) {
602
763
  const route = 'bilibili/videoInfo';
603
764
  const cardData = request.platform === 'bilibili'
604
765
  ? {
605
- share_url: 'https://b23.tv/' + (detail.bvid ?? request.id),
766
+ share_url: bilibiliShareUrl(String(detail.bvid ?? request.id)),
606
767
  title: detail.title,
607
768
  desc: '',
608
769
  stat: detail.stat,
@@ -1013,16 +1174,36 @@ async function sendQqParsePanel(e, request) {
1013
1174
  * 「打开原站」:放在表格下方(用户要求的位置)——用户看完卡片和画质后,
1014
1175
  * 想直接去平台看原作品时不用再翻聊天记录找链接。
1015
1176
  * 开关在 WebUI 的 QQ 适配器分组(qqPanelSourceLink,默认开)。
1177
+ *
1178
+ * B站 / 抖音给的是「复制后打开 XX 自动跳转」代码块:各家 App 都认剪贴板里的自家链接,
1179
+ * 不依赖 QQ 会不会把 markdown 链接渲染成可点的东西。快手之类仍给普通链接。
1180
+ *
1181
+ * 链接**用用户原样发的那条**(`request.jumpUrl`),拼不出来才退回 `request.url`。
1182
+ * 之前按「和卡片二维码一致」去用 `bilibiliShareUrl` / `douyinShareUrl`,实测行不通:
1183
+ * 那种链接带一串 `xsec_token` / `u_code` 之类的参数,长到 App 的剪贴板识别压根不认。
1184
+ * 反过来用户发出来的 `b23.tv/xxx`、`v.douyin.com/xxx` 本来就是 App 自己生成的分享形态,
1185
+ * 最短也最容易被识别。二维码那边不受影响(扫码不吃 URL 长度),保持原来的链接不动。
1016
1186
  */
1017
- if (runtime.config.qqPanelSourceLink !== false && request.url)
1018
- lines.push(sourceLink('打开原站', request.url));
1187
+ const linkStart = lines.length;
1188
+ if (runtime.config.qqPanelSourceLink !== false && request.url) {
1189
+ // 优先用户原样发的那条:我们拼的链接偏长,App 剪贴板识别不了;App 自家分享的短链才稳
1190
+ const jumpUrl = request.jumpUrl || request.url;
1191
+ const block = request.platform === 'bilibili'
1192
+ ? copyJumpBlock('b站', jumpUrl)
1193
+ : request.platform === 'douyin'
1194
+ ? copyJumpBlock('抖音', jumpUrl, DOUYIN_JUMP_TOKEN)
1195
+ : [sourceLink('打开原站', jumpUrl)];
1196
+ lines.push(...block);
1197
+ }
1019
1198
  // 带链接发不出去时自动去掉链接行重发(不然整条面板、整个解析都会被一个链接拖死)
1199
+ // 代码块连围栏共 3 行,所以按**下标范围**整块摘,不能只删中间那行
1200
+ const linkRange = lines.length > linkStart ? [linkStart, lines.length] : undefined;
1020
1201
  const { sent } = await sendPanelMarkdown(lines, async (content) => {
1021
1202
  // 卡片已经撤过「加载中…」了;卡片没发出去(或渲染失败)时这里补撤一次
1022
1203
  if (!cardSent)
1023
1204
  await recallMessageById(e, loadingId);
1024
1205
  return await e.reply(content);
1025
- });
1206
+ }, linkRange);
1026
1207
  rememberPanelMessage(e, sent?.messageId);
1027
1208
  node_karin_1.logger.debug('[QQ面板] 已发送解析面板: ' + request.platform + ' ' + request.id + '(' + shown.length + '/' + info.options.length + ' 档画质)');
1028
1209
  return true;
@@ -6,6 +6,7 @@ const module_1 = require("../../module");
6
6
  const ParseSteps_1 = require("../../module/utils/ParseSteps");
7
7
  // sendParseTip 单独导入:它在一个无依赖的叶子模块里,避免和平台模块形成循环 import
8
8
  const parseTip_1 = require("../../module/utils/parseTip");
9
+ const QqPanel_1 = require("../../module/utils/QqPanel");
9
10
  // 注意用相对写法:@/ 别名在仓库里指向 karin/,@/player 会被解析成不存在的 karin/player
10
11
  const player_1 = require("../../../player");
11
12
  const kuaishou_1 = require("../../platform/kuaishou");
@@ -35,10 +36,20 @@ class Kuaishou extends module_1.Base {
35
36
  * 本插件只解析视频,图集/单图会在下面提前返回,那时保持 undefined。
36
37
  */
37
38
  workType;
38
- constructor(e, iddata) {
39
+ /** ID 解析结果:里面的 photoId 用来兜底拼作品页链接 */
40
+ iddata;
41
+ /**
42
+ * **用户消息里原样提取出来的链接**(`v.kuaishou.com/xxx` 这类 App 分享短链)。
43
+ * 「复制后打开快手自动跳转」优先用这条 —— 它是快手 App 自己生成的,识别率最高;
44
+ * 我们拼的长链接(哪怕只是 `short-video/<photoId>`)App 未必认。
45
+ */
46
+ originUrl;
47
+ constructor(e, iddata, originUrl = '') {
39
48
  super(e);
40
49
  this.e = e;
41
50
  this.type = iddata?.type;
51
+ this.iddata = iddata;
52
+ this.originUrl = String(originUrl ?? '');
42
53
  }
43
54
  async KuaishouHandler(data) {
44
55
  // 入参是 fetchKuaishouData 的联合返回,本插件只解析视频,先收窄到 one_work 那一支:
@@ -92,6 +103,20 @@ class Kuaishou extends module_1.Base {
92
103
  return true;
93
104
  }
94
105
  this.workType = 'video';
106
+ /**
107
+ * 卡片右下角二维码与「复制后打开快手自动跳转」共用**同一条作品页链接**。
108
+ *
109
+ * 原来这里给模板的是 `video_url`(CDN 视频直链),扫出来是一段播放地址、
110
+ * 不是作品页;快手 App 认的是自家域名下的链接,所以统一改成 `short-video/<photoId>`。
111
+ */
112
+ const photoId = String(work?.photo?.id ?? this.iddata?.photoId ?? '');
113
+ /**
114
+ * 取不到 photoId 时**不能给空串**:模板拿不到二维码内容会整张卡渲染不出来,
115
+ * 那种情况下退回 CDN 直链(原来的行为),至少卡片照常出、只是二维码还是播放地址。
116
+ * 「复制跳转」不受影响——它用自己的那份 shareUrl,取不到就不发。
117
+ */
118
+ const shareUrl = photoId ? (0, QqPanel_1.kuaishouShareUrl)(photoId) : '';
119
+ const cardShareUrl = shareUrl || video_url;
95
120
  /** 本次解析的步骤容器:单步失败只跳过、不中断,最后统一渲染一张错误卡片(见 ParseSteps) */
96
121
  const steps = new ParseSteps_1.ParseSteps();
97
122
  /** 发送任务组:评论区与视频各走一条线,谁先就绪谁先发 */
@@ -128,7 +153,7 @@ class Kuaishou extends module_1.Base {
128
153
  viewCount: work.photo.viewCount,
129
154
  CommentsData,
130
155
  CommentLength: CommentsData?.length ?? 0,
131
- share_url: video_url,
156
+ share_url: cardShareUrl,
132
157
  VideoSize: fileSizeInMB,
133
158
  likeCount: work.photo.likeCount
134
159
  });
@@ -147,6 +172,12 @@ class Kuaishou extends module_1.Base {
147
172
  await (0, module_1.uploadFile)(this.e, downloadedVideo, video_url, { message_id: this.e.messageId });
148
173
  });
149
174
  await sends.settle();
175
+ /**
176
+ * 「打开原站」**单独发一条**:快手没有清晰度面板(那套面板只有 B站 / 抖音有,
177
+ * 它们的跳转块挂在画质表格下面),所以这里在所有内容发完之后补一条同格式的消息。
178
+ * 发不出去也不影响解析(详情见 sendCopyJumpMessage)。
179
+ */
180
+ await (0, QqPanel_1.sendCopyJumpMessage)(this.e, '快手', this.originUrl || shareUrl);
150
181
  steps.throwIfFailed();
151
182
  return true;
152
183
  }
@@ -25,10 +25,18 @@ class Xiaohongshu extends module_1.Base {
25
25
  * 笔记详情拿到之前无法判定,所以留到 `XiaohongshuHandler` 里赋值。
26
26
  */
27
27
  workType;
28
- constructor(e, iddata) {
28
+ /**
29
+ * **用户消息里原样提取出来的链接**(`xhslink.com/o/xxx` 这类 App 分享短链)。
30
+ *
31
+ * 「复制后打开小红书自动跳转」优先用这条:卡片那条带 `xsec_token` 全参数的分享链接
32
+ * 实测**太长、App 剪贴板识别不了**,而用户发的是小红书 App 自己生成的,一定能识别。
33
+ */
34
+ originUrl;
35
+ constructor(e, iddata, originUrl = '') {
29
36
  super(e);
30
37
  this.e = e;
31
38
  this.type = iddata?.type;
39
+ this.originUrl = String(originUrl ?? '');
32
40
  }
33
41
  /**
34
42
  * 取够配置条数的笔记评论。
@@ -84,6 +92,13 @@ class Xiaohongshu extends module_1.Base {
84
92
  */
85
93
  const rawItem = Array.isArray(noteItems) ? noteItems[0] : undefined;
86
94
  const noteCard = rawItem?.note_card ?? rawItem?.noteCard ?? rawItem;
95
+ /**
96
+ * **卡片右下角的二维码与「复制跳转」必须是同一条链接**。
97
+ *
98
+ * 以前 noteInfo / comment 两个模板各拼一遍同样的字符串,跳转块再拼第三遍,
99
+ * 三处任何一处改动都会让它们指向不同地址。这里统一从这里取值。
100
+ */
101
+ const shareUrl = (0, QqPanel_1.xiaohongshuShareUrl)(String(data.note_id), String(data.xsec_token ?? ''));
87
102
  node_karin_2.logger.mark('[小红书] 条目键名: ' + JSON.stringify(Object.keys(rawItem ?? {}).slice(0, 12)) +
88
103
  ' / note_card 键名: ' + JSON.stringify(Object.keys(rawItem?.note_card ?? {}).slice(0, 16)));
89
104
  if (!noteCard) {
@@ -198,7 +213,7 @@ class Xiaohongshu extends module_1.Base {
198
213
  ?? '',
199
214
  time: noteCard.time,
200
215
  ip_location: noteCard.ip_location,
201
- share_url: `https://www.xiaohongshu.com/discovery/item/${data.note_id}?source=webshare&xhsshare=pc_web&xsec_token=${data.xsec_token}&xsec_source=pc_share`,
216
+ share_url: shareUrl,
202
217
  image_list: noteCard.image_list?.map((image) => image.url_default) ?? [],
203
218
  is_video: Boolean(noteCard.video)
204
219
  });
@@ -348,7 +363,7 @@ class Xiaohongshu extends module_1.Base {
348
363
  ImageLength: noteCard.image_list?.length || 0,
349
364
  // 提示直接写进卡片(模板的 ErrorText),不再单独发文字、也不带 emoji
350
365
  ErrorText: '评论数据获取失败,稍后再试试',
351
- share_url: 'https://www.xiaohongshu.com/discovery/item/' + data.note_id
366
+ share_url: shareUrl
352
367
  });
353
368
  await this.e.reply(emptyCommentCard);
354
369
  node_karin_2.logger.mark('[小红书] 已发出空的评论卡片(占位)');
@@ -370,7 +385,7 @@ class Xiaohongshu extends module_1.Base {
370
385
  CommentsData: processedComments,
371
386
  CommentLength: processedComments.length,
372
387
  ImageLength: noteCard.image_list?.length || 0,
373
- share_url: `https://www.xiaohongshu.com/discovery/item/${data.note_id}?source=webshare&xhsshare=pc_web&xsec_token=${data.xsec_token}&xsec_source=pc_share`
388
+ share_url: shareUrl
374
389
  });
375
390
  this.e.reply(commentListImg);
376
391
  }
@@ -552,6 +567,12 @@ class Xiaohongshu extends module_1.Base {
552
567
  * 由 ErrorHandler 渲染**一张**错误卡片(此时能发的卡片/图片/视频都已经发出去了)。
553
568
  */
554
569
  await sends.settle();
570
+ /**
571
+ * 「打开原站」**单独发一条**:小红书没有清晰度面板(那套面板只有 B站 / 抖音有,
572
+ * 它们的跳转块挂在画质表格下面),所以这里在所有内容发完之后补一条同格式的消息。
573
+ * 发不出去也不影响解析(详情见 sendCopyJumpMessage)。
574
+ */
575
+ await (0, QqPanel_1.sendCopyJumpMessage)(this.e, '小红书', this.originUrl || shareUrl);
555
576
  steps.throwIfFailed();
556
577
  return true;
557
578
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.9.1",
3
+ "version": "3.10.0",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -208,7 +208,13 @@ const handleDouyin = withParseForward(wrapWithErrorHandler(
208
208
  * 面板按钮里继续用**原始分享链接**:`getDouyinID` 是靠跟随跳转拿 aweme_id 的,
209
209
  * 换成 `www.douyin.com/video/{id}` 反而可能撞上抖音的验证页。
210
210
  */
211
- if (iddata.type === 'one_work' && await tryQqPanel(e, { platform: 'douyin', url, id: String(iddata.aweme_id) }, flags)) {
211
+ if (iddata.type === 'one_work' && await tryQqPanel(e, {
212
+ platform: 'douyin',
213
+ url,
214
+ id: String(iddata.aweme_id),
215
+ // 跳转块用用户原样发的那条(App 分享出来的短链,识别率最高)
216
+ jumpUrl: url
217
+ }, flags)) {
212
218
  return
213
219
  }
214
220
 
@@ -313,7 +319,12 @@ const handleBilibili = withParseForward(wrapWithErrorHandler(
313
319
  platform: 'bilibili',
314
320
  url: 'https://www.bilibili.com/video/' + iddata.bvid + (iddata.p ? '?p=' + iddata.p : ''),
315
321
  id: String(iddata.bvid),
316
- page: iddata.p
322
+ page: iddata.p,
323
+ /**
324
+ * 跳转块用**用户发出来的那条**(通常是 b23.tv 短链):
325
+ * 上面那条规范链接带 ?p= 参数,长度上去了 App 剪贴板就认不出来。
326
+ */
327
+ jumpUrl: String(url)
317
328
  }, flags)
318
329
  ) {
319
330
  return
@@ -388,7 +399,13 @@ const handleKuaishou = withParseForward(wrapWithErrorHandler(
388
399
  }
389
400
  const iddata = await getKuaishouID(String(kuaishouUrl))
390
401
  const WorkData = await fetchKuaishouData(iddata.type, iddata)
391
- const kuaishou = new Kuaishou(e, iddata)
402
+ /**
403
+ * 跳转块要用**用户发出来的那条**:`match(...g)` 拿到的是数组,
404
+ * `String(数组)` 会把多条链接用逗号拼成一串(一条链接时碰巧正确),
405
+ * 这里显式取第一个,保证只发出一条干净的原链接。
406
+ */
407
+ const kuaishouOrigin = Array.isArray(kuaishouUrl) && kuaishouUrl.length ? String(kuaishouUrl[0]) : String(kuaishouUrl ?? '')
408
+ const kuaishou = new Kuaishou(e, iddata, kuaishouOrigin)
392
409
  await runWithParseOverride(
393
410
  {
394
411
  ...flags.override,
@@ -437,7 +454,7 @@ const handleXiaohongshu = withParseForward(wrapWithErrorHandler(
437
454
  return
438
455
  }
439
456
  const iddata = await getXiaohongshuID(url)
440
- const xiaohongshu = new Xiaohongshu(e, iddata)
457
+ const xiaohongshu = new Xiaohongshu(e, iddata, url)
441
458
  await runWithParseOverride(
442
459
  {
443
460
  ...flags.override,
@@ -48,6 +48,14 @@ export interface PanelRequest {
48
48
  id: string
49
49
  /** B站分P */
50
50
  page?: number
51
+ /**
52
+ * **用户消息里原样提取出来的那条链接**,只用在「复制后打开 XX 自动跳转」里。
53
+ *
54
+ * 之前跳转块用的是这边构造的链接(`www.douyin.com/video/<id>`、带一长串参数的分享链接…),
55
+ * 实测**太长 App 剪贴板不认**,而用户发的那条(通常是 App 分享出来的短口令链接)
56
+ * 反而是 App 自家生成的、一定能识别。没有时退回 `url`。
57
+ */
58
+ jumpUrl?: string
51
59
  }
52
60
 
53
61
  /** 一个可选画质 */
@@ -343,6 +351,160 @@ export function sourceLink (label: string, url: string): string {
343
351
  return '[' + label + '](' + safe + ')'
344
352
  }
345
353
 
354
+ /**
355
+ * 「复制后打开 XX 自动跳转」代码块:
356
+ *
357
+ * ```复制后打开b站自动跳转 →
358
+ * https://b23.tv/BV…
359
+ * ```
360
+ *
361
+ * 提示文案放在**围栏的信息位**(` ``` ` 后面那段,也就是 markdown 写语言的地方),
362
+ * 不占正文一行 —— 代码块里只有那条链接,全选复制时不会带上多余文字,
363
+ * 渲染出来提示仍是显示在代码块顶上的。
364
+ *
365
+ * 不用 `[打开原站](url)` 是因为那种链接能不能点取决于 adapter —— 官方 QQ 接口实测会拒收
366
+ * (见 `sourceLink` 的说明)。而各家 App 都认「剪贴板里有一条自家链接」:
367
+ * 复制整段文本后打开 App 会弹出打开询问,比点链接稳。
368
+ * @param appName App 名字,写进提示文案里(b站 / 抖音)
369
+ * @param url 目标地址(放进代码块的是纯文本,不做 markdown 编码)
370
+ * @returns 代码块的行数组;没地址时是空数组,调用方直接展开即可
371
+ */
372
+ /**
373
+ * 抖音 App 的唤起口令。
374
+ *
375
+ * 抖音认的是「剪贴板里有一段带口令的文本」:光有一条链接,App 不一定弹跳转询问,
376
+ * 链接后接着这段口令才能保证复制后打开抖音直达作品。
377
+ * 这是抖音 App 生成的固定串(每条作品都一样,接口里没有对应字段),别当成模板去替换。
378
+ */
379
+ const DOUYIN_JUMP_TOKEN = ':1pm Mai:/ 2237886846@qq.com'
380
+
381
+ /**
382
+ * @param appName App 名字,写进提示文案里(b站 / 抖音)
383
+ * @param url 目标地址(放进代码块的是纯文本,不做 markdown 编码)
384
+ * @param tail 追加在链接后面的文字(抖音的唤起口令走这里)
385
+ */
386
+ export function copyJumpBlock (appName: string, url: string, tail = ''): string[] {
387
+ if (!url) return []
388
+ // 链接和口令之间留两个空格:抖音的口令是独立一段,贴着链接会被当成 URL 的一部分
389
+ // 提示末尾的箭头指向代码块右上角的「复制」按钮 —— 复制的是整段(这里只有链接),App 才会跳转
390
+ return ['```' + '复制后打开' + appName + '自动跳转 →', String(url) + (tail ? ' ' + tail : ''), '```']
391
+ }
392
+
393
+ /**
394
+ * 抖音作品的分享链接(**给卡片右下角二维码用的**)。
395
+ *
396
+ * 优先取接口返回的 `share_url`:那是抖音 App 生成的分享链接,后面带一串参数,
397
+ * 扫码场景没问题(二维码不吃 URL 长度),比自己拼的裸链接更接近官方形态;
398
+ * 拿不到(接口改版 / 风控)才退回标准链接。
399
+ *
400
+ * 注意它**不适合作为「复制跳转」的链接** —— 参数太长,App 的剪贴板识别不了,
401
+ * 那种场景要用用户原样发的那条(见 `sendParsePanel` 里 `request.jumpUrl`)。
402
+ * @param awemeId 作品 id
403
+ * @param detailShareUrl 作品详情里的 `share_url`
404
+ */
405
+ export function douyinShareUrl (awemeId: string, detailShareUrl?: string): string {
406
+ return String(detailShareUrl ?? '') || ('https://www.douyin.com/video/' + String(awemeId))
407
+ }
408
+
409
+ /**
410
+ * B站作品的跳转链接。
411
+ *
412
+ * 用 **b23.tv 短链**(`https://b23.tv/<bvid>`):卡片右下角二维码走的就是这条,
413
+ * 扫二维码和复制跳转是同一件事,两处链接必须一致。
414
+ */
415
+ export function bilibiliShareUrl (bvid: string): string {
416
+ return 'https://b23.tv/' + String(bvid)
417
+ }
418
+
419
+ /** 快手作品页链接(App 认的是自家域名下的链接,短链最终也是跳到这里) */
420
+ export function kuaishouShareUrl (photoId: string): string {
421
+ return 'https://www.kuaishou.com/short-video/' + String(photoId)
422
+ }
423
+
424
+ /**
425
+ * 小红书笔记页链接。
426
+ *
427
+ * 和详情卡片的二维码**逐字符对齐**(含 `xsec_token` 那串参数)——
428
+ * 卡片那儿一直是这么拼的,跳转块换用同一个函数即可,别再单独拼一遍。
429
+ * @param noteId 笔记 id
430
+ * @param xsecToken 访问令牌;没有就退回到不带参数的短形式
431
+ */
432
+ export function xiaohongshuShareUrl (noteId: string, xsecToken = ''): string {
433
+ const base = 'https://www.xiaohongshu.com/discovery/item/' + String(noteId)
434
+ return xsecToken
435
+ ? base + '?source=webshare&xhsshare=pc_web&xsec_token=' + String(xsecToken) + '&xsec_source=pc_share'
436
+ : base
437
+ }
438
+
439
+ /**
440
+ * **能真正渲染 markdown 的平台** —— 只有官方 QQ 和 QQ 频道,别的一律发纯文本。
441
+ *
442
+ * 白名单而不是黑名单:markdown 是腾讯那两个 adapter 的私货,
443
+ * OneBot(NapCat / Lagrange / go-cqhttp / Chronocat)、Discord、Telegram、KOOK…
444
+ * 都不认这东西。代码块的 ` ``` ` 围栏在它们那里会原样露出来,
445
+ * 用户看到的是一串反引号而不是代码块。
446
+ *
447
+ * 这些平台发**去围栏的纯文本**:提示一行 + 链接一行,照样能整段复制,App 一样认。
448
+ * @param platform `platformOf(e)` 拿到的适配器名
449
+ */
450
+ export const supportsMarkdown = (platform: string): boolean => {
451
+ const name = String(platform ?? '').toLowerCase()
452
+ return name === 'qq' || name === 'qqguild'
453
+ }
454
+
455
+ /**
456
+ * **没有清晰度面板的平台**:单独发一条「复制后打开 XX 自动跳转」。
457
+ *
458
+ * B站 / 抖音那条 repeat 代码块挂在清晰度表格下面(`sendParsePanel`),
459
+ * 而快手、小红书压根没有可选画质的面板——本来也不发任何原站链接。
460
+ * 这里给它们补一条同格式的独立消息,行为和开关都跟面板里的那行一致。
461
+ *
462
+ * 发送失败**不影响解析**:跳转是锦上添花,主流程(卡片 / 图片 / 视频)都已经发出去了。
463
+ * @param appName App 名字(快手 / 小红书)
464
+ * @param url 目标地址;空则直接跳过
465
+ * @param tail 追加在链接后面的文字(抖音走 QqPanel 内部的口令,其它平台不用)
466
+ * @returns 是否发出去了
467
+ */
468
+ export async function sendCopyJumpMessage (
469
+ e: Message,
470
+ appName: string,
471
+ url: string,
472
+ tail = ''
473
+ ): Promise<boolean> {
474
+ if (!url) return false
475
+ if (tryGetRuntime()?.config.qqPanelSourceLink === false) return false
476
+
477
+ const lines = copyJumpBlock(appName, url, tail)
478
+ /**
479
+ * 去掉围栏就是同内容的纯文本 —— 给不渲染 markdown 的适配器兜底。
480
+ * 提示行现在是 ``` 开头的(信息位写法),去掉 ``` 正好还原成一句提示。
481
+ */
482
+ const plain = lines
483
+ .map((line) => (line.startsWith('```') ? line.slice(3) : line))
484
+ .filter(Boolean)
485
+ .join(String.fromCharCode(10))
486
+ const content = supportsMarkdown(platformOf(e))
487
+ ? segment.markdown(lines.join(String.fromCharCode(10)))
488
+ : plain
489
+ try {
490
+ await e.reply(content)
491
+ return true
492
+ } catch (error: any) {
493
+ /**
494
+ * 官方 QQ 有时因为消息里的链接形式拒收整条 markdown(见 `sourceLink` 的说明),
495
+ * 这里退回纯文本再试一次;连纯文本都发不出去就跳过,不影响主流程。
496
+ */
497
+ logger.debug('[原站跳转] ' + appName + ' markdown 发送失败,退回纯文本: ' + String(error?.message ?? error).slice(0, 120))
498
+ try {
499
+ await e.reply(plain)
500
+ return true
501
+ } catch (retryError: any) {
502
+ logger.warn('[原站跳转] ' + appName + ' 发送失败,已跳过: ' + String(retryError?.message ?? retryError).slice(0, 120))
503
+ return false
504
+ }
505
+ }
506
+ }
507
+
346
508
  export function cmdInput (command: string, show?: string): string {
347
509
  const text = encodeURIComponent(command).replace(/'/g, '%27')
348
510
  const label = encodeURIComponent(show ?? command).replace(/'/g, '%27')
@@ -465,6 +627,10 @@ async function showLoadingTip (e: Message): Promise<string | undefined> {
465
627
  *
466
628
  * 单独认出来是为了**发送失败时能摘掉它重发**:链接只是锦上添花,
467
629
  * 不能因为它(不同 adapter / 版本的链接限制不一样)把整条面板甚至整个解析搞挂。
630
+ *
631
+ * 「复制跳转」代码块(`copyJumpBlock`)也是链接行,但它跨 4 行带 ``` 围栏,
632
+ * 按行内容匹配容易漏(漏掉围栏会把后面的文字全包进代码块),所以那几行由调用方
633
+ * 用**下标范围**传给 `sendPanelMarkdown`。
468
634
  */
469
635
  const isSourceLinkLine = (line: string): boolean => /^\[[^\]]+\]\([^)]+\)\s*$/.test(line.trim())
470
636
 
@@ -477,16 +643,21 @@ const isSourceLinkLine = (line: string): boolean => /^\[[^\]]+\]\([^)]+\)\s*$/.t
477
643
  * 先照常发,失败且内容里确实有链接行时,去掉链接行再发一次,成功就当没事发生。
478
644
  * @param lines 面板的 markdown 行
479
645
  * @param send 真正发送的函数(第一次/重发都走它)
646
+ * @param linkRange 「打开原站」那几行在 lines 里的下标范围 [起, 止);
647
+ * 不传时退回到按内容匹配 `[label](url)` 那一种写法
480
648
  * @returns 最后一次发送的结果
481
649
  */
482
650
  async function sendPanelMarkdown (
483
651
  lines: string[],
484
- send: (content: any) => Promise<any>
652
+ send: (content: any) => Promise<any>,
653
+ linkRange?: [number, number]
485
654
  ): Promise<{ sent: any, droppedLink: boolean }> {
486
655
  try {
487
656
  return { sent: await send(segment.markdown(lines.join(String.fromCharCode(10)))), droppedLink: false }
488
657
  } catch (error: any) {
489
- const withoutLink = lines.filter((line) => !isSourceLinkLine(line))
658
+ const withoutLink = linkRange
659
+ ? [...lines.slice(0, linkRange[0]), ...lines.slice(linkRange[1])]
660
+ : lines.filter((line) => !isSourceLinkLine(line))
490
661
  if (withoutLink.length === lines.length) throw error
491
662
  logger.warn('[QQ面板] 带链接的面板发送失败(' + String(error?.message ?? error).slice(0, 120) + '),去掉链接行重发一次')
492
663
  return { sent: await send(segment.markdown(withoutLink.join(String.fromCharCode(10)))), droppedLink: true }
@@ -573,7 +744,8 @@ async function uploadPanelCard (
573
744
  e,
574
745
  Detail_Data: detail,
575
746
  create_time: Number(detail?.create_time) || Math.floor(Date.now() / 1000),
576
- shareLink: 'https://www.douyin.com/video/' + String(detail?.aweme_id ?? request.id),
747
+ // 和右下角二维码同一个链接:抖音那条带一串分享参数,App 才认
748
+ shareLink: douyinShareUrl(String(detail?.aweme_id ?? request.id), detail?.share_url),
577
749
  // 还没选档,所以不显示分辨率块(和分享信息图一致)
578
750
  videoSource: undefined
579
751
  } as any)
@@ -602,7 +774,7 @@ async function uploadPanelCard (
602
774
  const route = 'bilibili/videoInfo'
603
775
  const cardData: any = request.platform === 'bilibili'
604
776
  ? {
605
- share_url: 'https://b23.tv/' + (detail.bvid ?? request.id),
777
+ share_url: bilibiliShareUrl(String(detail.bvid ?? request.id)),
606
778
  title: detail.title,
607
779
  desc: '',
608
780
  stat: detail.stat,
@@ -1009,14 +1181,35 @@ export async function sendQqParsePanel (e: Message, request: PanelRequest): Prom
1009
1181
  * 「打开原站」:放在表格下方(用户要求的位置)——用户看完卡片和画质后,
1010
1182
  * 想直接去平台看原作品时不用再翻聊天记录找链接。
1011
1183
  * 开关在 WebUI 的 QQ 适配器分组(qqPanelSourceLink,默认开)。
1184
+ *
1185
+ * B站 / 抖音给的是「复制后打开 XX 自动跳转」代码块:各家 App 都认剪贴板里的自家链接,
1186
+ * 不依赖 QQ 会不会把 markdown 链接渲染成可点的东西。快手之类仍给普通链接。
1187
+ *
1188
+ * 链接**用用户原样发的那条**(`request.jumpUrl`),拼不出来才退回 `request.url`。
1189
+ * 之前按「和卡片二维码一致」去用 `bilibiliShareUrl` / `douyinShareUrl`,实测行不通:
1190
+ * 那种链接带一串 `xsec_token` / `u_code` 之类的参数,长到 App 的剪贴板识别压根不认。
1191
+ * 反过来用户发出来的 `b23.tv/xxx`、`v.douyin.com/xxx` 本来就是 App 自己生成的分享形态,
1192
+ * 最短也最容易被识别。二维码那边不受影响(扫码不吃 URL 长度),保持原来的链接不动。
1012
1193
  */
1013
- if (runtime.config.qqPanelSourceLink !== false && request.url) lines.push(sourceLink('打开原站', request.url))
1194
+ const linkStart = lines.length
1195
+ if (runtime.config.qqPanelSourceLink !== false && request.url) {
1196
+ // 优先用户原样发的那条:我们拼的链接偏长,App 剪贴板识别不了;App 自家分享的短链才稳
1197
+ const jumpUrl = request.jumpUrl || request.url
1198
+ const block = request.platform === 'bilibili'
1199
+ ? copyJumpBlock('b站', jumpUrl)
1200
+ : request.platform === 'douyin'
1201
+ ? copyJumpBlock('抖音', jumpUrl, DOUYIN_JUMP_TOKEN)
1202
+ : [sourceLink('打开原站', jumpUrl)]
1203
+ lines.push(...block)
1204
+ }
1014
1205
  // 带链接发不出去时自动去掉链接行重发(不然整条面板、整个解析都会被一个链接拖死)
1206
+ // 代码块连围栏共 3 行,所以按**下标范围**整块摘,不能只删中间那行
1207
+ const linkRange: [number, number] | undefined = lines.length > linkStart ? [linkStart, lines.length] : undefined
1015
1208
  const { sent } = await sendPanelMarkdown(lines, async (content) => {
1016
1209
  // 卡片已经撤过「加载中…」了;卡片没发出去(或渲染失败)时这里补撤一次
1017
1210
  if (!cardSent) await recallMessageById(e, loadingId)
1018
1211
  return await e.reply(content)
1019
- })
1212
+ }, linkRange)
1020
1213
  rememberPanelMessage(e, sent?.messageId)
1021
1214
  logger.debug('[QQ面板] 已发送解析面板: ' + request.platform + ' ' + request.id + '(' + shown.length + '/' + info.options.length + ' 档画质)')
1022
1215
  return true
@@ -6,6 +6,7 @@ import { ParseSteps, SendTasks } from '@/module/utils/ParseSteps'
6
6
  // sendParseTip 单独导入:它在一个无依赖的叶子模块里,避免和平台模块形成循环 import
7
7
  import { sendParseTip } from '@/module/utils/parseTip'
8
8
  import type { ParseWorkType } from '@/module/db'
9
+ import { kuaishouShareUrl, sendCopyJumpMessage } from '@/module/utils/QqPanel'
9
10
  import { Config } from '@/module/utils/Config'
10
11
  // 注意用相对写法:@/ 别名在仓库里指向 karin/,@/player 会被解析成不存在的 karin/player
11
12
  import { applyForceOnlinePlayer } from '../../../player'
@@ -39,10 +40,20 @@ export class Kuaishou extends Base {
39
40
  * 本插件只解析视频,图集/单图会在下面提前返回,那时保持 undefined。
40
41
  */
41
42
  workType?: ParseWorkType
42
- constructor(e: Message, iddata: ExtendedKuaishouOptionsType) {
43
+ /** ID 解析结果:里面的 photoId 用来兜底拼作品页链接 */
44
+ private readonly iddata: ExtendedKuaishouOptionsType
45
+ /**
46
+ * **用户消息里原样提取出来的链接**(`v.kuaishou.com/xxx` 这类 App 分享短链)。
47
+ * 「复制后打开快手自动跳转」优先用这条 —— 它是快手 App 自己生成的,识别率最高;
48
+ * 我们拼的长链接(哪怕只是 `short-video/<photoId>`)App 未必认。
49
+ */
50
+ private readonly originUrl: string
51
+ constructor(e: Message, iddata: ExtendedKuaishouOptionsType, originUrl = '') {
43
52
  super(e)
44
53
  this.e = e
45
54
  this.type = iddata?.type
55
+ this.iddata = iddata
56
+ this.originUrl = String(originUrl ?? '')
46
57
  }
47
58
 
48
59
  async KuaishouHandler(data: KuaishouDataResult) {
@@ -101,6 +112,20 @@ export class Kuaishou extends Base {
101
112
  return true
102
113
  }
103
114
  this.workType = 'video'
115
+ /**
116
+ * 卡片右下角二维码与「复制后打开快手自动跳转」共用**同一条作品页链接**。
117
+ *
118
+ * 原来这里给模板的是 `video_url`(CDN 视频直链),扫出来是一段播放地址、
119
+ * 不是作品页;快手 App 认的是自家域名下的链接,所以统一改成 `short-video/<photoId>`。
120
+ */
121
+ const photoId = String(work?.photo?.id ?? this.iddata?.photoId ?? '')
122
+ /**
123
+ * 取不到 photoId 时**不能给空串**:模板拿不到二维码内容会整张卡渲染不出来,
124
+ * 那种情况下退回 CDN 直链(原来的行为),至少卡片照常出、只是二维码还是播放地址。
125
+ * 「复制跳转」不受影响——它用自己的那份 shareUrl,取不到就不发。
126
+ */
127
+ const shareUrl = photoId ? kuaishouShareUrl(photoId) : ''
128
+ const cardShareUrl = shareUrl || video_url
104
129
  /** 本次解析的步骤容器:单步失败只跳过、不中断,最后统一渲染一张错误卡片(见 ParseSteps) */
105
130
  const steps = new ParseSteps()
106
131
  /** 发送任务组:评论区与视频各走一条线,谁先就绪谁先发 */
@@ -140,7 +165,7 @@ export class Kuaishou extends Base {
140
165
  viewCount: work.photo.viewCount,
141
166
  CommentsData,
142
167
  CommentLength: CommentsData?.length ?? 0,
143
- share_url: video_url,
168
+ share_url: cardShareUrl,
144
169
  VideoSize: fileSizeInMB,
145
170
  likeCount: work.photo.likeCount
146
171
  })
@@ -161,6 +186,12 @@ export class Kuaishou extends Base {
161
186
  })
162
187
 
163
188
  await sends.settle()
189
+ /**
190
+ * 「打开原站」**单独发一条**:快手没有清晰度面板(那套面板只有 B站 / 抖音有,
191
+ * 它们的跳转块挂在画质表格下面),所以这里在所有内容发完之后补一条同格式的消息。
192
+ * 发不出去也不影响解析(详情见 sendCopyJumpMessage)。
193
+ */
194
+ await sendCopyJumpMessage(this.e, '快手', this.originUrl || shareUrl)
164
195
  steps.throwIfFailed()
165
196
  return true
166
197
  }
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import { platformOf } from '@/module/utils/ImageSlice'
3
- import { buildMarkdownImageMessage } from '@/module/utils/QqPanel'
3
+ import { buildMarkdownImageMessage, sendCopyJumpMessage, xiaohongshuShareUrl } from '@/module/utils/QqPanel'
4
4
  import { sendParseTip } from '@/module/utils/parseTip'
5
5
 
6
6
  import type { NoteComments, XiaohongshuEmojiListResponse } from '@ikenxuan/amagi'
@@ -61,11 +61,19 @@ export class Xiaohongshu extends Base {
61
61
  * 笔记详情拿到之前无法判定,所以留到 `XiaohongshuHandler` 里赋值。
62
62
  */
63
63
  workType?: ParseWorkType
64
+ /**
65
+ * **用户消息里原样提取出来的链接**(`xhslink.com/o/xxx` 这类 App 分享短链)。
66
+ *
67
+ * 「复制后打开小红书自动跳转」优先用这条:卡片那条带 `xsec_token` 全参数的分享链接
68
+ * 实测**太长、App 剪贴板识别不了**,而用户发的是小红书 App 自己生成的,一定能识别。
69
+ */
70
+ private readonly originUrl: string
64
71
 
65
- constructor(e: Message, iddata: XiaohongshuIdData) {
72
+ constructor(e: Message, iddata: XiaohongshuIdData, originUrl = '') {
66
73
  super(e)
67
74
  this.e = e
68
75
  this.type = iddata?.type
76
+ this.originUrl = String(originUrl ?? '')
69
77
  }
70
78
 
71
79
  /**
@@ -124,6 +132,13 @@ export class Xiaohongshu extends Base {
124
132
  */
125
133
  const rawItem: any = Array.isArray(noteItems) ? noteItems[0] : undefined
126
134
  const noteCard: any = rawItem?.note_card ?? rawItem?.noteCard ?? rawItem
135
+ /**
136
+ * **卡片右下角的二维码与「复制跳转」必须是同一条链接**。
137
+ *
138
+ * 以前 noteInfo / comment 两个模板各拼一遍同样的字符串,跳转块再拼第三遍,
139
+ * 三处任何一处改动都会让它们指向不同地址。这里统一从这里取值。
140
+ */
141
+ const shareUrl = xiaohongshuShareUrl(String(data.note_id), String(data.xsec_token ?? ''))
127
142
  logger.mark('[小红书] 条目键名: ' + JSON.stringify(Object.keys(rawItem ?? {}).slice(0, 12)) +
128
143
  ' / note_card 键名: ' + JSON.stringify(Object.keys(rawItem?.note_card ?? {}).slice(0, 16)))
129
144
  if (!noteCard) {
@@ -245,7 +260,7 @@ export class Xiaohongshu extends Base {
245
260
  ?? '',
246
261
  time: noteCard.time,
247
262
  ip_location: noteCard.ip_location,
248
- share_url: `https://www.xiaohongshu.com/discovery/item/${data.note_id}?source=webshare&xhsshare=pc_web&xsec_token=${data.xsec_token}&xsec_source=pc_share`,
263
+ share_url: shareUrl,
249
264
  image_list: noteCard.image_list?.map((image) => image.url_default) ?? [],
250
265
  is_video: Boolean(noteCard.video)
251
266
  })
@@ -395,7 +410,7 @@ export class Xiaohongshu extends Base {
395
410
  ImageLength: noteCard.image_list?.length || 0,
396
411
  // 提示直接写进卡片(模板的 ErrorText),不再单独发文字、也不带 emoji
397
412
  ErrorText: '评论数据获取失败,稍后再试试',
398
- share_url: 'https://www.xiaohongshu.com/discovery/item/' + data.note_id
413
+ share_url: shareUrl
399
414
  })
400
415
  await this.e.reply(emptyCommentCard)
401
416
  logger.mark('[小红书] 已发出空的评论卡片(占位)')
@@ -415,7 +430,7 @@ export class Xiaohongshu extends Base {
415
430
  CommentsData: processedComments,
416
431
  CommentLength: processedComments.length,
417
432
  ImageLength: noteCard.image_list?.length || 0,
418
- share_url: `https://www.xiaohongshu.com/discovery/item/${data.note_id}?source=webshare&xhsshare=pc_web&xsec_token=${data.xsec_token}&xsec_source=pc_share`
433
+ share_url: shareUrl
419
434
  })
420
435
  this.e.reply(commentListImg)
421
436
  }
@@ -618,6 +633,12 @@ export class Xiaohongshu extends Base {
618
633
  * 由 ErrorHandler 渲染**一张**错误卡片(此时能发的卡片/图片/视频都已经发出去了)。
619
634
  */
620
635
  await sends.settle()
636
+ /**
637
+ * 「打开原站」**单独发一条**:小红书没有清晰度面板(那套面板只有 B站 / 抖音有,
638
+ * 它们的跳转块挂在画质表格下面),所以这里在所有内容发完之后补一条同格式的消息。
639
+ * 发不出去也不影响解析(详情见 sendCopyJumpMessage)。
640
+ */
641
+ await sendCopyJumpMessage(this.e, '小红书', this.originUrl || shareUrl)
621
642
  steps.throwIfFailed()
622
643
  return true
623
644
  }