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
@@ -147,9 +147,40 @@ function listActiveDownloads() {
147
147
  }
148
148
  return result.sort((left, right) => right.at - left.at);
149
149
  }
150
+ /* ------------------------------------------------------------------ *
151
+ * 坏源判定阈值
152
+ * ------------------------------------------------------------------ */
153
+ /**
154
+ * 换源前的热身时间:新源先给这么久证明自己。
155
+ *
156
+ * B站 playurl 经常把直链指向 `*.mcdn.bilivideo.cn` 这类 PCDN 边缘节点:
157
+ * TCP 连得上、响应头也回得来(content-length 都正常),就是几乎不吐数据
158
+ * (实测 118 KB / 60 秒 ≈ 2 KB/s)。在这种源上干等毫无意义。
159
+ */
160
+ const STALL_WARMUP_MS = 20_000;
161
+ /** 热身期过后速度仍低于这个值(byte/s)且还有备用地址时,判定为坏源并换源 */
162
+ const STALL_MIN_SPEED = 64 * 1024;
163
+ /**
164
+ * 重试同一个源之前,本轮至少要传这么多字节,才值得原地重试。
165
+ *
166
+ * 传了几 MB 再断,说明源本身是好的(只是被掐了),留着进度续传最划算;
167
+ * 传了几百 KB 就断,那就是坏源,直接换。
168
+ */
169
+ const SWITCH_IF_UNDER_BYTES = 4 * 1024 * 1024;
170
+ /** 单次尝试的硬上限:既没数据、又没有备用源可换的「涓流」源不能永远挂着 */
171
+ const MAX_ATTEMPT_MS = 30 * 60_000;
172
+ /** 打个码的地址(只留 host,别把带签名的完整直链写进日志) */
173
+ const safeHost = (url) => {
174
+ try {
175
+ return new URL(url).host;
176
+ }
177
+ catch {
178
+ return '(非法地址)';
179
+ }
180
+ };
150
181
  /**
151
182
  * 文件下载器
152
- * 支持断点续传、限速下载、自动重试
183
+ * 支持断点续传、限速下载、自动重试,以及**坏源自动换源**
153
184
  */
