koishi-plugin-share-links-analysis 0.14.0 → 0.15.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/lib/index.js CHANGED
@@ -90,7 +90,8 @@ exports.Config = koishi_1.Schema.intersect([
90
90
  ----------
91
91
  {mainbody}
92
92
  ----------
93
- {sourceUrl}`).description('图文/视频输出格式。<br/>可用占位符: `{title}`, `{cover}`, `{authorName}`, `{mainbody}`, `{stats}`, `{sourceUrl}`'),
93
+ {cache}
94
+ {sourceUrl}`).description('图文/视频输出格式。<br/>可用占位符: `{title}`, `{cover}`, `{authorName}`, `{mainbody}`, `{stats}`, `{cache}`, `{sourceUrl}`'),
94
95
  }).description("格式化模板"),
95
96
  koishi_1.Schema.object({
96
97
  parseLimit: koishi_1.Schema.number().default(3).description("单对话多链接解析上限"),
@@ -151,6 +152,258 @@ async function reportMetric(ctx, config, payload) {
151
152
  }
152
153
  });
153
154
  }
155
+ function getCombinedRetentionHours(config) {
156
+ const expirations = [config.cacheExpiration, config.optimisticExpiration];
157
+ if (expirations.some(value => value === 0))
158
+ return 0;
159
+ return Math.max(...expirations);
160
+ }
161
+ function getRetentionThreshold(hours) {
162
+ if (hours === 0)
163
+ return null;
164
+ return Date.now() - hours * 60 * 60 * 1000;
165
+ }
166
+ function normalizeForCompare(value) {
167
+ if (Array.isArray(value))
168
+ return value.map(normalizeForCompare);
169
+ if (value && typeof value === 'object') {
170
+ const output = {};
171
+ for (const key of Object.keys(value).sort()) {
172
+ if (key.startsWith('_'))
173
+ continue;
174
+ output[key] = normalizeForCompare(value[key]);
175
+ }
176
+ return output;
177
+ }
178
+ return value;
179
+ }
180
+ function stableStringify(value) {
181
+ return JSON.stringify(normalizeForCompare(value));
182
+ }
183
+ function isSameParsedInfo(a, b) {
184
+ return stableStringify(a) === stableStringify(b);
185
+ }
186
+ function formatDiffValue(value) {
187
+ if (value === undefined)
188
+ return 'undefined';
189
+ if (value === null)
190
+ return 'null';
191
+ if (typeof value === 'string')
192
+ return value.length > 160 ? `${value.slice(0, 157)}...` : value;
193
+ const text = JSON.stringify(value);
194
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
195
+ }
196
+ function collectDiffLines(before, after, path = '') {
197
+ if (stableStringify(before) === stableStringify(after))
198
+ return [];
199
+ if (Array.isArray(before) || Array.isArray(after)) {
200
+ return [
201
+ `- ${path || 'root'}: ${formatDiffValue(before)}`,
202
+ `+ ${path || 'root'}: ${formatDiffValue(after)}`
203
+ ];
204
+ }
205
+ if (before && after &&
206
+ typeof before === 'object' &&
207
+ typeof after === 'object') {
208
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
209
+ const lines = [];
210
+ for (const key of [...keys].sort()) {
211
+ if (key.startsWith('_'))
212
+ continue;
213
+ const nextPath = path ? `${path}.${key}` : key;
214
+ lines.push(...collectDiffLines(before[key], after[key], nextPath));
215
+ }
216
+ return lines;
217
+ }
218
+ return [
219
+ `- ${path || 'root'}: ${formatDiffValue(before)}`,
220
+ `+ ${path || 'root'}: ${formatDiffValue(after)}`
221
+ ];
222
+ }
223
+ function buildParsedInfoDelta(previous, current) {
224
+ if (!previous)
225
+ return 'base';
226
+ const lines = collectDiffLines(normalizeForCompare(previous), normalizeForCompare(current));
227
+ return lines.length > 0 ? lines.join('\n') : 'no changes';
228
+ }
229
+ function summarizeParsedInfo(data) {
230
+ const parts = [
231
+ `标题: ${data.title || '无标题'}`,
232
+ `作者: ${data.authorName || '未知'}`,
233
+ `文件: ${Array.isArray(data.files) ? data.files.length : 0}`,
234
+ ];
235
+ if (data.stats)
236
+ parts.push(`统计: ${data.stats}`);
237
+ return parts.join('\n');
238
+ }
239
+ function summarizeDelta(delta) {
240
+ const lines = delta.split('\n');
241
+ const text = lines.length > 8
242
+ ? `${lines.slice(0, 8).join('\n')}\n...`
243
+ : delta;
244
+ return text.length > 800 ? `${text.slice(0, 797)}...` : text;
245
+ }
246
+ function formatTime(timestamp) {
247
+ return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
248
+ }
249
+ function createTimelineNodeId(cacheKey, createdAt = Date.now()) {
250
+ const safeKey = cacheKey.replace(/[^a-zA-Z0-9_-]/g, '_');
251
+ return `${safeKey}:${createdAt}:${Math.random().toString(36).slice(2, 8)}`;
252
+ }
253
+ async function getTimelineNodes(ctx, cacheKey) {
254
+ const nodes = await ctx.database.get('sla_parse_timeline', { cache_key: cacheKey });
255
+ return nodes.sort((a, b) => a.created_at - b.created_at);
256
+ }
257
+ function isTimelineNodeActive(config, node) {
258
+ const threshold = getRetentionThreshold(getCombinedRetentionHours(config));
259
+ if (threshold === null)
260
+ return true;
261
+ return (node.last_checked_at || node.created_at) >= threshold;
262
+ }
263
+ async function getActiveTimelineNodes(ctx, config, cacheKey) {
264
+ const nodes = await getTimelineNodes(ctx, cacheKey);
265
+ return nodes.filter(node => isTimelineNodeActive(config, node));
266
+ }
267
+ async function touchTimelineNode(ctx, node, checkedAt = Date.now()) {
268
+ const touched = { ...node, last_checked_at: checkedAt };
269
+ await ctx.database.upsert('sla_parse_timeline', [touched]);
270
+ return touched;
271
+ }
272
+ async function createTimelineNodeFromCache(ctx, cacheKey, result, createdAt, delta = 'base') {
273
+ const node = {
274
+ node_id: createTimelineNodeId(cacheKey, createdAt),
275
+ cache_key: cacheKey,
276
+ data: result,
277
+ delta,
278
+ created_at: createdAt,
279
+ last_checked_at: createdAt,
280
+ };
281
+ await ctx.database.upsert('sla_parse_timeline', [node]);
282
+ return node;
283
+ }
284
+ async function upsertTimelineIfChanged(ctx, config, cacheKey, result) {
285
+ const nodes = await getActiveTimelineNodes(ctx, config, cacheKey);
286
+ const latest = nodes[nodes.length - 1];
287
+ if (latest && isSameParsedInfo(latest.data, result)) {
288
+ return touchTimelineNode(ctx, latest);
289
+ }
290
+ const now = Date.now();
291
+ const node = {
292
+ node_id: createTimelineNodeId(cacheKey, now),
293
+ cache_key: cacheKey,
294
+ data: result,
295
+ delta: buildParsedInfoDelta(latest?.data ?? null, result),
296
+ created_at: now,
297
+ last_checked_at: now,
298
+ };
299
+ await ctx.database.upsert('sla_parse_timeline', [node]);
300
+ return node;
301
+ }
302
+ async function removeExpiredTimelineNodes(ctx, threshold) {
303
+ if (threshold === null)
304
+ return;
305
+ await ctx.database.remove('sla_parse_timeline', {
306
+ last_checked_at: { $lt: threshold }
307
+ });
308
+ }
309
+ async function pruneExpiredTimelineNodes(ctx, config) {
310
+ await removeExpiredTimelineNodes(ctx, getRetentionThreshold(getCombinedRetentionHours(config)));
311
+ }
312
+ async function getLatestTimelineNode(ctx, config, cacheKey) {
313
+ const nodes = await getActiveTimelineNodes(ctx, config, cacheKey);
314
+ return nodes[nodes.length - 1] || null;
315
+ }
316
+ function registerLegacyParseCacheModel(ctx) {
317
+ ctx.model.extend('sla_parse_cache', {
318
+ key: 'string',
319
+ data: 'json',
320
+ created_at: 'double',
321
+ }, { primary: 'key' });
322
+ }
323
+ async function getLegacyParseCacheEntries(ctx) {
324
+ try {
325
+ registerLegacyParseCacheModel(ctx);
326
+ return await ctx.database.get('sla_parse_cache', {});
327
+ }
328
+ catch {
329
+ return [];
330
+ }
331
+ }
332
+ async function migrateLegacyParseCache(ctx) {
333
+ const legacyEntries = await getLegacyParseCacheEntries(ctx);
334
+ let migrated = 0;
335
+ let skipped = 0;
336
+ let invalid = 0;
337
+ for (const entry of legacyEntries) {
338
+ if (!entry?.key || !entry.data || !entry.created_at) {
339
+ invalid++;
340
+ continue;
341
+ }
342
+ const nodes = await getTimelineNodes(ctx, entry.key);
343
+ const sameNode = nodes.find(node => isSameParsedInfo(node.data, entry.data));
344
+ if (sameNode) {
345
+ await touchTimelineNode(ctx, sameNode, Math.max(sameNode.last_checked_at || 0, entry.created_at));
346
+ skipped++;
347
+ continue;
348
+ }
349
+ const latest = nodes[nodes.length - 1];
350
+ const delta = latest ? buildParsedInfoDelta(latest.data, entry.data) : 'base';
351
+ await createTimelineNodeFromCache(ctx, entry.key, entry.data, entry.created_at, delta);
352
+ migrated++;
353
+ }
354
+ let mismatched = 0;
355
+ for (const entry of legacyEntries) {
356
+ if (!entry?.key || !entry.data)
357
+ continue;
358
+ const nodes = await getTimelineNodes(ctx, entry.key);
359
+ if (!nodes.some(node => isSameParsedInfo(node.data, entry.data))) {
360
+ mismatched++;
361
+ }
362
+ }
363
+ return {
364
+ legacyParse: legacyEntries.length,
365
+ migrated,
366
+ skipped,
367
+ invalid,
368
+ mismatched,
369
+ };
370
+ }
371
+ async function dropLegacyParseCache(ctx) {
372
+ const messages = [];
373
+ try {
374
+ registerLegacyParseCacheModel(ctx);
375
+ await ctx.database.remove('sla_parse_cache', {});
376
+ messages.push('旧 sla_parse_cache 数据已清空。');
377
+ }
378
+ catch (e) {
379
+ messages.push(`清空旧 sla_parse_cache 失败: ${e.message || String(e)}`);
380
+ }
381
+ const database = ctx.database;
382
+ const candidates = [
383
+ { fn: database.drop, args: ['sla_parse_cache'] },
384
+ { fn: database.dropTable, args: ['sla_parse_cache'] },
385
+ { fn: database.dropTables, args: [['sla_parse_cache']] },
386
+ ];
387
+ let dropped = false;
388
+ for (const candidate of candidates) {
389
+ if (typeof candidate.fn !== 'function')
390
+ continue;
391
+ try {
392
+ const result = candidate.fn.apply(database, candidate.args);
393
+ if (result && typeof result.then === 'function')
394
+ await result;
395
+ messages.push('旧表 sla_parse_cache 已尝试删除。');
396
+ dropped = true;
397
+ break;
398
+ }
399
+ catch {
400
+ }
401
+ }
402
+ if (!dropped) {
403
+ messages.push('当前数据库适配器未暴露删除 sla_parse_cache 的表 API,请按需手动 drop。');
404
+ }
405
+ return messages.join('\n');
406
+ }
154
407
  function apply(ctx, config) {
155
408
  // 数据库模型定义
156
409
  ctx.model.extend('sla_cookie_cache', {
@@ -166,12 +419,6 @@ function apply(ctx, config) {
166
419
  }, {
167
420
  primary: 'guildId',
168
421
  });
169
- // 解析结果缓存
170
- ctx.model.extend('sla_parse_cache', {
171
- key: 'string', // platform + ':' + id
172
- data: 'json',
173
- created_at: 'double',
174
- }, { primary: 'key' });
175
422
  // 资源文件缓存 (hash)
176
423
  ctx.model.extend('sla_file_cache', {
177
424
  hash: 'string', // URL MD5
@@ -179,6 +426,15 @@ function apply(ctx, config) {
179
426
  url: 'string',
180
427
  created_at: 'double',
181
428
  }, { primary: 'hash' });
429
+ // 解析结果时间线
430
+ ctx.model.extend('sla_parse_timeline', {
431
+ node_id: 'string',
432
+ cache_key: 'string',
433
+ data: 'json',
434
+ delta: 'text',
435
+ created_at: 'double',
436
+ last_checked_at: 'double',
437
+ }, { primary: 'node_id' });
182
438
  const logger = ctx.logger('share-links-analysis');
183
439
  // 根据配置设置日志等级
184
440
  if (config.debug) {
@@ -189,32 +445,13 @@ function apply(ctx, config) {
189
445
  const cleanExpiredCache = async () => {
190
446
  if (!config.enableCache)
191
447
  return;
192
- const now = Date.now();
193
- let maxExpiration = 0;
194
- if (config.optimisticCache) {
195
- // 如果开启了乐观缓存,且 L1 或 L2 中有任何一个设为 0(永不过期),则直接跳过定时清理
196
- if (config.cacheExpiration === 0 || config.optimisticExpiration === 0) {
197
- return;
198
- }
199
- // 只有两者都不为 0 时,才取它们的最大值作为物理清理时间
200
- maxExpiration = Math.max(config.cacheExpiration, config.optimisticExpiration);
201
- }
202
- else {
203
- // 如果没开启乐观缓存,且 L1 设为 0(永不过期),直接跳过
204
- if (config.cacheExpiration === 0) {
205
- return;
206
- }
207
- maxExpiration = config.cacheExpiration;
208
- }
209
- const threshold = now - maxExpiration * 60 * 60 * 1000;
210
- // 清理解析缓存
211
- await ctx.database.remove('sla_parse_cache', {
212
- created_at: { $lt: threshold }
213
- });
448
+ const fileAndTimelineThreshold = getRetentionThreshold(getCombinedRetentionHours(config));
214
449
  // 清理文件缓存
215
- const expiredFiles = await ctx.database.get('sla_file_cache', {
216
- created_at: { $lt: threshold }
217
- });
450
+ const expiredFiles = fileAndTimelineThreshold === null
451
+ ? []
452
+ : await ctx.database.get('sla_file_cache', {
453
+ created_at: { $lt: fileAndTimelineThreshold }
454
+ });
218
455
  for (const file of expiredFiles) {
219
456
  try {
220
457
  if (fs.existsSync(file.path)) {
@@ -225,9 +462,12 @@ function apply(ctx, config) {
225
462
  logger.warn(`删除过期文件失败 ${file.path}: ${e}`);
226
463
  }
227
464
  }
228
- await ctx.database.remove('sla_file_cache', {
229
- created_at: { $lt: threshold }
230
- });
465
+ if (fileAndTimelineThreshold !== null) {
466
+ await ctx.database.remove('sla_file_cache', {
467
+ created_at: { $lt: fileAndTimelineThreshold }
468
+ });
469
+ await pruneExpiredTimelineNodes(ctx, config);
470
+ }
231
471
  if (expiredFiles.length > 0) {
232
472
  logger.info(`已自动清理 ${expiredFiles.length} 个过期文件。`);
233
473
  }
@@ -284,10 +524,27 @@ function apply(ctx, config) {
284
524
  await ctx.database.remove('sla_group_settings', { guildId: session.guildId });
285
525
  return '已重置为全局默认设置。';
286
526
  });
527
+ cmd.subcommand('.migratecache', '迁移旧解析缓存到新时间线并校验', { authority: 4 })
528
+ .action(async () => {
529
+ const result = await migrateLegacyParseCache(ctx);
530
+ const status = result.mismatched === 0 ? '校验通过' : `校验失败 ${result.mismatched} 条`;
531
+ return [
532
+ '旧解析缓存迁移完成。',
533
+ `旧解析缓存记录: ${result.legacyParse}`,
534
+ `新增节点: ${result.migrated}`,
535
+ `已存在/续期: ${result.skipped}`,
536
+ `无效记录: ${result.invalid}`,
537
+ status,
538
+ ].join('\n');
539
+ });
540
+ cmd.subcommand('.dropoldcache', '删除旧 sla_parse_cache 数据/表', { authority: 4 })
541
+ .action(async () => {
542
+ return await dropLegacyParseCache(ctx);
543
+ });
287
544
  // 清除缓存指令
288
545
  cmd.subcommand('.clean', '清除所有缓存和文件', { authority: 3 })
289
546
  .action(async () => {
290
- await ctx.database.remove('sla_parse_cache', {});
547
+ await ctx.database.remove('sla_parse_timeline', {});
291
548
  const allFiles = await ctx.database.get('sla_file_cache', {});
292
549
  for (const file of allFiles) {
293
550
  try {
@@ -301,8 +558,8 @@ function apply(ctx, config) {
301
558
  await ctx.database.remove('sla_file_cache', {});
302
559
  return '缓存及对应文件已清理。';
303
560
  });
304
- cmd.subcommand('.checkcache <url:string>', '查看缓存数据状态', { authority: 2 })
305
- .action(async ({ session }, url) => {
561
+ cmd.subcommand('.checkcache <url:string> [index:string]', '查看缓存时间线/读取指定节点', { authority: 1 })
562
+ .action(async ({ session }, url, index) => {
306
563
  if (!session)
307
564
  return '会话不可用。';
308
565
  if (!config.enableCache)
@@ -312,23 +569,43 @@ function apply(ctx, config) {
312
569
  return '未在该链接中识别到支持的内容。';
313
570
  const link = links[0];
314
571
  const cacheKey = `${link.platform}:${link.id}`;
315
- const cached = await ctx.database.get('sla_parse_cache', cacheKey);
316
- if (cached.length === 0)
317
- return `未找到缓存数据: ${cacheKey}`;
318
- const entry = cached[0];
319
- const ageMs = Date.now() - entry.created_at;
320
- const isExpiredL1 = config.cacheExpiration > 0 && (ageMs > config.cacheExpiration * 60 * 60 * 1000);
321
- const isExpiredL2 = config.optimisticExpiration > 0 && (ageMs > config.optimisticExpiration * 60 * 60 * 1000);
322
- if (isExpiredL2)
323
- return `缓存数据已完全过期: ${cacheKey}`;
324
- const cacheTimeStr = new Date(entry.created_at).toLocaleString('zh-CN', { hour12: false });
325
- const result = { ...entry.data };
326
- if (isExpiredL1 && config.optimisticCache) {
327
- result.mainbody = (result.mainbody || '') + `\n\n[📦 L2 缓存 | 缓存时间: ${cacheTimeStr}]`;
572
+ const nodes = await getTimelineNodes(ctx, cacheKey);
573
+ if (!index) {
574
+ if (nodes.length === 0)
575
+ return `未找到历史节点: ${cacheKey}`;
576
+ const latest = nodes[nodes.length - 1];
577
+ const lines = [
578
+ `历史节点: ${cacheKey}`,
579
+ summarizeParsedInfo(latest.data),
580
+ ''
581
+ ];
582
+ nodes.forEach((node, nodeIndex) => {
583
+ lines.push(`#${nodeIndex + 1} | ${formatTime(node.created_at)}`);
584
+ lines.push(summarizeDelta(node.delta));
585
+ });
586
+ return lines.join('\n');
587
+ }
588
+ let selectedIndex;
589
+ const normalizedIndex = index.trim().toLowerCase();
590
+ if (normalizedIndex === 'l' || normalizedIndex === 'latest') {
591
+ if (nodes.length === 0)
592
+ return `未找到历史节点: ${cacheKey}`;
593
+ selectedIndex = nodes.length;
328
594
  }
329
595
  else {
330
- result.mainbody = (result.mainbody || '') + `\n\n[📦 L1 缓存 | 缓存时间: ${cacheTimeStr}]`;
596
+ selectedIndex = Number.parseInt(normalizedIndex, 10);
597
+ if (!Number.isInteger(selectedIndex) || `${selectedIndex}` !== normalizedIndex || selectedIndex < 1) {
598
+ return '节点编号必须是从 1 开始的整数,或 l/latest。';
599
+ }
331
600
  }
601
+ const node = nodes[selectedIndex - 1];
602
+ if (!node)
603
+ return `节点不存在: #${selectedIndex}`;
604
+ const result = { ...node.data };
605
+ result._cache = {
606
+ ...result._cache,
607
+ parse: `Timeline #${selectedIndex} | 节点时间: ${formatTime(node.created_at)}`
608
+ };
332
609
  const sendStats = { downloadTime: 0, sendTime: 0, errors: [] };
333
610
  await (0, utils_1.sendResult)(ctx, session, config, result, logger, sendStats);
334
611
  return;
@@ -348,11 +625,8 @@ function apply(ctx, config) {
348
625
  return `解析失败: ${link.platform}/${link.type}:${link.id}`;
349
626
  const cacheKey = `${link.platform}:${link.id}`;
350
627
  if (config.enableCache) {
351
- await ctx.database.upsert('sla_parse_cache', [{
352
- key: cacheKey,
353
- data: result,
354
- created_at: Date.now()
355
- }]);
628
+ await upsertTimelineIfChanged(ctx, config, cacheKey, result);
629
+ result._cache = { ...result._cache, parse: `强制刷新 | 缓存时间: ${formatTime(Date.now())}` };
356
630
  }
357
631
  const sendStats = { downloadTime: 0, sendTime: 0, errors: [] };
358
632
  await (0, utils_1.sendResult)(ctx, session, config, result, logger, sendStats);
@@ -568,9 +842,12 @@ function apply(ctx, config) {
568
842
  }
569
843
  // === 性能统计变量 ===
570
844
  const startTotal = Date.now();
571
- const sendStats = { downloadTime: 0, sendTime: 0, errors: [] };
845
+ const sendStats = {
846
+ downloadTime: 0,
847
+ sendTime: 0,
848
+ errors: []
849
+ };
572
850
  let parseTime = 0;
573
- let isCache = false;
574
851
  let status = "success";
575
852
  let errorMsg = "";
576
853
  let errorStack = "";
@@ -580,31 +857,25 @@ function apply(ctx, config) {
580
857
  let optimisticData = null; // 用于暂存乐观缓存数据
581
858
  let optimisticTime = 0; // 记录乐观缓存的生成时间
582
859
  try {
583
- // 1. 查持久化缓存 (DB)
860
+ // 1. 查解析时间线的最新节点
584
861
  if (config.enableCache) {
585
- const cached = await ctx.database.get('sla_parse_cache', cacheKey);
586
- // 检查是否存在
587
- if (cached.length > 0) {
588
- const entry = cached[0];
589
- const ageMs = Date.now() - entry.created_at;
862
+ const latestNode = await getLatestTimelineNode(ctx, config, cacheKey);
863
+ if (latestNode) {
864
+ const checkedAt = latestNode.last_checked_at || latestNode.created_at;
865
+ const ageMs = Date.now() - checkedAt;
590
866
  // 分别判断是否超过 L1 和 乐观缓存 时效
591
867
  const isExpiredL1 = config.cacheExpiration > 0 && (ageMs > config.cacheExpiration * 60 * 60 * 1000);
592
868
  const isExpiredL2 = config.optimisticExpiration > 0 && (ageMs > config.optimisticExpiration * 60 * 60 * 1000);
593
869
  if (!isExpiredL1) {
594
870
  logger.debug(`使用 L1 缓存解析结果: ${cacheKey}`);
595
- result = { ...entry.data }; // 浅拷贝,避免修改污染原缓存对象
596
- const cacheTimeStr = new Date(entry.created_at).toLocaleString('zh-CN', { hour12: false });
597
- result.mainbody = (result.mainbody || '') + `\n\n[📦 正在使用 L1 缓存 | 缓存时间: ${cacheTimeStr}]`;
598
- isCache = true;
871
+ result = { ...latestNode.data }; // 浅拷贝,避免修改污染原缓存对象
872
+ const cacheTimeStr = formatTime(checkedAt);
873
+ result._cache = { ...result._cache, parse: `L1 命中 | 缓存时间: ${cacheTimeStr}` };
599
874
  }
600
875
  else if (config.optimisticCache && !isExpiredL2) {
601
876
  logger.debug(`L1 缓存已过期,暂存乐观缓存备用: ${cacheKey}`);
602
- optimisticData = { ...entry.data }; // 浅拷贝备用
603
- optimisticTime = entry.created_at;
604
- }
605
- else {
606
- // 彻底过期,删除
607
- await ctx.database.remove('sla_parse_cache', { key: cacheKey });
877
+ optimisticData = { ...latestNode.data }; // 浅拷贝备用
878
+ optimisticTime = checkedAt;
608
879
  }
609
880
  }
610
881
  }
@@ -617,7 +888,6 @@ function apply(ctx, config) {
617
888
  result = await pendingChecks.get(cacheKey) || null;
618
889
  // 若合并任务返回的结果带有乐观缓存标记
619
890
  if (result && result._isOptimisticFallback) {
620
- isCache = true;
621
891
  status = "optimistic_fallback";
622
892
  }
623
893
  }
@@ -632,10 +902,12 @@ function apply(ctx, config) {
632
902
  if (optimisticData) {
633
903
  logger.warn(`合并任务失败,触发乐观缓存回退: ${cacheKey} | Error: ${mergeErrDetail}`);
634
904
  result = optimisticData;
635
- const cacheTimeStr = new Date(optimisticTime).toLocaleString('zh-CN', { hour12: false });
636
- result.mainbody = (result.mainbody || '') + `\n\n[⚠️ 并发请求异常,回退 L2 乐观缓存 | 缓存时间: ${cacheTimeStr}]`;
905
+ const cacheTimeStr = formatTime(optimisticTime);
906
+ result._cache = {
907
+ ...result._cache,
908
+ parse: `L2 回退: 并发请求异常 | 缓存时间: ${cacheTimeStr}`
909
+ };
637
910
  result._isOptimisticFallback = true;
638
- isCache = true;
639
911
  status = "optimistic_fallback";
640
912
  }
641
913
  else {
@@ -653,8 +925,11 @@ function apply(ctx, config) {
653
925
  if (!res) {
654
926
  if (optimisticData) {
655
927
  logger.warn(`API 返回 null,触发乐观缓存回退: ${cacheKey}`);
656
- const cacheTimeStr = new Date(optimisticTime).toLocaleString('zh-CN', { hour12: false });
657
- optimisticData.mainbody = (optimisticData.mainbody || '') + `\n\n[⚠️ 解析数据为空,回退 L2 乐观缓存 | 缓存时间: ${cacheTimeStr}]`;
928
+ const cacheTimeStr = formatTime(optimisticTime);
929
+ optimisticData._cache = {
930
+ ...optimisticData._cache,
931
+ parse: `L2 回退: 解析数据为空 | 缓存时间: ${cacheTimeStr}`
932
+ };
658
933
  optimisticData._isOptimisticFallback = true;
659
934
  return optimisticData;
660
935
  }
@@ -662,11 +937,12 @@ function apply(ctx, config) {
662
937
  }
663
938
  // 解析成功且开启缓存,则写入 DB,刷新缓存时间戳
664
939
  if (config.enableCache) {
665
- await ctx.database.upsert('sla_parse_cache', [{
666
- key: cacheKey,
667
- data: res,
668
- created_at: Date.now()
669
- }]);
940
+ const cacheTime = Date.now();
941
+ await upsertTimelineIfChanged(ctx, config, cacheKey, res);
942
+ res._cache = {
943
+ ...res._cache,
944
+ parse: `未命中,已刷新 | 缓存时间: ${formatTime(cacheTime)}`
945
+ };
670
946
  }
671
947
  return res;
672
948
  }
@@ -681,8 +957,11 @@ function apply(ctx, config) {
681
957
  // 抛出异常(网络错误/封禁)时触发乐观回退
682
958
  if (optimisticData) {
683
959
  logger.warn(`解析抛出异常,触发乐观缓存回退: ${cacheKey} | Error: ${errDetail}`);
684
- const cacheTimeStr = new Date(optimisticTime).toLocaleString('zh-CN', { hour12: false });
685
- optimisticData.mainbody = (optimisticData.mainbody || '') + `\n\n[⚠️ 接口触发异常,回退 L2 乐观缓存 | 缓存时间: ${cacheTimeStr}]`;
960
+ const cacheTimeStr = formatTime(optimisticTime);
961
+ optimisticData._cache = {
962
+ ...optimisticData._cache,
963
+ parse: `L2 回退: 接口异常 | 缓存时间: ${cacheTimeStr}`
964
+ };
686
965
  optimisticData._isOptimisticFallback = true;
687
966
  return optimisticData;
688
967
  }
@@ -701,7 +980,6 @@ function apply(ctx, config) {
701
980
  result = await task;
702
981
  // 识别最终是否使用了乐观缓存回退
703
982
  if (result && result._isOptimisticFallback) {
704
- isCache = true;
705
983
  status = "optimistic_fallback";
706
984
  }
707
985
  }
@@ -770,7 +1048,7 @@ function apply(ctx, config) {
770
1048
  raw_content_len: session.content ? session.content.length : 0, // 查明是否因长文本混排导致误触
771
1049
  // === 3. 执行状态与缓存命中 ===
772
1050
  status: status, // success, failed, error, optimistic_fallback
773
- is_cache: isCache,
1051
+ is_cache: (sendStats.fileCacheHits || 0) > 0,
774
1052
  // === 4. 关键排障配置状态 ===
775
1053
  // 记录当时的运行配置,排查是否是特定模式下才报错(如本地下载+混合发送)
776
1054
  cfg_using_local: config.usingLocal,
package/lib/types.d.ts CHANGED
@@ -13,11 +13,16 @@ export interface ParsedInfo {
13
13
  files: FileInfo[];
14
14
  sourceUrl: string;
15
15
  stats: string;
16
+ _cache?: ParsedInfoCacheStatus;
16
17
  }
17
18
  export interface FileInfo {
18
19
  type: 'video' | 'audio' | 'generic';
19
20
  url: string;
20
21
  }
22
+ export interface ParsedInfoCacheStatus {
23
+ parse?: string;
24
+ file?: string;
25
+ }
21
26
  export interface PluginConfig {
22
27
  Video_ClarityPriority: '1' | '2';
23
28
  Max_size: number;
@@ -100,6 +105,7 @@ declare module 'koishi' {
100
105
  sla_parse_cache: SlaParseCache;
101
106
  sla_file_cache: SlaFileCache;
102
107
  sla_cookie_cache: SlaCookieCache;
108
+ sla_parse_timeline: SlaParseTimelineNode;
103
109
  sla_group_settings: SlaGroupSettings;
104
110
  }
105
111
  }
@@ -119,6 +125,14 @@ export interface SlaCookieCache {
119
125
  cookie: string;
120
126
  updated_at: number;
121
127
  }
128
+ export interface SlaParseTimelineNode {
129
+ node_id: string;
130
+ cache_key: string;
131
+ data: ParsedInfo;
132
+ delta: string;
133
+ created_at: number;
134
+ last_checked_at: number;
135
+ }
122
136
  export interface SlaGroupSettings {
123
137
  guildId: string;
124
138
  custom_parsers: Record<string, boolean>;
package/lib/utils.d.ts CHANGED
@@ -44,14 +44,20 @@ export declare function sendResult(ctx: Context, session: Session, config: Plugi
44
44
  downloadTime: number;
45
45
  sendTime: number;
46
46
  errors: string[];
47
+ fileCacheHits?: number;
48
+ fileCacheMisses?: number;
47
49
  }): Promise<void>;
48
50
  export declare function sendResult_plain(ctx: Context, session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger, statsRef: {
49
51
  downloadTime: number;
50
52
  sendTime: number;
51
53
  errors: string[];
54
+ fileCacheHits?: number;
55
+ fileCacheMisses?: number;
52
56
  }): Promise<void>;
53
57
  export declare function sendResult_forward(ctx: Context, session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger, mixed_sending: boolean | undefined, statsRef: {
54
58
  downloadTime: number;
55
59
  sendTime: number;
56
60
  errors: string[];
61
+ fileCacheHits?: number;
62
+ fileCacheMisses?: number;
57
63
  }): Promise<void>;
package/lib/utils.js CHANGED
@@ -385,7 +385,7 @@ function parseHtmlToSegments(html) {
385
385
  }
386
386
  return segments;
387
387
  }
388
- async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, onebotReadDir, logger, enableCache) {
388
+ async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, onebotReadDir, logger, enableCache, statsRef) {
389
389
  await fs.promises.mkdir(localDownloadDir, { recursive: true });
390
390
  // 1. 计算 Hash
391
391
  const hash = (0, crypto_1.createHash)('md5').update(url).digest('hex');
@@ -400,6 +400,14 @@ async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, o
400
400
  if (fs.existsSync(cachedPath)) {
401
401
  const filename = path_1.default.basename(cachedPath);
402
402
  const onebotPath = path_1.default.posix.join(onebotReadDir, filename);
403
+ await ctx.database.upsert('sla_file_cache', [{
404
+ hash,
405
+ path: cachedPath,
406
+ url,
407
+ created_at: Date.now()
408
+ }]);
409
+ if (statsRef)
410
+ statsRef.fileCacheHits = (statsRef.fileCacheHits || 0) + 1;
403
411
  logger.debug(`缓存命中: ${url} -> ${cachedPath}`);
404
412
  return `file://${onebotPath}`;
405
413
  }
@@ -427,6 +435,8 @@ async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, o
427
435
  url,
428
436
  created_at: Date.now()
429
437
  }]);
438
+ if (statsRef)
439
+ statsRef.fileCacheHits = (statsRef.fileCacheHits || 0) + 1;
430
440
  return fileUrl;
431
441
  }
432
442
  const attemptDownload = () => new Promise((resolve, reject) => {
@@ -443,7 +453,7 @@ async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, o
443
453
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
444
454
  req.destroy();
445
455
  logger.debug(`重定向: ${url} -> ${res.headers.location}`);
446
- resolve(downloadAndMapUrl(ctx, res.headers.location, proxy, userAgent, localDownloadDir, onebotReadDir, logger, enableCache));
456
+ resolve(downloadAndMapUrl(ctx, res.headers.location, proxy, userAgent, localDownloadDir, onebotReadDir, logger, enableCache, statsRef));
447
457
  return;
448
458
  }
449
459
  if (res.statusCode !== 200) {
@@ -479,6 +489,8 @@ async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, o
479
489
  logger.warn(`写入文件缓存数据库失败: ${dbErr}`);
480
490
  }
481
491
  }
492
+ if (statsRef)
493
+ statsRef.fileCacheMisses = (statsRef.fileCacheMisses || 0) + 1;
482
494
  resolve(fileUrl);
483
495
  })
484
496
  .catch((err) => {
@@ -653,6 +665,15 @@ async function isUserAdmin(session, userId) {
653
665
  }
654
666
  }
655
667
  async function sendResult(ctx, session, config, result, logger, statsRef) {
668
+ if (config.enableCache && config.usingLocal) {
669
+ const availability = await getLocalCacheAvailability(ctx, result);
670
+ if (availability.total > 0) {
671
+ result._cache = {
672
+ ...result._cache,
673
+ file: `命中 ${availability.hits}/${availability.total}`
674
+ };
675
+ }
676
+ }
656
677
  if (!session.channel) {
657
678
  await sendResult_plain(ctx, session, config, result, logger, statsRef);
658
679
  return;
@@ -669,6 +690,47 @@ async function sendResult(ctx, session, config, result, logger, statsRef) {
669
690
  break;
670
691
  }
671
692
  }
693
+ function collectResultRemoteUrls(result) {
694
+ const urls = new Set();
695
+ if (result.coverUrl)
696
+ urls.add(result.coverUrl);
697
+ if (result.mainbody) {
698
+ for (const match of result.mainbody.matchAll(/<img\s[^>]*src\s*=\s*["']?([^"'>\s]+)["']?/gi)) {
699
+ urls.add(match[1]);
700
+ }
701
+ }
702
+ if (Array.isArray(result.files)) {
703
+ for (const file of result.files) {
704
+ if (file?.url)
705
+ urls.add(file.url);
706
+ }
707
+ }
708
+ return [...urls].filter(url => /^https?:\/\//i.test(url));
709
+ }
710
+ async function getLocalCacheAvailability(ctx, result) {
711
+ const urls = collectResultRemoteUrls(result);
712
+ let hits = 0;
713
+ for (const url of urls) {
714
+ const hash = (0, crypto_1.createHash)('md5').update(url).digest('hex');
715
+ const cached = await ctx.database.get('sla_file_cache', hash);
716
+ if (cached.length > 0 && cached[0].path && fs.existsSync(cached[0].path)) {
717
+ hits++;
718
+ }
719
+ }
720
+ return { total: urls.length, hits };
721
+ }
722
+ function buildCachePlaceholder(result, statsRef) {
723
+ const lines = [];
724
+ if (result._cache?.parse)
725
+ lines.push(`解析缓存: ${result._cache.parse}`);
726
+ if (result._cache?.file) {
727
+ lines.push(`文件缓存: ${result._cache.file}`);
728
+ }
729
+ else if ((statsRef.fileCacheHits || 0) > 0 || (statsRef.fileCacheMisses || 0) > 0) {
730
+ lines.push(`文件缓存: 命中 ${statsRef.fileCacheHits || 0} / 新下载 ${statsRef.fileCacheMisses || 0}`);
731
+ }
732
+ return lines.join('\n');
733
+ }
672
734
  async function sendResult_plain(ctx, session, config, result, logger, statsRef) {
673
735
  logger.debug('进入普通发送');
674
736
  const localDownloadDir = config.localDownloadDir;
@@ -685,7 +747,7 @@ async function sendResult_plain(ctx, session, config, result, logger, statsRef)
685
747
  if (config.usingLocal) {
686
748
  const t = Date.now(); // 计时开始
687
749
  try {
688
- mediaCoverUrl = await downloadAndMapUrl(ctx, result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
750
+ mediaCoverUrl = await downloadAndMapUrl(ctx, result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache, statsRef);
689
751
  logger.debug(`封面已下载: ${mediaCoverUrl}`);
690
752
  }
691
753
  catch (e) {
@@ -708,7 +770,7 @@ async function sendResult_plain(ctx, session, config, result, logger, statsRef)
708
770
  const remoteUrl = match[1];
709
771
  if (config.usingLocal) {
710
772
  try {
711
- const localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
773
+ const localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache, statsRef);
712
774
  urlMap[remoteUrl] = localUrl;
713
775
  logger.debug(`正文图片已下载: ${localUrl}`);
714
776
  }
@@ -736,6 +798,7 @@ async function sendResult_plain(ctx, session, config, result, logger, statsRef)
736
798
  message = message.replace(/{sourceUrl}/g, escapeHtml(result.sourceUrl || ''));
737
799
  message = message.replace(/{cover}/g, mediaCoverUrl ? koishi_1.h.image(mediaCoverUrl).toString() : '');
738
800
  message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
801
+ message = message.replace(/{cache}/g, escapeHtml(buildCachePlaceholder(result, statsRef)));
739
802
  // 清理空行
740
803
  const cleanMessage = message.split('\n').filter(line => line.trim() !== '' || line.includes('<')).join('\n');
741
804
  logger.debug(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
@@ -783,7 +846,7 @@ async function sendResult_plain(ctx, session, config, result, logger, statsRef)
783
846
  let localUrl = remoteUrl;
784
847
  if (config.usingLocal) {
785
848
  const t = Date.now();
786
- localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
849
+ localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache, statsRef);
787
850
  statsRef.downloadTime += Date.now() - t;
788
851
  }
789
852
  if (!localUrl)
@@ -841,7 +904,7 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
841
904
  if (config.usingLocal) {
842
905
  const t = Date.now();
843
906
  try {
844
- mediaCoverUrl = await downloadAndMapUrl(ctx, result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
907
+ mediaCoverUrl = await downloadAndMapUrl(ctx, result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache, statsRef);
845
908
  }
846
909
  catch (e) {
847
910
  logger.warn('封面下载失败', e);
@@ -864,7 +927,7 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
864
927
  try {
865
928
  // 去重下载
866
929
  if (!urlMap[url]) {
867
- urlMap[url] = await downloadAndMapUrl(ctx, url, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
930
+ urlMap[url] = await downloadAndMapUrl(ctx, url, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache, statsRef);
868
931
  }
869
932
  }
870
933
  catch (e) {
@@ -888,6 +951,7 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
888
951
  message = message.replace(/{authorName}/g, result.authorName || '');
889
952
  message = message.replace(/{sourceUrl}/g, result.sourceUrl || '');
890
953
  message = message.replace(/{stats}/g, result.stats || '');
954
+ message = message.replace(/{cache}/g, buildCachePlaceholder(result, statsRef));
891
955
  const lines = message.split('\n').filter(line => line.trim() !== '');
892
956
  const mainSegments = [];
893
957
  for (let i = 0; i < lines.length; i++) {
@@ -983,7 +1047,7 @@ async function sendResult_forward(ctx, session, config, result, logger, mixed_se
983
1047
  let localUrl = remoteUrl;
984
1048
  if (config.usingLocal) {
985
1049
  const t = Date.now();
986
- localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
1050
+ localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache, statsRef);
987
1051
  statsRef.downloadTime += Date.now() - t;
988
1052
  }
989
1053
  if (!localUrl)
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.14.0",
5
+ "version": "0.15.0",
6
6
  "main": "lib/index.js",
7
7
  "typings": "lib/index.d.ts",
8
8
  "files": [