koishi-plugin-kkk 3.4.2 → 3.6.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/assets/web/assets/index-BRROK0vh.js +1 -1
  3. package/config/default_config/app.yaml +4 -6
  4. package/config/default_config/bilibili.yaml +1 -1
  5. package/config/default_config/config.json +2 -2
  6. package/lib/build-metadata.json +10 -0
  7. package/lib/compat/adapter-info.js +333 -0
  8. package/lib/compat/forward-collect.js +57 -4
  9. package/lib/compat/node-karin.js +25 -56
  10. package/lib/compat/sendError.js +27 -0
  11. package/lib/index.js +15 -8
  12. package/lib/karin/apps/help.js +23 -2
  13. package/lib/karin/module/utils/Base.js +30 -8
  14. package/lib/karin/module/utils/ErrorHandler/render.js +8 -2
  15. package/lib/karin/module/utils/InteractiveGraph.js +164 -0
  16. package/lib/karin/module/utils/InteractiveVideo.js +316 -0
  17. package/lib/karin/module/utils/Network/Downloader.js +159 -13
  18. package/lib/karin/module/utils/Network/Network.js +5 -1
  19. package/lib/karin/module/utils/ParseForward.js +12 -2
  20. package/lib/karin/module/utils/ParseSteps.js +1 -1
  21. package/lib/karin/module/utils/Render/index.js +10 -110
  22. package/lib/karin/module/utils/build-metadata.js +64 -19
  23. package/lib/karin/module/utils/runtime-report.js +16 -4
  24. package/lib/karin/platform/bilibili/bilibili.js +137 -11
  25. package/lib/karin/platform/bilibili/interactive-story.js +463 -0
  26. package/lib/karin/platform/bilibili/push.js +12 -5
  27. package/lib/ktr/registry.js +1 -0
  28. package/lib/ktr/template/bilibili/interactive/components/interactive.js +137 -0
  29. package/lib/ktr/template/bilibili/interactive/components/types.js +3 -0
  30. package/lib/ktr/template/bilibili/interactive/index.js +13 -0
  31. package/lib/ktr/template/other/handlerError/components/handlerError.js +1 -1
  32. package/lib/webui.js +40 -18
  33. package/package.json +1 -1
  34. package/scripts/build.mjs +36 -0
  35. package/scripts/patch-webui.mjs +126 -40
  36. package/scripts/probe-edge-cookie.cjs +50 -0
  37. package/scripts/probe-panel-options.cjs +15 -0
  38. package/scripts/probe-schema-options.cjs +12 -0
  39. package/scripts/smoke-adapter-info.cjs +149 -0
  40. package/scripts/smoke-config-schema.cjs +62 -0
  41. package/scripts/smoke-download-source-fallback.cjs +166 -0
  42. package/scripts/smoke-forward-chart.cjs +139 -0
  43. package/scripts/smoke-interactive-card.cjs +87 -0
  44. package/scripts/smoke-interactive-story.cjs +285 -0
  45. package/scripts/smoke-interactive.cjs +298 -0
  46. package/scripts/smoke-onebot-forward.cjs +10 -5
  47. package/scripts/smoke-panel-chart.cjs +92 -0
  48. package/scripts/smoke-parse-forward.cjs +1 -1
  49. package/scripts/smoke-render-path.cjs +44 -55
  50. package/src/compat/adapter-info.ts +346 -0
  51. package/src/compat/forward-collect.ts +69 -4
  52. package/src/compat/node-karin.ts +24 -54
  53. package/src/compat/sendError.ts +26 -0
  54. package/src/index.ts +15 -8
  55. package/src/karin/apps/help.ts +26 -3
  56. package/src/karin/module/utils/Base.ts +39 -9
  57. package/src/karin/module/utils/ErrorHandler/render.ts +8 -2
  58. package/src/karin/module/utils/InteractiveGraph.ts +199 -0
  59. package/src/karin/module/utils/InteractiveVideo.ts +400 -0
  60. package/src/karin/module/utils/Network/Downloader.ts +164 -14
  61. package/src/karin/module/utils/Network/Network.ts +6 -2
  62. package/src/karin/module/utils/ParseForward.ts +12 -3
  63. package/src/karin/module/utils/ParseSteps.ts +1 -1
  64. package/src/karin/module/utils/Render/index.ts +10 -113
  65. package/src/karin/module/utils/build-metadata.ts +61 -19
  66. package/src/karin/module/utils/runtime-report.ts +16 -4
  67. package/src/karin/platform/bilibili/bilibili.ts +149 -20
  68. package/src/karin/platform/bilibili/interactive-story.ts +519 -0
  69. package/src/karin/platform/bilibili/push.ts +12 -6
  70. package/src/karin/types/config/app.ts +7 -15
  71. package/src/karin/types/config/bilibili.ts +1 -1
  72. package/src/karin/types/netrorks/NetworksConfigType.ts +5 -0
  73. package/src/ktr/registry.ts +1 -0
  74. package/src/ktr/template/bilibili/interactive/components/interactive.tsx +202 -0
  75. package/src/ktr/template/bilibili/interactive/components/types.ts +37 -0
  76. package/src/ktr/template/bilibili/interactive/index.tsx +15 -0
  77. package/src/ktr/template/other/handlerError/components/handlerError.tsx +4 -1
  78. package/src/webui.ts +38 -16
  79. package/scripts/smoke-shotkit-compat.cjs +0 -102