154
185
  class Downloader {
155
186
  axiosInstance;
@@ -161,7 +192,20 @@ class Downloader {
161
192
  throttleConfig;
162
193
  currentSpeed;
163
194
  consecutiveResets;
164
- constructor(axiosInstance, url, filepath, headers, timeout, maxRetries, throttleConfig) {
195
+ /** 备用直链:当前源被判定为坏源时按顺序换过去(B站 dash 的 backup_url) */
196
+ backupUrls;
197
+ /**
198
+ * 本次尝试「被自己掐断」的原因:
199
+ * - `timeout`:空闲超时(连续 timeout 毫秒没收到数据)
200
+ * - `slow`:坏源看门狗主动放弃
201
+ *
202
+ * 自己掐断的必须和「服务器重置」区分开 —— Node 把 abort 报成
203
+ * `Error: aborted`(code ECONNRESET),照抄进日志会把排查方向带偏。
204
+ */
205
+ abortReason = null;
206
+ /** 已经换过几次源(仅用于日志) */
207
+ sourceSwitches = 0;
208
+ constructor(axiosInstance, url, filepath, headers, timeout, maxRetries, throttleConfig, backupUrls = []) {
165
209
  this.axiosInstance = axiosInstance;
166
210
  this.url = url;
167
211
  this.filepath = filepath;
@@ -171,6 +215,7 @@ class Downloader {
171
215
  this.throttleConfig = { ...types_1.DEFAULT_THROTTLE_CONFIG, ...throttleConfig };
172
216
  this.currentSpeed = this.throttleConfig.maxSpeed;
173
217
  this.consecutiveResets = 0;
218
+ this.backupUrls = [...backupUrls];
174
219
  }
175
220
  /**
176
221
  * 执行下载
@@ -186,11 +231,51 @@ class Downloader {
186
231
  if (!this.filepath) {
187
232
  throw new Error('未指定文件保存路径: filepath 为空');
188
233
  }
234
+ // 每次尝试都从「没被掐断」开始
235
+ this.abortReason = null;
189
236
  const controller = new AbortController();
190
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
191
237
  let intervalId = null;
192
238
  let throttleStream = null;
193
239
  let writer = null;
240
+ /** 本轮尝试收到的字节数(用于坏源判定,断点续传时只算这一轮新增的) */
241
+ let attemptBytes = 0;
242
+ /** 本轮尝试的开始时间 */
243
+ const attemptStart = Date.now();
244
+ let timeoutId = null;
245
+ let attemptCapId = null;
246
+ const clearTimers = () => {
247
+ if (timeoutId)
248
+ clearTimeout(timeoutId);
249
+ if (attemptCapId)
250
+ clearTimeout(attemptCapId);
251
+ timeoutId = null;
252
+ attemptCapId = null;
253
+ };
254
+ /**
255
+ * 重新计时:**这是空闲超时,不是总时长超时**。
256
+ *
257
+ * 以前这里是一个从头开始算的定时器(`setTimeout(abort, 60000)` 并且全程不再重置),
258
+ * 于是任何需要超过 60 秒的文件(按 1.5 MB/s 算就是 90 MB 以上)都**永远下不完**:
259
+ * 到点就被 abort,而 Node 把「自己 abort」报成 `Error: aborted`(code ECONNRESET),
260
+ * 日志里就成了「下载失败: 连接被重置 (ECONNRESET): aborted」,把所有排查方向都带偏。
261
+ * 现在每收到一块数据就重新计时,只有**真的没数据**才超时。
262
+ */
263
+ const armIdleTimeout = () => {
264
+ if (this.timeout <= 0)
265
+ return;
266
+ if (timeoutId)
267
+ clearTimeout(timeoutId);
268
+ timeoutId = setTimeout(() => {
269
+ this.abortReason = 'timeout';
270
+ controller.abort();
271
+ }, this.timeout);
272
+ };
273
+ armIdleTimeout();
274
+ // 兜底:涓流源(一直有数据但慢得离谱、又没有备用源)不能在这一次尝试里挂几个小时
275
+ attemptCapId = setTimeout(() => {
276
+ this.abortReason = 'timeout';
277
+ controller.abort();
278
+ }, MAX_ATTEMPT_MS);
194
279
  try {
195
280
  // 检查断点续传
196
281
  let startByte = 0;
@@ -234,7 +319,7 @@ class Downloader {
234
319
  currentSpeed: this.throttleConfig.enabled ? (0, helpers_1.formatBytes)(this.currentSpeed) + '/s' : '不限速'
235
320
  });
236
321
  const response = await this.axiosInstance(requestConfig);
237
- clearTimeout(timeoutId);
322
+ // 注意:这里**不**清除定时器 —— 传输阶段的空闲超时还得靠它(每收到数据会重新计时)
238
323
  // 检查 HTTP 状态码
239
324
  // 416 Range Not Satisfiable
240
325
  if (response.status === 416) {
@@ -328,11 +413,35 @@ class Downloader {
328
413
  reportDownloadProgress(this.filepath, downloadedBytes, totalBytes);
329
414
  };
330
415
  const interval = totalBytes > 0 && totalBytes < 10 * 1024 * 1024 ? 1000 : 500;
331
- intervalId = setInterval(printProgress, interval);
416
+ /**
417
+ * 坏源看门狗:有备用地址时,热身期过了速度还低得离谱就直接放弃这个源。
418
+ *
419
+ * 没有备用地址时不介入 —— 慢总比失败强,真正的死源交给空闲超时兜底。
420
+ */
421
+ const checkStall = () => {
422
+ if (this.backupUrls.length === 0)
423
+ return;
424
+ const elapsed = Date.now() - attemptStart;
425
+ if (elapsed < STALL_WARMUP_MS)
426
+ return;
427
+ const speed = attemptBytes / (elapsed / 1000);
428
+ if (speed >= STALL_MIN_SPEED)
429
+ return;
430
+ node_karin_1.logger.warn(`当前下载源速度过低 (${(0, helpers_1.formatBytes)(Math.round(speed))}/s),准备切换备用地址`);
431
+ this.abortReason = 'slow';
432
+ controller.abort();
433
+ };
434
+ intervalId = setInterval(() => {
435
+ printProgress();
436
+ checkStall();
437
+ }, interval);
332
438
  // 创建计数流
333
439
  const counterStream = new node_stream_1.Transform({
334
440
  transform(chunk, encoding, callback) {
335
441
  downloadedBytes += chunk.length;
442
+ attemptBytes += chunk.length;
443
+ // 还有数据在流,说明这个源活着:把空闲超时往后推
444
+ armIdleTimeout();
336
445
  callback(null, chunk);
337
446
  }
338
447
  });
@@ -345,8 +454,6 @@ class Downloader {
345
454
  else {
346
455
  await (0, promises_1.pipeline)(response.data, counterStream, writer);
347
456
  }
348
- if (intervalId)
349
- clearInterval(intervalId);
350
457
  // pipeline 已经等待所有流完成,包括 writer 的 finish 事件
351
458
  node_karin_1.logger.debug('文件下载并写入完成');
352
459
  // 验证文件大小
@@ -388,20 +495,34 @@ class Downloader {
388
495
  };
389
496
  }
390
497
  catch (error) {
391
- clearTimeout(timeoutId);
498
+ clearTimers();
392
499
  if (intervalId)
393
500
  clearInterval(intervalId);
394
501
  // 失败/中断也要清掉,不然「查询下载进度」会一直卡在旧任务上
395
502
  clearDownloadProgress(this.filepath);
396
- const isRecoverable = (0, helpers_1.isRecoverableNetworkError)(error);
397
- const isThrottling = (0, helpers_1.isThrottlingError)(error);
398
- const errorDesc = (0, helpers_1.getErrorDescription)(error);
399
- if (error instanceof axios_1.AxiosError) {
503
+ /**
504
+ * 自己掐断的(空闲超时 / 坏源看门狗)不能按「服务器重置」处理。
505
+ *
506
+ * Node 对 `request.abort()` 抛的是 `Error: aborted`(code ECONNRESET),
507
+ * 直接套 code→文案的映射就会打印成「连接被重置」,既误导排查、
508
+ * 又会让下面的「服务器断流自动降速」误判(把一个坏源当成风控)。
509
+ */
510
+ const idleTimeout = this.abortReason === 'timeout';
511
+ const slowSource = this.abortReason === 'slow';
512
+ const ownAbort = idleTimeout || slowSource;
513
+ const isRecoverable = ownAbort || (0, helpers_1.isRecoverableNetworkError)(error);
514
+ const isThrottling = !ownAbort && (0, helpers_1.isThrottlingError)(error);
515
+ const errorDesc = idleTimeout
516
+ ? `下载超时(${Math.round(this.timeout / 1000)} 秒内没有收到新数据)`
517
+ : slowSource
518
+ ? '下载源速度过低,主动放弃该源'
519
+ : (0, helpers_1.getErrorDescription)(error);
520
+ if (error instanceof axios_1.AxiosError && !ownAbort) {
400
521
  const sanitized = (0, helpers_1.sanitizeHeaders)(this.headers);
401
522
  node_karin_1.logger.error(`请求失败: ${errorDesc}, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`);
402
523
  }
403
524
  else {
404
- node_karin_1.logger.error(`下载失败: ${errorDesc}`);
525
+ node_karin_1.logger.error(ownAbort ? `下载中断: ${errorDesc}` : `下载失败: ${errorDesc}`);
405
526
  }
406
527
  // 如果是断流错误,自动降速
407
528
  if (isThrottling && this.throttleConfig.enabled) {
@@ -415,6 +536,22 @@ class Downloader {
415
536
  node_karin_1.logger.warn(`已达到最低速度限制 ${(0, helpers_1.formatBytes)(this.throttleConfig.minSpeed)}/s,无法继续降速`);
416
537
  }
417
538
  }
539
+ /**
540
+ * 坏源优先换源。
541
+ *
542
+ * 同一份数据在别的镜像上往往能跑满带宽,而坏源上耗满 maxRetries 次
543
+ * (每次都要再等一个超时)纯属浪费时间;反过来,如果本轮已经传了好几 MB
544
+ * 只是被掐了一下,那就该留在原源续传,别把进度扔掉。
545
+ */
546
+ if ((slowSource || (idleTimeout && attemptBytes < SWITCH_IF_UNDER_BYTES)) && this.backupUrls.length > 0) {
547
+ const next = this.backupUrls.shift();
548
+ this.sourceSwitches++;
549
+ node_karin_1.logger.warn(`当前源不可用,换备用下载源 (${this.sourceSwitches},剩余 ${this.backupUrls.length}): ` +
550
+ `${safeHost(this.url)} -> ${safeHost(next)}`);
551
+ this.url = next;
552
+ await new Promise((resolve) => setTimeout(resolve, 1000));
553
+ return this.download(progressCallback, 0);
554
+ }
418
555
  const nextDelay = (0, helpers_1.calculateBackoffDelay)(retryCount);
419
556
  if (retryCount < this.maxRetries) {
420
557
  // 等待 writer 完全关闭,确保异步写入操作已完成,避免 stat 获取到不准确的文件大小
@@ -453,6 +590,9 @@ class Downloader {
453
590
  if (isThrottling) {
454
591
  node_karin_1.logger.warn('建议: 服务器可能有下载速度限制,请尝试在配置中降低 maxSpeed 参数');
455
592
  }
593
+ if (idleTimeout) {
594
+ node_karin_1.logger.warn('提示: 这条直链所属的 CDN 节点(常见于 *.mcdn.bilivideo.cn 这类 PCDN)很可能已失效,稍后重试或换个画质通常会换到别的节点');
595
+ }
456
596
  }
457
597
  else {
458
598
  try {
@@ -468,6 +608,12 @@ class Downloader {
468
608
  throw new Error(`在 ${this.maxRetries} 次尝试后下载失败: ${errorDesc}, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`);
469
609
  }
470
610
  }
611
+ finally {
612
+ // 所有出口(成功 / 416 提前返回 / 换源 / 重试 / 抛错)都要把定时器和轮询清干净
613
+ clearTimers();
614
+ if (intervalId)
615
+ clearInterval(intervalId);
616
+ }
471
617
  }
472
618
  /**
473
619
  * 手动设置下载速度
@@ -54,6 +54,7 @@ class Network {
54
54
  filepath;
55
55
  maxRetries;
56
56
  throttleConfig;
57
+ backupUrls;
57
58
  /**
58
59
  * 创建网络请求实例
59
60
  *
@@ -79,6 +80,7 @@ class Network {
79
80
  this.filepath = data.filepath ?? '';
80
81
  this.maxRetries = data.maxRetries ?? 3;
81
82
  this.throttleConfig = data.throttle;
83
+ this.backupUrls = data.backupUrls ?? [];
82
84
  // 创建 axios 实例
83
85
  this.axiosInstance = axios_1.default.create({
84
86
  timeout: this.timeout,
@@ -126,7 +128,9 @@ class Network {
126
128
  * @param retryCount 重试次数(内部使用)
127
129
  */
128
130
  async downloadStream(progressCallback, retryCount = 0) {
129
- const downloader = new Downloader_1.Downloader(this.axiosInstance, this.url, this.filepath, this.headers, this.timeout, this.maxRetries, this.throttleConfig);
131
+ const downloader = new Downloader_1.Downloader(this.axiosInstance, this.url, this.filepath, this.headers, this.timeout, this.maxRetries, this.throttleConfig,
132
+ // 拷贝一份:下载器内部会 shift 掉已经试过的地址
133
+ [...this.backupUrls]);
130
134
  return downloader.download(progressCallback, retryCount);
131
135
  }
132
136
  async getfetch() {
@@ -111,6 +111,8 @@ function isParseForwardEnabled(platform) {
111
111
  * - \`image\` 图片(信息卡、评论卡、图集、切片后的评论卡都算这一类)
112
112
  * - \`video\` 视频(体积大的会被硬上限挡下,见 HARD_INLINE_LIMIT)
113
113
  * - \`file\` 文件(群文件等)
114
+ * - \`chart\` B站互动视频的**剧情流程图**:它本身是图片,但可以在「合并转发内容」里单独勾/不勾,
115
+ * 所以由发送方用 \`withForwardKind('chart', …)\` 标出来(见 compat/forward-collect)
114
116
  *
115
117
  * **没列出来的内容一律单独直发**(不进转发节点)。默认只合并文字和图片。
116
118
  *
@@ -128,7 +130,7 @@ function isParseForwardEnabled(platform) {
128
130
  * 会让整条转发被拒(\`Error with request send_group_forward_msg …\`),所以默认只合并文字和图片;
129
131
  * 想要视频也进去就勾上 \`video\` —— 真发不出去时下面的兜底会逐条直发,不会丢内容。
130
132
  */
131
- const FORWARD_KINDS = ['text', 'image', 'video', 'file'];
133
+ const FORWARD_KINDS = ['text', 'image', 'video', 'file', 'chart'];
132
134
  const DEFAULT_FORWARD_CONTENT = ['text', 'image'];
133
135
  /**
134
136
  * **永远不进转发节点**的元素类型。
@@ -144,8 +146,16 @@ const NEVER_FORWARD_TYPES = /^(record|audio|markdown)$/;
144
146
  * 适配器的请求撑爆。到这一步就回退成单独直发。
145
147
  */
146
148
  const HARD_INLINE_LIMIT = 30 * 1024 * 1024;
147
- /** 把元素归到一个可配置的类别(\`other\` 是 at / 引用这类轻量装饰,永远跟着转发走) */
149
+ /**
150
+ * 把元素归到一个可配置的类别(\`other\` 是 at / 引用这类轻量装饰,永远跟着转发走)。
151
+ *
152
+ * **发送方标的类别优先**:剧情流程图和普通图片都是 \`img\`,从段对象上看不出区别 ——
153
+ * 只有发送方知道它是剧情图(见 compat 的 \`withForwardKind\`)。没标的才按段类型判断。
154
+ */
148
155
  const kindOfElement = (element) => {
156
+ const tagged = (0, node_karin_1.forwardKindOf)(element);
157
+ if (tagged)
158
+ return tagged;
149
159
  const type = String(element?.type ?? '');
150
160
  if (type === 'img' || type === 'image')
151
161
  return 'image';
@@ -100,7 +100,7 @@ exports.ParseSteps = ParseSteps;
100
100
  * ## 注意
101
101
  *
102
102
  * - **有依赖顺序的步骤别拆开**:比如「先渲染再发送」必须在同一个任务里;
103
- * - 渲染内核(shotkit)是**单线程 FIFO**,两个渲染任务并发并不会更快,
103
+ * - 浏览器渲染是**串行**的(一个浏览器同时渲好几张只会互相拖慢),
104
104
  * 拆线只对「网络/磁盘/ffmpeg」这类真正并行的阶段有意义。
105
105
  */
106
106
  class SendTasks {
@@ -10,7 +10,6 @@ exports.buildPosterContext = buildPosterContext;
10
10
  exports.renderTemplateHtml = renderTemplateHtml;
11
11
  exports.downgradeNoiseLayers = downgradeNoiseLayers;
12
12
  exports.pruneMissingFontFaces = pruneMissingFontFaces;
13
- exports.neutralizeRemoteAssets = neutralizeRemoteAssets;
14
13
  exports.optimizeForScreenshot = optimizeForScreenshot;
15
14
  /**
16
15
  * 渲染模块(Koishi 移植版)。
@@ -19,7 +18,7 @@ exports.optimizeForScreenshot = optimizeForScreenshot;
19
18
  * Koishi 侧不引入 ktr 的构建管线(需要 vite/tailwind 打包),改为:
20
19
  * 1. 直接用 **react-dom/server** 对上游模板组件做 SSR(模板只依赖 \`{ data, ctx }\` props);
21
20
  * 2. 内联上游构建产物 \`resources/template/style.css\`(tailwind 编译结果,省掉 tailwind 构建);
22
- * 3. 优先用 koishi-plugin-puppeteer 截图(没有它才退回 koishi-plugin-shotkit 内核)。
21
+ * 3. 用浏览器渲染服务(koishi-plugin-puppeteer / puppeteer-without-canvas)截图;3.5.0 起不再支持 shotkit 内核。
23
22
  * 路由不在注册表里、或 SSR 失败时,回退到内置通用信息卡片,保证解析结果始终有图。
24
23
  */
25
24
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -470,36 +469,6 @@ function pruneMissingFontFaces(html, baseDir) {
470
469
  return resolvable ? block : '';
471
470
  });
472
471
  }
473
- /**
474
- * 内核(shotkit)加载不了的远程资源,交给它之前先摘掉。
475
- *
476
- * 实测:内核在 Windows 上**加载不了 https 资源**(卡片里的 https 图片一律渲染成空白框),
477
- * 但它照样会为这些地址发请求、并且等它们结束 —— 一张 1440x600 的小页面里只要有一个连不上的
478
- * https 图片,截图就从 1.5 秒变成 22 秒。React 19 的 SSR 还会给每个 `<img>` 额外补一条
479
- * `<link rel="preload" as="image">`(一张评论卡实测 27 条),这些 preload 同样算在页面加载里。
480
- *
481
- * 所以**只在 shotkit 这条路径**上做替换(puppeteer 那条路不动,它能正常加载远程图片):
482
- * - 删掉 `<link rel="preload">`(纯预加载提示,本地渲染没有意义)
483
- * - 远程 `<img src>` 换成 1x1 透明图(反正渲染不出来),顺带删掉 srcset
484
- * - 内联样式/CSS 里的 `url(http…)` 换成 none
485
- */
486
- function neutralizeRemoteAssets(html) {
487
- let links = 0;
488
- let images = 0;
489
- let output = html.replace(/<link\b[^>]*\brel=["']?preload["']?[^>]*>/gi, () => {
490
- links++;
491
- return '';
492
- });
493
- output = output.replace(/(<img\b[^>]*?)\ssrc="https?:\/\/[^"]*"/gi, (_all, head) => {
494
- images++;
495
- return head + ' src="' + TRANSPARENT_PIXEL + '"';
496
- });
497
- output = output.replace(/(<img\b[^>]*?)\ssrcset="[^"]*"/gi, '$1');
498
- output = output.replace(/url\(\s*(['"]?)https?:\/\/[^)'"]*\1\s*\)/gi, 'none');
499
- return { html: output, links, images };
500
- }
501
- /** 顶掉远程图片用的 1x1 透明 gif(内联 data: 内核能正常解码) */
502
- const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
503
472
  /** 截图前的统一降级:先剔无效字体,再降级噪点层 */
504
473
  function optimizeForScreenshot(html, baseDir) {
505
474
  const pruned = pruneMissingFontFaces(html, baseDir);
@@ -515,30 +484,20 @@ function optimizeForScreenshot(html, baseDir) {
515
484
  /**
516
485
  * 对一个 HTML 文件截图,返回 base64 和实际产出的 mime。
517
486
  *
518
- * 优先用 koishi-plugin-puppeteer 那条浏览器路径;没有浏览器服务时退回 shotkit 内核。
487
+ * **只用浏览器渲染服务**(koishi-plugin-puppeteer / puppeteer-without-canvas 之类)。
488
+ * 3.5.0 起不再支持 shotkit 内核:它的预编译内核在 Windows 上加载不了 https 资源
489
+ * (卡片里的封面、头像全是空白框),换来的那点速度不值得让卡片看起来是坏的。
519
490
  *
520
- * `scale` 会作为 deviceScaleFactor(配置项 `app.renderScale`,100 = 1x,200 = 2x):
491
+ * scale 会作为 deviceScaleFactor(配置项 app.renderScale,100 = 1x,200 = 2x):
521
492
  * 不设的话高分辨率卡片会被截成 1x,群里看着又小又糊。
522
493
  */
523
- /** 用了 shotkit 只提示一次(每张卡片都刷一行没意义) */
524
- let shotkitLogged = false;
525
- async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg', htmlSource) {
494
+ async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg') {
526
495
  const koishiCtx = (0, node_karin_1.getKoishiContext)();
527
496
  /** 浏览器渲染服务(koishi-plugin-puppeteer / puppeteer-without-canvas 之类) */
528
497
  const puppeteer = koishiCtx?.get ? koishiCtx.get('puppeteer') : koishiCtx?.puppeteer;
529
- /** kernel 渲染服务(koishi-plugin-shotkit) */
530
- const shotkit = typeof koishiCtx?.get === 'function' ? koishiCtx.get('shotkit') : koishiCtx?.shotkit;
531
- if (!puppeteer && !shotkit) {
532
- throw new Error('未安装 koishi-plugin-puppeteer(或 koishi-plugin-shotkit),无法渲染图片');
498
+ if (!puppeteer) {
499
+ throw new Error('没有可用的渲染器:需要安装浏览器渲染服务(koishi-plugin-puppeteer 或同类插件)才能渲染图片');
533
500
  }
534
- /**
535
- * 优先渲染器(面板「通用设置 → 优先渲染器」,配置项 `app.renderer`,默认 shotkit)。
536
- *
537
- * 谁排前面谁先上,失败或服务没装就自动落到另一个;两个都没有才报错。
538
- * 默认 shotkit 是因为它快得多也省内存;要完整还原卡片外观(尤其是 https 远程封面/头像)
539
- * 就把这个开关改成 puppeteer。
540
- */
541
- const preferShotkit = String(Config_1.Config.app?.renderer ?? 'shotkit').toLowerCase() !== 'puppeteer';
542
501
  // 卡片是给手机看的:**低于 2x 会明显发虚**,所以下限锁 2(上限 3)。
543
502
  // 想更大更清晰就调 `app.renderScale`(100 → 2x,150 → 3x),配 100 时保持 2x 不出错。
544
503
  const requested = Number.isFinite(scale) && scale > 0 ? scale * 2 : 2;
@@ -550,59 +509,6 @@ async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg
550
509
  deviceScaleFactor = Math.max(1, deviceScaleFactor - 1);
551
510
  }
552
511
  catch { /* 忽略 */ }
553
- /**
554
- * shotkit 内核路径。
555
- *
556
- * 内核一次调用出一张图、直接按 `selector` 截元素盒子,不需要 evaluate 量尺寸,
557
- * 也不执行页面 JS。上面选了 shotkit 就先用它;选了 puppeteer 而没装浏览器时也走这里兜底。
558
- *
559
- * 注意:实测这个预编译内核在 Windows 上**加载不了 https 资源**(http / data: / file: 正常),
560
- * 卡片里的远程封面、头像会缺图 —— 面板上的说明里写了,要完整外观请把优先渲染器改成 puppeteer。
561
- *
562
- * 格式固定 PNG:内核不支持 JPEG(支持 webp,要压体积可以把 type 改成 'webp'
563
- * 并把下面的 mime 一起改掉)。
564
- */
565
- let shotkitFailure = null;
566
- if (shotkit && typeof shotkit.renderFile === 'function' && (preferShotkit || !puppeteer)) {
567
- try {
568
- const started = Date.now();
569
- /**
570
- * 交给内核的 HTML 先摘掉它加载不了的远程资源(见 neutralizeRemoteAssets 的说明)。
571
- * 走 `screenshot({ html })` 而不是 renderFile:磁盘上那份保持原样,
572
- * 万一内核失败要回退到 puppeteer,那边还能正常加载远程图片。
573
- */
574
- const sanitized = htmlSource ? neutralizeRemoteAssets(htmlSource) : null;
575
- if (sanitized && (sanitized.links > 0 || sanitized.images > 0)) {
576
- node_karin_1.logger.debug('[Render] 内核用 HTML:摘掉 preload ' + sanitized.links + ' 条、远程图片 ' + sanitized.images + ' 张');
577
- }
578
- const buffer = sanitized && typeof shotkit.screenshot === 'function'
579
- ? ((await shotkit.screenshot({
580
- html: sanitized.html,
581
- selector,
582
- timeout,
583
- deviceScaleFactor,
584
- type: 'png',
585
- }))?.image)
586
- : await shotkit.renderFile(htmlPath, {
587
- selector,
588
- timeout,
589
- deviceScaleFactor,
590
- type: 'png',
591
- });
592
- if (buffer && buffer.length) {
593
- if (!shotkitLogged) {
594
- shotkitLogged = true;
595
- node_karin_1.logger.info('[Render] 使用 shotkit 内核渲染卡片(静态内核,不执行页面 JS)');
596
- }
597
- node_karin_1.logger.debug('[Render] shotkit 渲染 ' + htmlPath.split(/[\\/]/).pop() + ' 用时 ' + (Date.now() - started) + 'ms');
598
- return { base64: Buffer.from(buffer).toString('base64'), mime: 'image/png', dsf: deviceScaleFactor };
599
- }
600
- }
601
- catch (error) {
602
- shotkitFailure = error;
603
- node_karin_1.logger.warn('[Render] shotkit 渲染失败,回退到 koishi-plugin-puppeteer:' + String(error?.message ?? error));
604
- }
605
- }
606
512
  /**
607
513
  * 视口用卡片的**设计宽度**(模板都按 1440 排版),高度按内容量一次。
608
514
  *
@@ -711,12 +617,6 @@ async function screenshot(htmlPath, selector, timeout, scale = 1, format = 'jpeg
711
617
  catch { /* 页面可能已被插件关掉,忽略 */ }
712
618
  }
713
619
  }
714
- if (!puppeteer) {
715
- // 走到这里说明:优先的那条路失败了,而且另一条也没得用
716
- const reason = String(shotkitFailure?.message ?? shotkitFailure ?? '');
717
- throw new Error('没有可用的渲染器:未安装 koishi-plugin-puppeteer(或它的 start 失败)'
718
- + (reason ? ';shotkit 内核渲染失败:' + reason : ''));
719
- }
720
620
  node_karin_1.logger.debug('[Render] 插件未暴露 page/browser,退回 puppeteer.render()');
721
621
  return await puppeteer.render('', async (page) => capture(page));
722
622
  }
@@ -864,9 +764,9 @@ const Render = async (_event, route, data) => {
864
764
  // 二维码:必须像素级清晰(JPEG 压缩会影响扫码),继续用 PNG;其它卡片用 JPEG 压体积
865
765
  const isQrCode = /qrcode/i.test(route);
866
766
  const format = isQrCode ? 'png' : 'jpeg';
867
- // mime 由 screenshot 返回实际产出的格式:shotkit 走 PNG,其余路径仍是传入的 format
767
+ // mime 由 screenshot 返回实际产出的格式(就是上面选的那个 format)
868
768
  const htmlBuiltAt = Date.now();
869
- const { base64, mime, dsf } = await screenshot(htmlPath, '#container', Number(Config_1.Config.app?.RenderWaitTime ?? 10) * 1000, scale, format, html);
769
+ const { base64, mime, dsf } = await screenshot(htmlPath, '#container', Number(Config_1.Config.app?.RenderWaitTime ?? 10) * 1000, scale, format);
870
770
  const shotAt = Date.now();
871
771
  const buffer = Buffer.from(base64, 'base64');
872
772
  const meta = (0, exports.getImageMetadata)(buffer);
@@ -10,41 +10,86 @@ const node_karin_1 = require("../../../compat/node-karin.js");
10
10
  const root_1 = require("../../root");
11
11
  let cachedMetadata = null;
12
12
  /**
13
- * 获取构建元数据
14
- * @returns 构建元数据对象,如果读取失败则返回 null
13
+ * 构建元数据的候选位置。
14
+ *
15
+ * 正常情况是 scripts/build.mjs 生成的 lib/build-metadata.json(随 lib 一起部署、也打进 npm 包);
16
+ * 早期版本把文件放在插件根目录,所以两个位置都看一眼。
15
17
  */
16
- const getBuildMetadata = () => {
17
- if (cachedMetadata) {
18
- return cachedMetadata;
19
- }
20
- try {
21
- // 使用插件路径 + lib/build-metadata.json
22
- // Root.pluginPath 指向插件根目录,构建后的元数据在 lib 目录下
23
- const metadataPath = (0, node_path_1.resolve)(root_1.Root.pluginPath, 'lib/build-metadata.json');
24
- if (node_fs_1.default.existsSync(metadataPath)) {
25
- const content = node_fs_1.default.readFileSync(metadataPath, 'utf-8');
26
- cachedMetadata = JSON.parse(content);
27
- return cachedMetadata;
18
+ const candidatePaths = () => [
19
+ (0, node_path_1.resolve)(root_1.Root.pluginPath, 'lib/build-metadata.json'),
20
+ (0, node_path_1.resolve)(root_1.Root.pluginPath, 'build-metadata.json')
21
+ ];
22
+ /** 读磁盘上的构建元数据;文件不存在返回 null,文件坏了只记一条日志 */
23
+ const readFromDisk = () => {
24
+ for (const file of candidatePaths()) {
25
+ try {
26
+ if (!node_fs_1.default.existsSync(file))
27
+ continue;
28
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(file, 'utf-8'));
29
+ if (!parsed || typeof parsed !== 'object')
30
+ continue;
31
+ return {
32
+ version: String(parsed.version ?? ''),
33
+ buildTime: String(parsed.buildTime ?? ''),
34
+ buildTimestamp: Number(parsed.buildTimestamp ?? 0) || 0,
35
+ name: String(parsed.name ?? ''),
36
+ description: String(parsed.description ?? ''),
37
+ homepage: String(parsed.homepage ?? ''),
38
+ commitHash: String(parsed.commitHash ?? ''),
39
+ shortCommitHash: String(parsed.shortCommitHash ?? '')
40
+ };
41
+ }
42
+ catch (error) {
43
+ node_karin_1.logger.error('构建元数据解析失败(' + file + '):', error);
28
44
  }
29
- }
30
- catch (error) {
31
- node_karin_1.logger.error('无法读取构建元数据:', error);
32
45
  }
33
46
  return null;
34
47
  };
48
+ /**
49
+ * 兜底元数据:老安装包里没有 build-metadata.json,就用 package.json 拼一份。
50
+ *
51
+ * 版本号一定是准的,构建时间和 commit 留空 —— 卡片上显示「未知」也比整块信息消失要好,
52
+ * 而且旧版本提示里的版本比较还需要 version 字段。
53
+ */
54
+ const fromPackage = () => {
55
+ const pkg = root_1.Root.pkg ?? {};
56
+ return {
57
+ version: String(root_1.Root.pluginVersion ?? pkg.version ?? ''),
58
+ buildTime: '',
59
+ buildTimestamp: 0,
60
+ name: String(root_1.Root.pluginName ?? pkg.name ?? ''),
61
+ description: String(pkg.description ?? ''),
62
+ homepage: String(pkg.homepage ?? ''),
63
+ commitHash: '',
64
+ shortCommitHash: ''
65
+ };
66
+ };
67
+ /**
68
+ * 获取构建元数据。
69
+ *
70
+ * 一定返回对象(读不到构建产物时退回 package.json),调用方不需要再判空。
71
+ */
72
+ const getBuildMetadata = () => {
73
+ if (cachedMetadata)
74
+ return cachedMetadata;
75
+ cachedMetadata = readFromDisk() ?? fromPackage();
76
+ return cachedMetadata;
77
+ };
35
78
  exports.getBuildMetadata = getBuildMetadata;
36
79
  /**
37
80
  * 格式化构建时间为可读格式
38
81
  * @param isoString ISO 格式的时间字符串
39
- * @returns 格式化后的时间字符串,格式:2025年11月02日 17:30
82
+ * @returns 格式化后的时间字符串,格式:2025年11月02日 17:30;无法解析时返回空串
40
83
  */
41
84
  const formatBuildTime = (isoString) => {
42
85
  const date = new Date(isoString);
86
+ if (!isoString || Number.isNaN(date.getTime()))
87
+ return '';
43
88
  const year = date.getFullYear();
44
89
  const month = String(date.getMonth() + 1).padStart(2, '0');
45
90
  const day = String(date.getDate()).padStart(2, '0');
46
91
  const hour = String(date.getHours()).padStart(2, '0');
47
92
  const minute = String(date.getMinutes()).padStart(2, '0');
48
- return `${year}年${month}月${day}日 ${hour}:${minute}`;
93
+ return year + '年' + month + '月' + day + '日 ' + hour + ':' + minute;
49
94
  };
50
95
  exports.formatBuildTime = formatBuildTime;