koishi-plugin-share-links-analysis 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -112,7 +112,42 @@ exports.Config = koishi_1.Schema.intersect([
112
112
  userAgent: koishi_1.Schema.string().description("所有 API 请求所用的 User-Agent").default("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"),
113
113
  debug: koishi_1.Schema.boolean().default(false).description("开启调试模式 (输出详细日志)"),
114
114
  }).description("调试设置"),
115
+ koishi_1.Schema.object({
116
+ reportEnabled: koishi_1.Schema.boolean().default(false).description("开启性能数据上报"),
117
+ reportUrl: koishi_1.Schema.string().default("http://127.0.0.1:8080/").description("性能数据上报地址 (HTTP POST)"),
118
+ }).description("性能监控"),
115
119
  ]);
120
+ async function reportMetric(ctx, config, payload) {
121
+ if (!config.reportEnabled || !config.reportUrl)
122
+ return;
123
+ const data = {
124
+ app: "share_links_analysis", // 必填 app 标识
125
+ timestamp: Math.floor(Date.now() / 1000), // 可选时间戳
126
+ ...payload
127
+ };
128
+ // 异步发送,不阻塞主流程
129
+ ctx.http.post(config.reportUrl, data).catch(e => {
130
+ // 仅在调试模式下打印上报错误,避免刷屏
131
+ if (config.debug) {
132
+ ctx.logger('share-links-analysis').warn(`性能数据上报失败: ${e.message}`);
133
+ }
134
+ });
135
+ }
136
+ async function sendResult(ctx, session, config, result, logger) {
137
+ if (!session.channel) {
138
+ return await (0, utils_1.sendResult_plain)(ctx, session, config, result, logger);
139
+ }
140
+ switch (config.useForward) {
141
+ case "plain":
142
+ return await (0, utils_1.sendResult_plain)(ctx, session, config, result, logger);
143
+ case 'forward':
144
+ return await (0, utils_1.sendResult_forward)(ctx, session, config, result, logger, false);
145
+ case "mixed":
146
+ return await (0, utils_1.sendResult_forward)(ctx, session, config, result, logger, true);
147
+ default:
148
+ return { downloadTime: 0, sendTime: 0 };
149
+ }
150
+ }
116
151
  function apply(ctx, config) {
117
152
  // 数据库模型定义
118
153
  ctx.model.extend('sla_cookie_cache', {
@@ -237,7 +272,7 @@ function apply(ctx, config) {
237
272
  });
238
273
  // 清除缓存指令
239
274
  cmd.subcommand('.clean', '清除所有缓存和文件', { authority: 3 })
240
- .action(async ({ session }) => {
275
+ .action(async () => {
241
276
  await ctx.database.remove('sla_parse_cache', {});
242
277
  const allFiles = await ctx.database.get('sla_file_cache', {});
243
278
  for (const file of allFiles) {
@@ -246,7 +281,8 @@ function apply(ctx, config) {
246
281
  await fs.promises.unlink(file.path);
247
282
  }
248
283
  }
249
- catch { }
284
+ catch {
285
+ }
250
286
  }
251
287
  await ctx.database.remove('sla_file_cache', {});
252
288
  return '缓存及对应文件已清理。';
@@ -297,87 +333,145 @@ function apply(ctx, config) {
297
333
  if (config.waitTip_Switch) {
298
334
  await session.send(config.waitTip_Switch);
299
335
  }
336
+ // === 性能统计变量 ===
337
+ const startTotal = Date.now();
338
+ let parseTime = 0;
339
+ let downloadTime = 0;
340
+ let sendTime = 0;
341
+ let isCache = false;
342
+ let status = "success";
343
+ let errorMsg = "";
344
+ let errorStack = "";
300
345
  // === 缓存与并发控制逻辑 ===
301
346
  let result = null;
302
347
  const cacheKey = `${link.platform}:${link.id}`;
303
- // 1. 查持久化缓存 (DB)
304
- if (config.enableCache) {
305
- const cached = await ctx.database.get('sla_parse_cache', cacheKey);
306
- // 检查是否存在且未过期
307
- if (cached.length > 0) {
308
- const entry = cached[0];
309
- const isExpired = config.cacheExpiration > 0 && (Date.now() - entry.created_at > config.cacheExpiration * 60 * 60 * 1000);
310
- if (!isExpired) {
311
- logger.debug(`使用缓存解析结果: ${cacheKey}`);
312
- result = entry.data;
313
- }
314
- else {
315
- // 过期删除
316
- await ctx.database.remove('sla_parse_cache', { key: cacheKey });
348
+ try {
349
+ // 1. 查持久化缓存 (DB)
350
+ if (config.enableCache) {
351
+ const cached = await ctx.database.get('sla_parse_cache', cacheKey);
352
+ // 检查是否存在且未过期
353
+ if (cached.length > 0) {
354
+ const entry = cached[0];
355
+ const isExpired = config.cacheExpiration > 0 && (Date.now() - entry.created_at > config.cacheExpiration * 60 * 60 * 1000);
356
+ if (!isExpired) {
357
+ logger.debug(`使用缓存解析结果: ${cacheKey}`);
358
+ result = entry.data;
359
+ isCache = true;
360
+ }
361
+ else {
362
+ // 过期删除
363
+ await ctx.database.remove('sla_parse_cache', { key: cacheKey });
364
+ }
317
365
  }
318
366
  }
319
- }
320
- // 2. 查内存任务队列
321
- if (!result) {
322
- if (pendingChecks.has(cacheKey)) {
323
- logger.debug(`检测到正在进行的解析任务,正在等待合并结果: ${cacheKey}`);
324
- // 如果有相同的任务正在进行,直接等待它的结果
325
- result = await pendingChecks.get(cacheKey) || null;
326
- }
327
- else {
328
- // 如果没有,创建一个新的 Promise 任务
329
- const task = (async () => {
367
+ // 2. 查内存任务队列
368
+ if (!result) {
369
+ if (pendingChecks.has(cacheKey)) {
370
+ logger.debug(`检测到正在进行的解析任务,正在等待合并结果: ${cacheKey}`);
371
+ // 如果有相同的任务正在进行,直接等待它的结果
330
372
  try {
331
- const res = await (0, core_1.processLink)(ctx, config, link, session);
332
- // 解析成功且开启缓存,则写入 DB
333
- if (res && config.enableCache) {
334
- await ctx.database.upsert('sla_parse_cache', [{
335
- key: cacheKey,
336
- data: res,
337
- created_at: Date.now()
338
- }]);
339
- }
340
- return res;
373
+ result = await pendingChecks.get(cacheKey) || null;
341
374
  }
342
375
  catch (e) {
343
- logger.warn(`解析任务出错: ${e}`);
344
- return null;
376
+ // 如果等待的任务失败了,这里也会捕获到
377
+ throw e;
345
378
  }
346
- })();
347
- // 将任务存入 Map
348
- pendingChecks.set(cacheKey, task);
349
- try {
350
- result = await task;
351
379
  }
352
- finally {
353
- // 无论成功失败,任务结束后从 Map 中移除
354
- pendingChecks.delete(cacheKey);
380
+ else {
381
+ // 如果没有,创建一个新的 Promise 任务
382
+ const task = (async () => {
383
+ const t = Date.now();
384
+ try {
385
+ const res = await (0, core_1.processLink)(ctx, config, link, session);
386
+ // 解析成功且开启缓存,则写入 DB
387
+ if (res && config.enableCache) {
388
+ await ctx.database.upsert('sla_parse_cache', [{
389
+ key: cacheKey,
390
+ data: res,
391
+ created_at: Date.now()
392
+ }]);
393
+ }
394
+ return res;
395
+ }
396
+ catch (e) {
397
+ logger.warn(`解析任务出错: ${e}`);
398
+ throw e;
399
+ }
400
+ finally {
401
+ // 执行上报
402
+ // 无论成功失败,都在 finally 中上报数据
403
+ parseTime = Date.now() - t;
404
+ }
405
+ })();
406
+ // 将任务存入 Map
407
+ pendingChecks.set(cacheKey, task);
408
+ try {
409
+ result = await task;
410
+ }
411
+ finally {
412
+ // 无论成功失败,任务结束后从 Map 中移除
413
+ pendingChecks.delete(cacheKey);
414
+ }
355
415
  }
356
416
  }
417
+ // === 逻辑结束 ===
418
+ if (result) {
419
+ lastProcessedUrls[channelId][link.url] = Date.now();
420
+ const stats = await sendResult(ctx, session, config, result, logger);
421
+ downloadTime = stats.downloadTime;
422
+ sendTime = stats.sendTime;
423
+ }
424
+ else {
425
+ status = "failed";
426
+ errorMsg = "parser_returned_null";
427
+ // 即使没有抛错,如果返回 null,也可以视为一种“软失败”,记录一下
428
+ }
429
+ linkCount++;
357
430
  }
358
- // === 逻辑结束 ===
359
- if (result) {
360
- lastProcessedUrls[channelId][link.url] = Date.now();
361
- await sendResult(ctx, session, config, result, logger);
431
+ catch (e) {
432
+ status = "error";
433
+ errorMsg = e.message || String(e);
434
+ errorStack = e.stack || String(e);
435
+ logger.warn(`处理异常: ${e}`);
436
+ }
437
+ finally {
438
+ // 4. 上报数据
439
+ const totalTime = Date.now() - startTotal;
440
+ // 获取内存使用情况 (MB)
441
+ const memoryUsage = process.memoryUsage();
442
+ const rssMB = (memoryUsage.rss / 1024 / 1024).toFixed(2);
443
+ // 截取堆栈前 1000 个字符,防止数据包过大(根据您的后端数据库限制调整)
444
+ const truncatedStack = errorStack.length > 1000 ? errorStack.substring(0, 1000) + "..." : errorStack;
445
+ reportMetric(ctx, config, {
446
+ type: "link_process", // 业务类型
447
+ // Tags
448
+ platform: link.platform,
449
+ status: status,
450
+ is_cache: isCache.toString(),
451
+ using_local: config.usingLocal.toString(),
452
+ user_id: session.userId || "unknown",
453
+ // [新增] 上下文信息,帮助定位是哪个群/频道
454
+ guild_id: session.guildId || "private",
455
+ channel_id: session.channelId || "unknown",
456
+ // [新增] 核心复现数据:具体的 URL
457
+ // 注意:如果您的后端是时序数据库(InfluxDB等),URL作为Tag可能会导致基数爆炸(High Cardinality)。
458
+ // 但根据您的描述是存入 tags 列用于筛选,且为内网服务,通常问题不大。
459
+ target_url: link.url,
460
+ // [新增] 错误信息
461
+ error_msg: errorMsg,
462
+ // 建议将堆栈放入 tags (如果数据库支持长文本) 或者单独的 log 字段
463
+ // 这里放入 tags 供查阅
464
+ error_stack: status === 'error' ? truncatedStack : "",
465
+ // === Metrics (数值/指标) ===
466
+ time_total_ms: totalTime,
467
+ time_parse_ms: parseTime,
468
+ time_download_ms: downloadTime,
469
+ time_send_ms: sendTime,
470
+ // 系统负载指标
471
+ memory_rss_mb: parseFloat(rssMB), // 当前进程内存占用
472
+ concurrent_tasks: pendingChecks.size, // 当前正在进行的并发解析数
473
+ });
362
474
  }
363
- linkCount++;
364
475
  }
365
476
  });
366
477
  }
367
- async function sendResult(ctx, session, config, result, logger) {
368
- if (!session.channel) {
369
- await (0, utils_1.sendResult_plain)(ctx, session, config, result, logger);
370
- return;
371
- }
372
- switch (config.useForward) {
373
- case "plain":
374
- await (0, utils_1.sendResult_plain)(ctx, session, config, result, logger);
375
- return;
376
- case 'forward':
377
- await (0, utils_1.sendResult_forward)(ctx, session, config, result, logger, false);
378
- return;
379
- case "mixed":
380
- await (0, utils_1.sendResult_forward)(ctx, session, config, result, logger, true);
381
- return;
382
- }
383
- }
package/lib/types.d.ts CHANGED
@@ -43,6 +43,12 @@ export interface PluginConfig {
43
43
  localDownloadDir: string;
44
44
  userAgent: string;
45
45
  debug: boolean;
46
+ reportEnabled: boolean;
47
+ reportUrl: string;
48
+ }
49
+ export interface SendResultStats {
50
+ downloadTime: number;
51
+ sendTime: number;
46
52
  }
47
53
  export interface BilibiliVideoInfo {
48
54
  data: {
package/lib/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ParsedInfo, PluginConfig } from './types';
1
+ import { ParsedInfo, PluginConfig, SendResultStats } from './types';
2
2
  import { Context, Logger, Session } from "koishi";
3
3
  import { Agent as HttpAgent } from 'http';
4
4
  import { Agent as HttpsAgent } from 'https';
@@ -19,5 +19,5 @@ export declare function getEffectiveSettings(ctx: Context, guildId: string | und
19
19
  nsfw: boolean;
20
20
  }>;
21
21
  export declare function isUserAdmin(session: Session, userId: string): Promise<boolean>;
22
- export declare function sendResult_plain(ctx: Context, session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger): Promise<void>;
23
- export declare function sendResult_forward(ctx: Context, session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger, mixed_sending?: boolean): Promise<void>;
22
+ export declare function sendResult_plain(ctx: Context, session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger): Promise<SendResultStats>;
23
+ export declare function sendResult_forward(ctx: Context, session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger, mixed_sending?: boolean): Promise<SendResultStats>;
package/lib/utils.js CHANGED
@@ -374,6 +374,8 @@ async function isUserAdmin(session, userId) {
374
374
  }
375
375
  async function sendResult_plain(ctx, session, config, result, logger) {
376
376
  logger.debug('进入普通发送');
377
+ let downloadTime = 0; // 下载耗时计时
378
+ let sendTime = 0; // 发送耗时计时
377
379
  const localDownloadDir = config.localDownloadDir;
378
380
  const onebotReadDir = config.onebotReadDir;
379
381
  let mediaCoverUrl = result.coverUrl;
@@ -386,6 +388,7 @@ async function sendResult_plain(ctx, session, config, result, logger) {
386
388
  // --- 下载封面 ---
387
389
  if (result.coverUrl) {
388
390
  if (config.usingLocal) {
391
+ const t = Date.now(); // 计时开始
389
392
  try {
390
393
  mediaCoverUrl = await downloadAndMapUrl(ctx, result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
391
394
  logger.debug(`封面已下载: ${mediaCoverUrl}`);
@@ -394,13 +397,15 @@ async function sendResult_plain(ctx, session, config, result, logger) {
394
397
  logger.warn(`封面下载失败: ${result.coverUrl}`, e);
395
398
  mediaCoverUrl = result.coverUrl;
396
399
  }
400
+ downloadTime += Date.now() - t; // 累加耗时
397
401
  }
398
402
  else {
399
403
  mediaCoverUrl = result.coverUrl;
400
404
  }
401
405
  }
402
406
  // --- 下载 mainbody 中的图片 ---
403
- if (result.mainbody) {
407
+ if (result.mainbody && config.usingLocal) {
408
+ const t = Date.now(); // 计时开始
404
409
  const imgMatches = [...result.mainbody.matchAll(/<img\s[^>]*src\s*=\s*["']?([^"'>\s]+)["']?/gi)];
405
410
  const urlMap = {};
406
411
  await Promise.all(imgMatches.map(async (match) => {
@@ -419,6 +424,7 @@ async function sendResult_plain(ctx, session, config, result, logger) {
419
424
  urlMap[remoteUrl] = remoteUrl;
420
425
  }
421
426
  }));
427
+ downloadTime += Date.now() - t; // 累加耗时
422
428
  mediaMainbody = result.mainbody;
423
429
  for (const [remote, local] of Object.entries(urlMap)) {
424
430
  const escaped = remote.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -449,7 +455,9 @@ async function sendResult_plain(ctx, session, config, result, logger) {
449
455
  continue;
450
456
  let shouldSend = true;
451
457
  if (config.Max_size !== undefined) {
458
+ const t = Date.now();
452
459
  const sizeBytes = await getFileSize(remoteUrl, proxy, config.userAgent, logger);
460
+ downloadTime += Date.now() - t;
453
461
  const maxBytes = config.Max_size * 1024 * 1024;
454
462
  if (sizeBytes !== null && sizeBytes > maxBytes) {
455
463
  shouldSend = false;
@@ -457,13 +465,17 @@ async function sendResult_plain(ctx, session, config, result, logger) {
457
465
  const maxMB = config.Max_size.toFixed(2);
458
466
  sendPromises.push(session.send(`文件大小超限 (${sizeMB} MB > ${maxMB} MB)`));
459
467
  logger.info(`文件大小超限 (${sizeMB} MB > ${maxMB} MB),跳过: ${remoteUrl}`);
468
+ sendPromises.push(session.send(`文件大小超限...`));
460
469
  }
461
470
  }
462
471
  if (shouldSend) {
463
472
  try {
464
473
  let localUrl = remoteUrl;
465
- if (config.usingLocal)
474
+ if (config.usingLocal) {
475
+ const t = Date.now();
466
476
  localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
477
+ downloadTime += Date.now() - t;
478
+ }
467
479
  if (!localUrl)
468
480
  continue;
469
481
  let element = null;
@@ -492,10 +504,15 @@ async function sendResult_plain(ctx, session, config, result, logger) {
492
504
  }
493
505
  }
494
506
  }
507
+ const tSend = Date.now(); // 发送计时开始
495
508
  await Promise.all(sendPromises);
509
+ sendTime = Date.now() - tSend; // 计算发送耗时
510
+ return { downloadTime, sendTime }; // 返回统计
496
511
  }
497
512
  async function sendResult_forward(ctx, session, config, result, logger, mixed_sending = false) {
498
513
  logger.debug(mixed_sending ? '进入混合发送' : '进入合并发送');
514
+ let downloadTime = 0;
515
+ let sendTime = 0;
499
516
  const localDownloadDir = config.localDownloadDir;
500
517
  const onebotReadDir = config.onebotReadDir;
501
518
  let mediaCoverUrl = result.coverUrl;
@@ -508,6 +525,7 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
508
525
  // --- 封面 ---
509
526
  if (result.coverUrl) {
510
527
  if (config.usingLocal) {
528
+ const t = Date.now();
511
529
  try {
512
530
  mediaCoverUrl = await downloadAndMapUrl(ctx, result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
513
531
  }
@@ -515,6 +533,7 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
515
533
  logger.warn('封面下载失败', e);
516
534
  mediaCoverUrl = '';
517
535
  }
536
+ downloadTime += Date.now() - t;
518
537
  }
519
538
  else {
520
539
  mediaCoverUrl = result.coverUrl;
@@ -526,12 +545,14 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
526
545
  const urlMap = {};
527
546
  await Promise.all(imgUrls.map(async (url) => {
528
547
  if (config.usingLocal) {
548
+ const t = Date.now();
529
549
  try {
530
550
  urlMap[url] = await downloadAndMapUrl(ctx, url, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
531
551
  }
532
552
  catch (e) {
533
553
  logger.warn(`正文图片下载失败: ${url}`, e);
534
554
  }
555
+ downloadTime += Date.now() - t;
535
556
  }
536
557
  else {
537
558
  urlMap[url] = url;
@@ -600,7 +621,9 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
600
621
  continue;
601
622
  let shouldInclude = true;
602
623
  if (config.Max_size !== undefined) {
624
+ const t = Date.now();
603
625
  const sizeBytes = await getFileSize(remoteUrl, proxy, config.userAgent, logger);
626
+ downloadTime += Date.now() - t;
604
627
  const maxBytes = config.Max_size * 1024 * 1024;
605
628
  if (sizeBytes !== null && sizeBytes > maxBytes) {
606
629
  shouldInclude = false;
@@ -624,8 +647,11 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
624
647
  if (shouldInclude) {
625
648
  try {
626
649
  let localUrl = remoteUrl;
627
- if (config.usingLocal)
650
+ if (config.usingLocal) {
651
+ const t = Date.now();
628
652
  localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
653
+ downloadTime += Date.now() - t;
654
+ }
629
655
  if (!localUrl)
630
656
  continue;
631
657
  if (!mixed_sending) {
@@ -686,8 +712,9 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
686
712
  });
687
713
  }
688
714
  }
689
- if (forwardNodes.length === 0 && extraSendPromises.length === 0)
690
- return;
715
+ if (forwardNodes.length === 0 && extraSendPromises.length === 0) {
716
+ return { downloadTime, sendTime };
717
+ }
691
718
  logger.debug(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
692
719
  if (!(session.onebot && session.onebot._request))
693
720
  throw new Error("Onebot is not defined");
@@ -702,8 +729,14 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
702
729
  source: result.title || ''
703
730
  }));
704
731
  }
732
+ // 混合模式额外消息
705
733
  if (mixed_sending && extraSendPromises.length > 0) {
706
734
  promises.push(...extraSendPromises);
707
735
  }
708
- await Promise.all(promises);
736
+ if (promises.length > 0) {
737
+ const tSend = Date.now();
738
+ await Promise.all(promises);
739
+ sendTime = Date.now() - tSend;
740
+ }
741
+ return { downloadTime, sendTime };
709
742
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "koishi-plugin-share-links-analysis",
3
3
  "description": "自用插件",
4
4
  "license": "MIT",
5
- "version": "0.8.1",
5
+ "version": "0.8.2",
6
6
  "main": "lib/index.js",
7
7
  "typings": "lib/index.d.ts",
8
8
  "files": [