package/lib/index.js CHANGED
@@ -123,8 +123,9 @@ exports.inject = {
123
123
  // ffmpeg:由 koishi-plugin-ffmpeg-path 提供,兼容层的 ffmpeg()/ffprobe() 会优先用它
124
124
  // assets:koishi-plugin-assets-qqbot-part-file 之类提供的图床服务,
125
125
  // 面板卡片要上传成 https 地址才能放进 QQ 的 markdown 图片里
126
- // shotkit:koishi-plugin-shotkit 提供的内核截图服务;装了就用它渲染卡片(没装退回 puppeteer)
127
- optional: ['shotkit', 'puppeteer', 'database', 'http', 'ffmpeg', 'assets', 'server', 'console']
126
+ // 渲染:只用浏览器渲染服务(koishi-plugin-puppeteer 或同类插件)
127
+ // 3.5.0 起不再支持 shotkit 内核(它在 Windows 上加载不了 https 资源,卡片会缺图)
128
+ optional: ['puppeteer', 'database', 'http', 'ffmpeg', 'assets', 'server', 'console']
128
129
  };
129
130
  /**
130
131
  * 配置表单 = 「Koishi 集成选项」 + 「上游插件配置」。
@@ -189,14 +190,14 @@ exports.Config = koishi_1.Schema.intersect([
189
190
  forward: koishi_1.Schema.object({
190
191
  global: koishi_1.Schema.boolean().default(false)
191
192
  .description('全局合并转发。打开后所有平台都把一次解析的内容合成一条聊天记录发出;关着时下面各平台的开关才起作用。默认关闭'),
192
- globalContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
193
- .description('全局合并转发里放哪些内容:text 文字 / image 图片 / video 视频 / file 文件。没勾的单独直发,留空等于只放文字和图片。视频体积大时有些适配器(比如 NapCat)会拒绝整个聊天记录,这时会自动改成单独发送,不会丢内容'),
193
+ globalContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file', 'chart'])).default([])
194
+ .description('全局合并转发里放哪些内容:text 文字 / image 图片 / video 视频 / file 文件 / chart 流程图(B站互动视频的剧情图)。没勾的单独直发,留空等于只放文字和图片。视频体积大时有些适配器(比如 NapCat)会拒绝整个聊天记录,这时会自动改成单独发送,不会丢内容'),
194
195
  douyin: koishi_1.Schema.boolean().default(false).description('抖音:单独打开合并转发(全局关着时才起作用)'),
195
196
  douyinContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
196
197
  .description('抖音合并转发里放哪些内容,留空表示用全局那一份'),
197
198
  bilibili: koishi_1.Schema.boolean().default(false).description('B站:单独打开合并转发(全局关着时才起作用)'),
198
- bilibiliContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
199
- .description('B站合并转发里放哪些内容,留空表示用全局那一份'),
199
+ bilibiliContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file', 'chart'])).default([])
200
+ .description('B站合并转发里放哪些内容,chart 是互动视频的剧情流程图(只有 B站有)。留空表示用全局那一份'),
200
201
  kuaishou: koishi_1.Schema.boolean().default(false).description('快手:单独打开合并转发(全局关着时才起作用)'),
201
202
  kuaishouContent: koishi_1.Schema.array(koishi_1.Schema.union(['text', 'image', 'video', 'file'])).default([])
202
203
  .description('快手合并转发里放哪些内容,留空表示用全局那一份'),
@@ -850,8 +851,14 @@ function registerCommands(ctx, logger, autoParse) {
850
851
  return next();
851
852
  });
852
853
  }
853
- /** 合并转发内容可选项(与 ParseForward 里的 FORWARD_KINDS 保持一致) */
854
- const FORWARD_KIND_VALUES = ['text', 'image', 'video', 'audio', 'file', 'markdown'];
854
+ /**
855
+ * 合并转发内容可选项(与 ParseForward 里的 FORWARD_KINDS 保持一致)。
856
+ *
857
+ * 注意这里**不再有** audio / markdown:QQ 的聊天记录不支持语音气泡,markdown 只有官方 bot 认、
858
+ * 而官方适配器没有合并转发能力 —— 留着它们只会让人配了不生效(见 ParseForward 的说明)。
859
+ * chart 是 B站互动视频的剧情流程图。
860
+ */
861
+ const FORWARD_KIND_VALUES = ['text', 'image', 'video', 'file', 'chart'];
855
862
  /** 平台名 → 上游配置段名 */
856
863
  const FORWARD_PLATFORMS = ['douyin', 'bilibili', 'kuaishou', 'xiaohongshu'];
857
864
  /**
@@ -39,6 +39,7 @@ const module_1 = require("../module");
39
39
  const Config_1 = require("../module/utils/Config");
40
40
  const ErrorHandler_1 = require("../module/utils/ErrorHandler");
41
41
  const runtime_report_1 = require("../module/utils/runtime-report");
42
+ const sendError_1 = require("../../compat/sendError");
42
43
  const HELP_MENU_CONFIG = [
43
44
  {
44
45
  title: '常用功能',
@@ -205,12 +206,22 @@ const handleHelp = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e) => {
205
206
  });
206
207
  // 包装版本命令
207
208
  const handleVersion = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e) => {
208
- const img = await (0, module_1.Render)(e, 'other/runtime', (0, runtime_report_1.collectRuntimeReport)(e));
209
+ const img = await (0, module_1.Render)(e, 'other/runtime', await (0, runtime_report_1.collectRuntimeReport)(e));
209
210
  await e.reply(img);
210
211
  return true;
211
212
  }, {
212
213
  businessName: 'KKK版本'
213
214
  });
215
+ /**
216
+ * 把 Markdown 更新日志压成纯文字。
217
+ *
218
+ * 图片发不出去时的降级用:去掉标题号与加粗符号就够了,内容本身不改写。
219
+ */
220
+ const toPlainChangelog = (markdown) => markdown
221
+ .replace(/^#{1,6}\s*/gm, '')
222
+ .replace(/\*\*/g, '')
223
+ .slice(0, 1800)
224
+ .trim();
214
225
  // 包装更新日志命令
215
226
  const handleChangelog = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e) => {
216
227
  const forwardLogs = (0, runtime_report_1.getLocalChangelog)(10);
@@ -223,7 +234,17 @@ const handleChangelog = (0, ErrorHandler_1.wrapWithErrorHandler)(async (e) => {
223
234
  localVersion: '',
224
235
  remoteVersion: ''
225
236
  });
226
- await e.reply(img);
237
+ try {
238
+ await e.reply(img);
239
+ }
240
+ catch (error) {
241
+ /**
242
+ * 图片发不出去(体积超限、主动消息被拒、适配器抽风…)时退回纯文字。
243
+ * 更新日志本身是文字,降级之后用户照样看得到更新了什么,比只弹一张错误卡片好。
244
+ */
245
+ node_karin_1.logger.warn('[kkk] 更新日志图片发送失败,改用文字发送:' + (0, sendError_1.describeSendFailure)((0, sendError_1.classifySendFailure)(error)));
246
+ await e.reply(toPlainChangelog(forwardLogs));
247
+ }
227
248
  return true;
228
249
  }, {
229
250
  businessName: 'KKK更新日志'
@@ -444,15 +444,25 @@ const downloadFile = async (videoUrl, opt) => {
444
444
  autoReduceRatio: uploadConfig.downloadAutoReduce ? 0.6 : 1, // 降速比例
445
445
  minSpeed: (uploadConfig.downloadMinSpeed ?? 1) * 1024 * 1024 // MB/s -> bytes/s
446
446
  };
447
+ /**
448
+ * 速度采样点。速度必须按「两次回调之间的增量」算,不能按「累计字节 ÷ 总耗时」:
449
+ * 后者把重试的退避等待也算进分母,断点续传时分子还会被重置回续传点,
450
+ * 于是明明在正常下载也会显示成 `0.0 MB/s 剩余: 661min`(用户实测反馈)。
451
+ */
452
+ let lastSampleBytes = 0;
453
+ let lastSampleAt = startTime;
454
+ /** 平滑后的速度(byte/s) */
455
+ let smoothSpeed = 0;
447
456
  try {
448
457
  // 使用 networks 类进行文件下载,并通过回调函数实时更新下载进度
449
458
  const { filepath, totalBytes } = await new utils_1.Networks({
450
459
  url: videoUrl,
451
460
  headers: opt.headers ?? utils_1.baseHeaders,
452
461
  filepath: opt.filepath ?? utils_1.Common.tempDri.video + opt.title,
453
- timeout: 60000, // 增加超时时间
462
+ timeout: 60000, // 空闲超时(连续 60s 没有新数据才算超时),传输途中会不断续期
454
463
  maxRetries: 3, // 增加重试次数
455
- throttle: throttleConfig
464
+ throttle: throttleConfig,
465
+ backupUrls: opt.backupUrls
456
466
  }).downloadStream((downloadedBytes, totalBytes) => {
457
467
  // 定义进度条长度及生成进度条字符串的函数
458
468
  const barLength = 45;
@@ -467,14 +477,26 @@ const downloadFile = async (videoUrl, opt) => {
467
477
  // 计算动态 RGB 颜色
468
478
  const red = Math.floor(255 - (255 * progressPercentage) / 100); // 红色分量随进度减少
469
479
  const coloredPercentage = node_karin_1.logger.chalk.rgb(red, 255, 0)(`${progressPercentage.toFixed(1)}%`);
470
- // 计算下载速度(MB/s)
471
- const elapsedTime = (Date.now() - startTime) / 1000;
472
- const speed = downloadedBytes / elapsedTime;
480
+ // 计算下载速度(MB/s):增量法 + 一点平滑,避免数字乱跳
481
+ const sampleAt = Date.now();
482
+ const elapsedSinceSample = (sampleAt - lastSampleAt) / 1000;
483
+ if (elapsedSinceSample >= 0.2) {
484
+ // 断点续传时 downloadedBytes 会往回退,增量按 0 处理
485
+ const instant = Math.max(0, downloadedBytes - lastSampleBytes) / elapsedSinceSample;
486
+ smoothSpeed = smoothSpeed > 0 ? smoothSpeed * 0.6 + instant * 0.4 : instant;
487
+ lastSampleBytes = downloadedBytes;
488
+ lastSampleAt = sampleAt;
489
+ }
490
+ const speed = smoothSpeed;
473
491
  const formattedSpeed = (speed / 1048576).toFixed(1) + ' MB/s';
474
- // 计算剩余时间
492
+ // 计算剩余时间(速度还没出来时不瞎猜)
475
493
  const remainingBytes = totalBytes - downloadedBytes; // 剩余字节数
476
- const remainingTime = remainingBytes / speed; // 剩余时间(秒)
477
- const formattedRemainingTime = remainingTime > 60 ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s` : `${remainingTime.toFixed(0)}s`;
494
+ const remainingTime = speed > 1024 ? remainingBytes / speed : Number.POSITIVE_INFINITY;
495
+ const formattedRemainingTime = !Number.isFinite(remainingTime)
496
+ ? '--'
497
+ : remainingTime > 60
498
+ ? `${Math.floor(remainingTime / 60)}min ${Math.floor(remainingTime % 60)}s`
499
+ : `${remainingTime.toFixed(0)}s`;
478
500
  // 计算已下载和总下载的文件大小(MB)
479
501
  const downloadedSizeMB = (downloadedBytes / 1048576).toFixed(1);
480
502
  const totalSizeMB = (totalBytes / 1048576).toFixed(1);
@@ -202,8 +202,14 @@ const renderErrorImage = async (ctx, opts = {}) => {
202
202
  pluginVersion: module_1.Root.pluginVersion,
203
203
  buildTime: buildMetadata?.buildTime ? (0, module_1.formatBuildTime)(buildMetadata.buildTime) : undefined,
204
204
  commitHash: buildMetadata?.commitHash,
205
- // 之前这里可能传 undefined,模板读 adapterInfo.version.startsWith 直接 SSR 崩掉
206
- adapterInfo: adapterInfo ?? { name: '未知适配器', version: '' },
205
+ /**
206
+ * 之前这里可能传 undefined,模板读 adapterInfo.version.startsWith 直接 SSR 崩掉。
207
+ * name 换成友好名(NapCat / OneBot)只是给卡片看:兼容层里 adapter.name 是**平台标识**,
208
+ * 别动原对象,免得面板那条链路拿到被改过的平台名。
209
+ */
210
+ adapterInfo: adapterInfo
211
+ ? { ...adapterInfo, name: String(adapterInfo.displayName || adapterInfo.name || '未知适配器') }
212
+ : { name: '未知适配器', version: '' },
207
213
  // 上报成功的编号与反馈群:印在卡片上,用户照着进群提问
208
214
  report: ctx.report
209
215
  ? { id: ctx.report.id, url: ctx.report.url, group: (0, ErrorReport_1.reportConfig)().group, groupUrl: (0, ErrorReport_1.groupLinkOf)((0, ErrorReport_1.reportConfig)().group) }
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clearInteractiveGraphCache = exports.peekCachedGraph = exports.crawlInteractiveGraph = void 0;
4
+ /**
5
+ * 把整张剧情图爬下来(给「剧情图」卡片画思维导图用)。
6
+ *
7
+ * 接口只有「按节点问」这一种(x/stein/edgeinfo_v2),所以要自己从根节点宽度优先展开:
8
+ * 每个节点问一次,拿到它的选项;每个选项的 cid 就是落点,再问落点,直到没有分支。
9
+ * 一张图动辄几十个节点,所以带**节点数 / 时间预算**:超了就停下并在卡片上标注「还有分支未展开」,
10
+ * 绝不让用户等着一张图渲不出来。
11
+ *
12
+ * 结果按 bvid + 剧情图版本号缓存 30 分钟(GRAPH_TTL):剧情图是静态的,同一个人反复玩不用重复爬。
13
+ */
14
+ const node_karin_1 = require("../../../compat/node-karin.js");
15
+ const InteractiveVideo_1 = require("../../module/utils/InteractiveVideo");
16
+ const GRAPH_CACHE = new Map();
17
+ const GRAPH_TTL = 30 * 60 * 1000;
18
+ const EDGE_INFO_URL = 'https://api.bilibili.com/x/stein/edgeinfo_v2';
19
+ /** 解析一个节点(edgeinfo_v2 的一份响应) */
20
+ const parseNode = (payload, cid) => {
21
+ const data = payload?.data;
22
+ if (!data || typeof data !== 'object')
23
+ return null;
24
+ const question = (data.edges?.questions ?? [])[0] ?? {};
25
+ const raw = Array.isArray(question.choices) ? question.choices : [];
26
+ const usable = raw
27
+ .map((choice) => ({
28
+ edgeId: Number(choice?.id),
29
+ cid: Number(choice?.cid),
30
+ rawText: String(choice?.option ?? ''),
31
+ isDefault: Boolean(choice?.is_default)
32
+ }))
33
+ .filter((item) => Number.isFinite(item.edgeId) && item.edgeId > 0 && Number.isFinite(item.cid) && item.cid > 0);
34
+ const choices = usable.map((item, index) => ({
35
+ label: (0, InteractiveVideo_1.choiceLabel)(index),
36
+ text: (0, InteractiveVideo_1.cleanChoiceText)(item.rawText) || item.rawText.trim() || (0, InteractiveVideo_1.choiceLabel)(index),
37
+ isDefault: item.isDefault,
38
+ edgeId: item.edgeId,
39
+ cid: item.cid
40
+ }));
41
+ return {
42
+ cid,
43
+ question: String(question.title ?? '').trim(),
44
+ isLeaf: Number(data.is_leaf) === 1 || choices.length === 0,
45
+ choices
46
+ };
47
+ };
48
+ /**
49
+ * 爬整张剧情图。
50
+ *
51
+ * @param params 基本信息 + 请求实现 + 预算
52
+ * @returns 爬到的图;连根节点都取不到时返回 null
53
+ */
54
+ const crawlInteractiveGraph = async (params) => {
55
+ const { bvid, graphVersion, rootCid, headers, request } = params;
56
+ const maxNodes = params.maxNodes ?? 400;
57
+ const deadline = Date.now() + (params.timeoutMs ?? 30000);
58
+ /** 缓存的是**整张图**(和从哪一段起画无关),所以 key 里不带 rootCid */
59
+ const cacheKey = bvid + ':' + graphVersion;
60
+ const cached = GRAPH_CACHE.get(cacheKey);
61
+ if (cached && Date.now() - cached.at < GRAPH_TTL)
62
+ return cached.graph;
63
+ const byCid = new Map();
64
+ let truncated = false;
65
+ /** 抓失败的节点数:有失败就不写缓存,下次重新抓,不留断头的图 */
66
+ let failed = 0;
67
+ /** 单个节点最多问两次:接口抖动一次不该在图里留一个断头分支 */
68
+ const fetchNode = async (cid, edgeId) => {
69
+ let url = EDGE_INFO_URL + '?bvid=' + bvid + '&graph_version=' + graphVersion + '&cid=' + cid;
70
+ if (edgeId)
71
+ url += '&edge_id=' + edgeId;
72
+ for (let attempt = 1; attempt <= 2; attempt++) {
73
+ const payload = await request(url, headers);
74
+ if (payload && Number(payload.code) === 0) {
75
+ const node = parseNode(payload, cid);
76
+ if (node)
77
+ return node;
78
+ }
79
+ else if (attempt === 1) {
80
+ node_karin_1.logger.debug('[互动视频] 剧情图节点取失败(cid=' + cid + ' edge=' + String(edgeId ?? '-') + ' code=' + String(payload?.code ?? '无响应') + '),重试一次');
81
+ }
82
+ if (attempt === 1)
83
+ await new Promise((resolve) => setTimeout(resolve, 300));
84
+ }
85
+ return null;
86
+ };
87
+ /** 一次进队列的「待展开」任务:从 fromCid 出发走这条选项,落点就是这条选项的 cid */
88
+ const queue = [];
89
+ /**
90
+ * 根节点。
91
+ *
92
+ * **不带 edge_id 地问接口会拿到整张图的根**,所以「从当前位置起画」必须带上进入那条边:
93
+ * 用户走到第 5 段时,我们要的是第 5 段那一段,而不是回到开场。
94
+ */
95
+ const rootEdge = params.rootEdge;
96
+ const rootUrl = EDGE_INFO_URL + '?bvid=' + bvid + '&graph_version=' + graphVersion
97
+ + '&cid=' + (rootEdge ? rootEdge.fromCid : rootCid)
98
+ + (rootEdge ? '&edge_id=' + rootEdge.edgeId : '');
99
+ const rootPayload = await request(rootUrl, headers);
100
+ if (!rootPayload || Number(rootPayload.code) !== 0)
101
+ return null;
102
+ const root = parseNode(rootPayload, rootCid);
103
+ if (!root)
104
+ return null;
105
+ byCid.set(rootCid, root);
106
+ for (const choice of root.choices)
107
+ queue.push({ fromCid: rootCid, choice });
108
+ /** 宽度优先:三个一波并发,节点数或时间到顶就停 */
109
+ while (queue.length) {
110
+ if (byCid.size >= maxNodes || Date.now() > deadline) {
111
+ truncated = true;
112
+ break;
113
+ }
114
+ const wave = queue.splice(0, 3);
115
+ const results = await Promise.all(wave.map(async (task) => ({ task, node: await fetchNode(task.fromCid, task.choice.edgeId) })));
116
+ for (const { task, node } of results) {
117
+ if (!node) {
118
+ failed++;
119
+ continue;
120
+ }
121
+ /** 落点那一段的 cid 就是选项自己带的 cid(请求用的 cid 是「从哪出发」) */
122
+ const landed = { ...node, cid: task.choice.cid };
123
+ /** 挂到卡片要的嵌套结构上:选项 → 落到的那一段 */
124
+ task.choice.children = [landed];
125
+ /**
126
+ * 同一个 cid 可能被多条路走到(剧情图不是纯树),只展开一次:
127
+ * 既省请求,也避免环把爬取变成死循环。
128
+ */
129
+ const seenBefore = byCid.has(task.choice.cid);
130
+ byCid.set(task.choice.cid, landed);
131
+ if (seenBefore || !landed.choices.length)
132
+ continue;
133
+ for (const choice of landed.choices) {
134
+ if (byCid.has(choice.cid))
135
+ continue;
136
+ if (queue.some((item) => item.choice.cid === choice.cid))
137
+ continue;
138
+ queue.push({ fromCid: task.choice.cid, choice });
139
+ }
140
+ }
141
+ }
142
+ const result = { root: byCid.get(rootCid) ?? root, nodeCount: byCid.size, truncated, cids: [...byCid.keys()] };
143
+ /** 有缺口(预算用尽或有节点没抓到)就不缓存:否则下次画出来还是同样缺分支 */
144
+ if (!truncated && !failed)
145
+ GRAPH_CACHE.set(cacheKey, { at: Date.now(), graph: result });
146
+ node_karin_1.logger.info('[互动视频] 剧情图已爬取:' + result.nodeCount + ' 个节点'
147
+ + (truncated ? '(预算用尽,还有分支未展开)' : '')
148
+ + (failed ? '(' + failed + ' 个节点没抓到,会在用户走到那里时重画)' : ''));
149
+ return result;
150
+ };
151
+ exports.crawlInteractiveGraph = crawlInteractiveGraph;
152
+ /** 取缓存里的整张图(点「渲染流程图」时直接用它,不再请求接口) */
153
+ const peekCachedGraph = (bvid, graphVersion) => {
154
+ const hit = GRAPH_CACHE.get(bvid + ':' + graphVersion);
155
+ if (!hit)
156
+ return null;
157
+ if (Date.now() - hit.at >= GRAPH_TTL)
158
+ return null;
159
+ return hit.graph;
160
+ };
161
+ exports.peekCachedGraph = peekCachedGraph;
162
+ /** 清理剧情图缓存(测试用) */
163
+ const clearInteractiveGraphCache = () => GRAPH_CACHE.clear();
164
+ exports.clearInteractiveGraphCache = clearInteractiveGraphCache;
@@ -0,0 +1,316 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatInteractivePath = exports.interactiveSessionCount = exports.clearInteractiveSession = exports.getInteractiveSession = exports.rememberInteractiveSession = exports.interactiveKey = exports.INTERACTIVE_TTL_MS = exports.parseChoiceInput = exports.fetchInteractiveNode = exports.fetchInteractiveInfo = exports.parseInteractiveNode = exports.choiceLabel = exports.isJunkChoice = exports.cleanChoiceText = exports.interactiveRequest = void 0;
4
+ /**
5
+ * B站互动视频(互动视频 / stein gate)支持。
6
+ *
7
+ * ## 这类视频和普通视频差在哪
8
+ *
9
+ * 普通视频只有一个 cid,下完就发;互动视频是一张剧情图:每个节点有自己的 cid,
10
+ * 播完要给用户几个选项(选项文本由 B站 下发,且**自带 A/B/C 前缀**),
11
+ * 用户选了之后才跳到下一个节点的视频。
12
+ *
13
+ * ## 拿数据要三个接口(amagi 里没有这三个,所以自己拼 URL)
14
+ *
15
+ * 1. `x/web-interface/view?bvid=` → `data.rights.is_stein_gate === 1` 说明是互动视频;
16
+ * 2. `x/player/wbi/v2?bvid=&cid=` → `data.interaction.graph_version`(剧情图版本号,
17
+ * 必须带它才能查剧情;同一个视频改了剧情,这个号会变);
18
+ * 3. `x/stein/edgeinfo_v2?bvid=&graph_version=&cid=[&edge_id=]` → 当前节点的题目与选项,
19
+ * `edge_id` 是要走的那条边的 id;不给 edge_id 就是根节点。
20
+ *
21
+ * ## 选项文字为什么要洗一遍
22
+ *
23
+ * B站 下发的 option 实测长这样:`"A 向左走"`、`"B 向右挖"`、`"(1)向左走"`,
24
+ * 有的还会拖着 `"A.B.C"`、`"1 2 3"` 这类纯编号噪音。我们统一**去掉自带编号**、
25
+ * 自己按顺序发 A/B/C,这样按钮显示的文字干净,用户回 A / 1 / 选项原文都能对上。
26
+ */
27
+ const node_karin_1 = require("../../../compat/node-karin.js");
28
+ const Network_1 = require("../../module/utils/Network");
29
+ const PLAYER_INFO_URL = 'https://api.bilibili.com/x/player/wbi/v2';
30
+ const EDGE_INFO_URL = 'https://api.bilibili.com/x/stein/edgeinfo_v2';
31
+ /** 默认请求实现:复用插件的网络层(它会带默认请求头、失败重试) */
32
+ const interactiveRequest = async (url, headers) => {
33
+ const data = await new Network_1.Networks({ url, headers, timeout: 15000 }).getData();
34
+ // Networks 失败时返回 false(它自己已经打过日志),这里统一成 null
35
+ return data === false ? null : data;
36
+ };
37
+ exports.interactiveRequest = interactiveRequest;
38
+ /**
39
+ * 选项文字里要去掉的「自带编号」。
40
+ *
41
+ * 必须要求编号后面跟分隔符(空格 / 点 / 顿号 / 括号 / 冒号)才认为是编号:
42
+ * 否则「Block 摆放」这种以字母开头的正常文字会被吃掉首字母。
43
+ */
44
+ const LABEL_PREFIX = /^(?:\s*[((]?\s*(?:[A-Za-z]|\d{1,2}|[一二三四五六七八九十]+)\s*[)).、,,::\-—]+\s*|[A-Za-z]\s+|\d{1,2}\s+|[一二三四五六七八九十]+[、..]\s*)+/;
45
+ /** 结尾的编号噪音:`向左走 A.B.C`、`向右挖 1 2 3` */
46
+ const LABEL_SUFFIX = /[\s.、,,]*(?:[A-Za-z]|\d{1,2})(?:[\s.、,,]+(?:[A-Za-z]|\d{1,2}))+\s*$/;
47
+ /** 只有字母序列的噪音:`A.B.C` / `A B C` */
48
+ const JUNK_LETTERS = /^(?:[A-Za-z][\s.、,,))((]*){2,}$/;
49
+ /** 只有数字/标点的噪音:`1 2 3` / `1.` */
50
+ const JUNK_DIGITS = /^[\d\s.、,,()()]{1,12}$/;
51
+ /** 纯标点 */
52
+ const JUNK_PUNCT = /^[.·•\-—_…,,、;;::!!??\s]+$/;
53
+ /** 零宽字符等看不见的东西 */
54
+ const INVISIBLE = /[\u200b-\u200f\ufeff\u2060]/g;
55
+ /**
56
+ * 洗选项文字:去零宽字符、去自带编号、去结尾编号噪音、压多余空格。
57
+ *
58
+ * @param raw B站 下发的 option 原文
59
+ */
60
+ const cleanChoiceText = (raw) => {
61
+ let text = String(raw ?? '').replace(INVISIBLE, '').trim();
62
+ text = text.replace(LABEL_PREFIX, '').trim();
63
+ text = text.replace(LABEL_SUFFIX, '').trim();
64
+ return text.replace(/\s{2,}/g, ' ');
65
+ };
66
+ exports.cleanChoiceText = cleanChoiceText;
67
+ /**
68
+ * 判断一个选项是不是「只剩编号」的噪音。
69
+ *
70
+ * 用户反馈的 `A.B.C`、`1 2 3` 就是这类:留着只会让按钮上出现一堆没意义的字。
71
+ */
72
+ const isJunkChoice = (text) => {
73
+ const value = String(text ?? '').trim();
74
+ if (!value)
75
+ return true;
76
+ return JUNK_LETTERS.test(value) || JUNK_DIGITS.test(value) || JUNK_PUNCT.test(value);
77
+ };
78
+ exports.isJunkChoice = isJunkChoice;
79
+ /** 选项标签:A、B、…、Z,超过 26 个用数字,避免出现 `[` 这种字符 */
80
+ const choiceLabel = (index) => index >= 0 && index < 26 ? String.fromCharCode(65 + index) : String(index + 1);
81
+ exports.choiceLabel = choiceLabel;
82
+ /**
83
+ * 把 B站 的 edgeinfo_v2 响应解析成节点。
84
+ *
85
+ * 纯函数:不联网、不改数据,方便直接拿真实响应做测试。
86
+ *
87
+ * @param payload edgeinfo_v2 的响应体
88
+ * @param base 本次请求用到的 bvid / 剧情图版本 / cid
89
+ */
90
+ const parseInteractiveNode = (payload, base) => {
91
+ const data = payload?.data ?? payload;
92
+ if (!data || typeof data !== 'object')
93
+ return null;
94
+ const question = (data.edges?.questions ?? [])[0] ?? {};
95
+ const rawChoices = Array.isArray(question.choices) ? question.choices : [];
96
+ /** 先按「能继续对话」的硬条件过滤:边 id 和 cid 必须都是有效数字 */
97
+ const usable = rawChoices
98
+ .map((choice, order) => ({
99
+ order,
100
+ edgeId: Number(choice?.id),
101
+ cid: Number(choice?.cid),
102
+ rawText: String(choice?.option ?? ''),
103
+ isDefault: Boolean(choice?.is_default)
104
+ }))
105
+ .filter((item) => Number.isFinite(item.edgeId) && item.edgeId > 0 && Number.isFinite(item.cid) && item.cid > 0);
106
+ const cleaned = usable.map((item) => ({
107
+ ...item,
108
+ text: (0, exports.cleanChoiceText)(item.rawText),
109
+ /**
110
+ * 噪音判断要看**原始文字**。
111
+ *
112
+ * `A.B.C` 洗掉前缀之后只剩一个 `C`,光看洗过的结果它一点都不像噪音,
113
+ * 于是这种选项会被留下来,按钮上就出现一串没意义的字母数字。
114
+ */
115
+ junk: (0, exports.isJunkChoice)((0, exports.cleanChoiceText)(item.rawText)) || (0, exports.isJunkChoice)(item.rawText)
116
+ }));
117
+ /**
118
+ * 噪音选项只在「还有正经选项」时才丢:万一一整道题都是噪音,
119
+ * 那就原样留着 —— 有得选总比没得选好。
120
+ */
121
+ const meaningful = cleaned.filter((item) => !item.junk);
122
+ const kept = meaningful.length ? meaningful : cleaned;
123
+ const choices = kept.map((item, index) => ({
124
+ index,
125
+ label: (0, exports.choiceLabel)(index),
126
+ /**
127
+ * 洗不干净(洗完还是编号)时退回原文:`A.B.C` 这种至少让用户看得出
128
+ * 「这是 B站 给的一条坏选项」,而不是一个莫名其妙的 `C`。
129
+ */
130
+ text: item.junk ? item.rawText.trim() || item.text || (0, exports.choiceLabel)(index) : item.text || item.rawText.trim() || (0, exports.choiceLabel)(index),
131
+ rawText: item.rawText,
132
+ edgeId: item.edgeId,
133
+ cid: item.cid,
134
+ isDefault: item.isDefault
135
+ }));
136
+ return {
137
+ bvid: base.bvid,
138
+ graphVersion: base.graphVersion,
139
+ cid: base.cid,
140
+ title: String(data.title ?? ''),
141
+ question: String(question.title ?? '').trim(),
142
+ choices,
143
+ isLeaf: Number(data.is_leaf) === 1 || choices.length === 0,
144
+ notice: ''
145
+ };
146
+ };
147
+ exports.parseInteractiveNode = parseInteractiveNode;
148
+ /**
149
+ * 查剧情图版本号(顺便带回 B站 的提示语)。
150
+ *
151
+ * 只有互动视频的播放器接口里才有 `data.interaction`,所以这个函数也是「是不是互动视频」的判断依据。
152
+ *
153
+ * @param params bvid / 当前 cid / 请求头 / 请求实现
154
+ */
155
+ const fetchInteractiveInfo = async (params) => {
156
+ const { bvid, cid, headers, request = exports.interactiveRequest } = params;
157
+ if (!bvid || !cid)
158
+ return null;
159
+ const payload = await request(PLAYER_INFO_URL + '?bvid=' + bvid + '&cid=' + cid, headers);
160
+ const interaction = payload?.data?.interaction;
161
+ const graphVersion = Number(interaction?.graph_version);
162
+ if (!Number.isFinite(graphVersion) || graphVersion <= 0) {
163
+ // 普通视频本来就没有 interaction,这里只记 debug;真的要排查时再看
164
+ node_karin_1.logger.debug('[互动视频] 播放器接口没有剧情图版本号(code=' + String(payload?.code ?? '无响应') + '),按普通视频处理');
165
+ return null;
166
+ }
167
+ return { graphVersion, notice: String(interaction?.msg ?? '').trim() };
168
+ };
169
+ exports.fetchInteractiveInfo = fetchInteractiveInfo;
170
+ /**
171
+ * 拉取一个剧情节点(不给 edgeId 就是根节点)。
172
+ *
173
+ * @param params bvid / 剧情图版本 / cid / 要走的边(可选)/ 请求头 / 请求实现
174
+ */
175
+ const fetchInteractiveNode = async (params) => {
176
+ const { bvid, graphVersion, cid, edgeId, headers, request = exports.interactiveRequest } = params;
177
+ if (!bvid || !graphVersion || !cid)
178
+ return null;
179
+ let url = EDGE_INFO_URL + '?bvid=' + bvid + '&graph_version=' + graphVersion + '&cid=' + cid;
180
+ if (edgeId)
181
+ url += '&edge_id=' + edgeId;
182
+ /**
183
+ * 重试两次。
184
+ *
185
+ * 线上实测:用户在群里选完选项,偶尔会遇到一次接口抖动 —— 之前只问一次、
186
+ * 失败就当作「剧情图取不到下一段」把剧情结束掉,用户明明回对了却被踢出剧情。
187
+ * 这种一次性的失败重试一次就好了,同时把真实原因(code / message / 没响应)打进日志。
188
+ */
189
+ let lastReason = '没有响应(网络层返回 false)';
190
+ for (let attempt = 1; attempt <= 2; attempt++) {
191
+ const payload = await request(url, headers);
192
+ const code = Number(payload?.code);
193
+ if (payload && code === 0) {
194
+ const node = (0, exports.parseInteractiveNode)(payload, { bvid, graphVersion, cid });
195
+ if (node)
196
+ return node;
197
+ lastReason = '响应里没有剧情数据';
198
+ }
199
+ else {
200
+ lastReason = 'code=' + String(payload?.code ?? '无') + ' message=' + JSON.stringify(payload?.message ?? '');
201
+ }
202
+ if (attempt === 1)
203
+ await new Promise((resolve) => setTimeout(resolve, 400));
204
+ }
205
+ node_karin_1.logger.warn('[互动视频] 取剧情节点失败(' + (edgeId ? '选项 edge_id=' + edgeId : '根节点') + ' cid=' + cid + '):' + lastReason);
206
+ return null;
207
+ };
208
+ exports.fetchInteractiveNode = fetchInteractiveNode;
209
+ /**
210
+ * 把用户回的那句话解析成选项序号。
211
+ *
212
+ * 支持:`A` / `a` / 全角 `A`、`1`、`选A`、`选择 2`、直接回选项原文(可只回其中几个字)。
213
+ *
214
+ * @param input 用户消息文本
215
+ * @param choices 当前节点可选的选项
216
+ * @returns 命中的序号;没命中返回 -1
217
+ */
218
+ const parseChoiceInput = (input, choices) => {
219
+ if (!Array.isArray(choices) || !choices.length)
220
+ return -1;
221
+ /**
222
+ * 先剥掉消息里的元素标记。
223
+ *
224
+ * 线上实测:用户点了 @ 之后发「A」,拿到的文本是 `<at id="661867728489"/>A`,
225
+ * 直接被当成「不认识的内容」—— 用户明明回对了却不认。CQ 码形态(`[CQ:at,qq=…]`)同理。
226
+ */
227
+ const stripped = String(input ?? '')
228
+ .replace(ELEMENT_TAG, '')
229
+ .replace(CQ_CODE, '');
230
+ // 全角字母数字先归一化成半角,手机上很容易打出全角
231
+ const normalized = stripped
232
+ .replace(/[A-Za-z0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
233
+ .replace(/\s+/g, ' ')
234
+ .trim();
235
+ if (!normalized)
236
+ return -1;
237
+ const hit = matchChoice(normalized, choices);
238
+ if (hit >= 0)
239
+ return hit;
240
+ /** 再来一次:这次把开头的 @某某 也去掉(有的客户端把提醒当纯文本发过来) */
241
+ const withoutMention = normalized.replace(LEADING_MENTION, '').trim();
242
+ return withoutMention && withoutMention !== normalized ? matchChoice(withoutMention, choices) : -1;
243
+ };
244
+ exports.parseChoiceInput = parseChoiceInput;
245
+ /** 消息元素标记:`<at id="…"/>`、`<img …/>`、`[CQ:at,qq=…]` 都算 */
246
+ const ELEMENT_TAG = /<\/?(?:at|img|face|mface|reply|quote|json|xml|button|markdown)\b[^>]*>/gi;
247
+ const CQ_CODE = /\[CQ:[^\]]+\]/gi;
248
+ /** 纯文本形态的提醒:`@麦芽糖bot A`(QQ 用的是全角空格,一并吃掉) */
249
+ const LEADING_MENTION = /^(?:@[^\s@]{1,32}[\s\u2005\u00a0]*)+/;
250
+ /** 从一句话里认出选项:字母 / 数字 / 选项原文(完全相等或包含) */
251
+ const matchChoice = (text, choices) => {
252
+ const bare = text.replace(/^(?:请|我)?(?:选择|选|回复|回|答案|答)\s*/, '').trim();
253
+ if (!bare)
254
+ return -1;
255
+ const letter = /^([A-Za-z])$/.exec(bare);
256
+ if (letter) {
257
+ const index = letter[1].toUpperCase().charCodeAt(0) - 65;
258
+ if (index >= 0 && index < choices.length)
259
+ return index;
260
+ }
261
+ const digit = /^(\d{1,2})$/.exec(bare);
262
+ if (digit) {
263
+ const index = Number(digit[1]) - 1;
264
+ if (index >= 0 && index < choices.length)
265
+ return index;
266
+ }
267
+ /** 回原文:完全相等优先,其次是「用户打了一半」的模糊匹配(至少两个字) */
268
+ const exact = choices.findIndex((choice) => choice.text === bare);
269
+ if (exact >= 0)
270
+ return exact;
271
+ if (bare.length >= 2) {
272
+ const partial = choices.findIndex((choice) => choice.text.includes(bare) || bare.includes(choice.text));
273
+ if (partial >= 0)
274
+ return partial;
275
+ }
276
+ return -1;
277
+ };
278
+ /** 会话有效期:超过就当作结束了(用户隔天再回 A 不该续上昨天的剧情) */
279
+ exports.INTERACTIVE_TTL_MS = 15 * 60 * 1000;
280
+ const sessions = new Map();
281
+ /** 会话 key:一个频道一份(群里谁回 A 都算继续剧情,和面板按钮的行为一致) */
282
+ const interactiveKey = (platform, channelId) => String(platform ?? '') + ':' + String(channelId ?? '');
283
+ exports.interactiveKey = interactiveKey;
284
+ /** 存一份会话(同时清理过期会话,避免 Map 越攒越大) */
285
+ const rememberInteractiveSession = (key, session) => {
286
+ const now = Date.now();
287
+ for (const [item, value] of sessions) {
288
+ if (now - value.updatedAt > exports.INTERACTIVE_TTL_MS)
289
+ sessions.delete(item);
290
+ }
291
+ sessions.set(key, { ...session, updatedAt: now });
292
+ };
293
+ exports.rememberInteractiveSession = rememberInteractiveSession;
294
+ /** 取会话;过期或不存在都返回 null */
295
+ const getInteractiveSession = (key) => {
296
+ const session = sessions.get(key);
297
+ if (!session)
298
+ return null;
299
+ if (Date.now() - session.updatedAt > exports.INTERACTIVE_TTL_MS) {
300
+ sessions.delete(key);
301
+ return null;
302
+ }
303
+ return session;
304
+ };
305
+ exports.getInteractiveSession = getInteractiveSession;
306
+ /** 结束会话(走到结局、用户放弃、解析失败都调用它) */
307
+ const clearInteractiveSession = (key) => {
308
+ sessions.delete(key);
309
+ };
310
+ exports.clearInteractiveSession = clearInteractiveSession;
311
+ /** 当前活着的会话数量(测试与排查用) */
312
+ const interactiveSessionCount = () => sessions.size;
313
+ exports.interactiveSessionCount = interactiveSessionCount;
314
+ /** 把一段剧情路径渲染成 `开场 → 向左走` 这种进度文本 */
315
+ const formatInteractivePath = (path) => Array.isArray(path) && path.length ? path.join(' → ') : '';
316
+ exports.formatInteractivePath = formatInteractivePath;