koishi-plugin-cs2-update-log 2.1.1 → 2.2.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/README.md CHANGED
@@ -14,13 +14,14 @@ Koishi QQ 机器人插件,用于轮询 CS2 官方 Steam 公告流,按 `gid`
14
14
  - 或正文包含 `[MAPS]`、`[GAMEPLAY]`、`[MISC]`、`[AUDIO]`、`[ITEMS]`、`[WORKSHOP]`、`[PREMIER]` 等更新分区
15
15
  - 支持纯文本推送或 Puppeteer 深色长图卡片推送。
16
16
  - 支持 OpenAI-compatible Chat Completions API 翻译。
17
+ - 内置短时运行时缓存:RSS 列表缓存 15 秒,新闻内容、AI 翻译结果、长图 PNG 和卡片静态资源每 5 分钟清理一次,减少连续测试/重复推送的开销。
17
18
  - 提供 `cs2log.check`、`cs2log.push` 与 `cs2log.test` 命令。
18
19
 
19
20
  ## 配置要点
20
21
 
21
22
  - `targets` 必须配置目标 QQ 群,其中 `channelId` 一般填写群号。
22
23
  - `picture` 开启后需要安装并启用 Puppeteer 服务插件,例如 `@koishijs/plugin-puppeteer`。
23
- - `trans` 开启后需要填写 `translateApiKey`;默认接口是 OpenAI Chat Completions 格式,可按你的服务商修改 `translateApiEndpoint` 和 `translateModel`。
24
+ - `trans` 开启后需要填写 `translateApiKey`;默认接口是 OpenAI Chat Completions 格式,可按你的服务商修改 `translateApiEndpoint` 和 `translateModel`。AI 翻译请求超时时间为 90 秒。
24
25
 
25
26
  ## 命令
26
27
 
Binary file
Binary file
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@ exports.apply = apply;
8
8
  const koishi_1 = require("koishi");
9
9
  const markdown_it_1 = __importDefault(require("markdown-it"));
10
10
  const fast_xml_parser_1 = require("fast-xml-parser");
11
+ const node_crypto_1 = require("node:crypto");
11
12
  const node_fs_1 = require("node:fs");
12
13
  const node_path_1 = __importDefault(require("node:path"));
13
14
  exports.name = 'cs2-update-log';
@@ -18,6 +19,12 @@ exports.inject = {
18
19
  const APP_ID = 730;
19
20
  const STEAM_RSS_URL = `https://store.steampowered.com/feeds/news/app/${APP_ID}/`;
20
21
  const STATE_LIMIT = 1000;
22
+ const RSS_LIST_CACHE_TTL_MS = 15 * 1000;
23
+ const RUNTIME_CACHE_CLEAR_INTERVAL_MS = 5 * 60 * 1000;
24
+ const TRANSLATE_TIMEOUT_MS = 90 * 1000;
25
+ const ASSET_DIR = node_path_1.default.resolve(__dirname, '..', 'assets');
26
+ const BRAND_LOGO_PATH = node_path_1.default.join(ASSET_DIR, 'brand-logo.png');
27
+ const GITHUB_QRCODE_PATH = node_path_1.default.join(ASSET_DIR, 'github-qrcode.png');
21
28
  const loggerName = 'cs2-update-log';
22
29
  exports.Config = koishi_1.Schema.object({
23
30
  interval: koishi_1.Schema.number().min(5).step(1).default(30).description('轮询间隔,单位:秒。'),
@@ -59,8 +66,25 @@ function apply(ctx, config) {
59
66
  const logger = ctx.logger(loggerName);
60
67
  const statePath = resolveStatePath(ctx, config.stateFile);
61
68
  const knownGids = new Set();
69
+ const cache = {
70
+ rssFetchedAt: 0,
71
+ rssItems: [],
72
+ newsByGid: new Map(),
73
+ translations: new Map(),
74
+ cardImages: new Map(),
75
+ assetDataUris: new Map(),
76
+ };
62
77
  let stateInitialized = false;
63
78
  let polling = false;
79
+ function clearRuntimeCache(reason) {
80
+ cache.rssFetchedAt = 0;
81
+ cache.rssItems = [];
82
+ cache.newsByGid.clear();
83
+ cache.translations.clear();
84
+ cache.cardImages.clear();
85
+ cache.assetDataUris.clear();
86
+ logger.info('已清理运行时缓存:%s', reason);
87
+ }
64
88
  async function loadState() {
65
89
  try {
66
90
  const raw = await node_fs_1.promises.readFile(statePath, 'utf8');
@@ -102,19 +126,38 @@ function apply(ctx, config) {
102
126
  }
103
127
  async function fetchNews() {
104
128
  try {
129
+ if (cache.rssItems.length && Date.now() - cache.rssFetchedAt < RSS_LIST_CACHE_TTL_MS) {
130
+ logger.debug('使用 RSS 短时缓存:items=%d', cache.rssItems.length);
131
+ return cache.rssItems.slice(0, config.count);
132
+ }
105
133
  const xml = await ctx.http.get(STEAM_RSS_URL, {
134
+ headers: {
135
+ Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
136
+ 'User-Agent': 'koishi-plugin-cs2-update-log/2.1',
137
+ },
106
138
  responseType: 'text',
139
+ timeout: 30000,
107
140
  });
108
141
  const parsed = rssParser.parse(xml);
109
142
  const rawItems = parsed.rss?.channel?.item;
110
143
  const items = Array.isArray(rawItems) ? rawItems : rawItems ? [rawItems] : [];
111
- return items
144
+ const parsedItems = items
112
145
  .map(parseRssItem)
113
- .filter((item) => !!item?.gid && !!item.title)
114
- .slice(0, config.count);
146
+ .filter((item) => !!item?.gid && !!item.title);
147
+ const cachedItems = parsedItems.map((item) => {
148
+ const cached = cache.newsByGid.get(item.gid);
149
+ if (cached && cached.title === item.title && cached.content === item.content && cached.url === item.url && cached.date === item.date) {
150
+ return cached;
151
+ }
152
+ cache.newsByGid.set(item.gid, item);
153
+ return item;
154
+ });
155
+ cache.rssFetchedAt = Date.now();
156
+ cache.rssItems = cachedItems;
157
+ return cachedItems.slice(0, config.count);
115
158
  }
116
159
  catch (error) {
117
- logger.error('拉取 Steam CS2 RSS 失败:%s', formatError(error));
160
+ logger.error('拉取 Steam CS2 RSS 失败:url=%s\n%s', STEAM_RSS_URL, formatError(error));
118
161
  return [];
119
162
  }
120
163
  }
@@ -194,7 +237,15 @@ function apply(ctx, config) {
194
237
  return buildTextMessage(news, title, bodyMarkdown, link);
195
238
  }
196
239
  try {
197
- const png = await renderCard(puppeteer, news, title, bodyMarkdown);
240
+ const cacheKey = hashCacheKey('card-image', news.item.gid, news.category, config.brandName, config.siteName, title, bodyMarkdown);
241
+ let png = cache.cardImages.get(cacheKey);
242
+ if (!png) {
243
+ png = await renderCard(puppeteer, news, title, bodyMarkdown);
244
+ cache.cardImages.set(cacheKey, png);
245
+ }
246
+ else {
247
+ logger.debug('使用长图缓存:gid=%s title=%s', news.item.gid, news.item.title);
248
+ }
198
249
  const image = koishi_1.h.image(png, 'image/png');
199
250
  return config.appendLink ? [image, '\n', link] : image;
200
251
  }
@@ -204,7 +255,8 @@ function apply(ctx, config) {
204
255
  }
205
256
  }
206
257
  async function renderCard(puppeteer, news, title, bodyMarkdown) {
207
- const html = buildCardHtml(news, title, bodyMarkdown, config);
258
+ const assets = await loadCardAssets();
259
+ const html = buildCardHtml(news, title, bodyMarkdown, config, assets);
208
260
  const page = await puppeteer.page();
209
261
  try {
210
262
  if (page.setViewport) {
@@ -237,6 +289,12 @@ function apply(ctx, config) {
237
289
  logger.warn('已开启 AI 翻译但未填写 translateApiKey,将推送原文。');
238
290
  return { title, markdown: bodyMarkdown };
239
291
  }
292
+ const cacheKey = hashCacheKey('translation', config.translateApiEndpoint, config.translateModel, config.translatePrompt, title, bodyMarkdown);
293
+ const cached = cache.translations.get(cacheKey);
294
+ if (cached) {
295
+ logger.debug('使用 AI 翻译缓存:title=%s', title);
296
+ return cached;
297
+ }
240
298
  try {
241
299
  const response = await ctx.http.post(config.translateApiEndpoint, {
242
300
  model: config.translateModel,
@@ -265,22 +323,49 @@ function apply(ctx, config) {
265
323
  Authorization: `Bearer ${config.translateApiKey}`,
266
324
  'Content-Type': 'application/json',
267
325
  },
268
- timeout: 60000,
326
+ timeout: TRANSLATE_TIMEOUT_MS,
269
327
  });
270
328
  const content = response?.choices?.[0]?.message?.content?.trim();
271
329
  if (!content)
272
330
  throw new Error('empty translation response');
273
331
  const parsed = parseJsonObject(content);
274
- return {
332
+ const translated = {
275
333
  title: parsed.title || title,
276
334
  markdown: parsed.markdown || bodyMarkdown,
277
335
  };
336
+ cache.translations.set(cacheKey, translated);
337
+ return translated;
278
338
  }
279
339
  catch (error) {
280
340
  logger.error('AI 翻译失败,将推送原文:%s', formatError(error));
281
341
  return { title, markdown: bodyMarkdown };
282
342
  }
283
343
  }
344
+ async function loadCardAssets() {
345
+ const [brandLogoDataUri, githubQrDataUri] = await Promise.all([
346
+ loadAssetDataUri('brand-logo', BRAND_LOGO_PATH, 'image/png'),
347
+ loadAssetDataUri('github-qrcode', GITHUB_QRCODE_PATH, 'image/png'),
348
+ ]);
349
+ return {
350
+ brandLogoDataUri,
351
+ githubQrDataUri,
352
+ };
353
+ }
354
+ async function loadAssetDataUri(cacheKey, filePath, mimeType) {
355
+ const cached = cache.assetDataUris.get(cacheKey);
356
+ if (cached)
357
+ return cached;
358
+ try {
359
+ const buffer = await node_fs_1.promises.readFile(filePath);
360
+ const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`;
361
+ cache.assetDataUris.set(cacheKey, dataUri);
362
+ return dataUri;
363
+ }
364
+ catch (error) {
365
+ logger.error('读取卡片资源失败:path=%s\n%s', filePath, formatError(error));
366
+ return '';
367
+ }
368
+ }
284
369
  ctx.command('cs2log.check', '查看最近 5 条 CS2 官方公告分类结果')
285
370
  .action(async () => {
286
371
  const items = await fetchNews();
@@ -327,6 +412,9 @@ function apply(ctx, config) {
327
412
  ctx.setInterval(() => {
328
413
  void pollAndPush('timer');
329
414
  }, Math.max(5, config.interval) * 1000);
415
+ ctx.setInterval(() => {
416
+ clearRuntimeCache('short cache interval');
417
+ }, RUNTIME_CACHE_CLEAR_INTERVAL_MS);
330
418
  }
331
419
  function classifyNews(item) {
332
420
  const body = decodeHtmlEntities(item.content || '');
@@ -417,6 +505,14 @@ function steamContentToMarkdown(input) {
417
505
  .replace(/\n{3,}/g, '\n\n')
418
506
  .trim();
419
507
  }
508
+ function hashCacheKey(...parts) {
509
+ const hash = (0, node_crypto_1.createHash)('sha256');
510
+ for (const part of parts) {
511
+ hash.update(part);
512
+ hash.update('\0');
513
+ }
514
+ return hash.digest('hex');
515
+ }
420
516
  function buildTextMessage(news, title, bodyMarkdown, link) {
421
517
  const categoryName = news.category === 'update' ? 'CS2 官方更新日志' : 'CS2 官方公告';
422
518
  const parts = [
@@ -433,7 +529,7 @@ function buildTextMessage(news, title, bodyMarkdown, link) {
433
529
  parts.push(`原文:${link}`);
434
530
  return parts.join('\n');
435
531
  }
436
- function buildCardHtml(news, title, bodyMarkdown, config) {
532
+ function buildCardHtml(news, title, bodyMarkdown, config, assets) {
437
533
  const categoryName = news.category === 'update' ? 'CS2 官方更新日志' : 'CS2 官方公告';
438
534
  const categoryTag = news.category === 'update' ? 'UPDATE LOG' : 'ANNOUNCEMENT';
439
535
  const rendered = markdown.render(bodyMarkdown);
@@ -497,6 +593,13 @@ body {
497
593
  border: 1px solid rgba(255, 255, 255, 0.14);
498
594
  box-shadow: inset 0 0 0 5px rgba(255, 255, 255, 0.03);
499
595
  font-size: 26px;
596
+ overflow: hidden;
597
+ }
598
+ .avatar img {
599
+ display: block;
600
+ width: 100%;
601
+ height: 100%;
602
+ object-fit: cover;
500
603
  }
501
604
  .brand-title {
502
605
  font-size: 21px;
@@ -637,6 +740,13 @@ body {
637
740
  font-weight: 900;
638
741
  color: #ffffff;
639
742
  }
743
+ .site-wrap {
744
+ display: flex;
745
+ align-items: center;
746
+ justify-content: flex-end;
747
+ gap: 14px;
748
+ min-width: 230px;
749
+ }
640
750
  .site {
641
751
  text-align: right;
642
752
  min-width: 160px;
@@ -651,13 +761,22 @@ body {
651
761
  color: #78b7ff;
652
762
  font-size: 14px;
653
763
  }
764
+ .qr-code {
765
+ width: 62px;
766
+ height: 62px;
767
+ flex: 0 0 auto;
768
+ padding: 5px;
769
+ border-radius: 8px;
770
+ background: rgba(255, 255, 255, 0.94);
771
+ border: 1px solid rgba(255, 255, 255, 0.22);
772
+ }
654
773
  </style>
655
774
  </head>
656
775
  <body>
657
776
  <article id="card">
658
777
  <header class="topbar">
659
778
  <div class="brand">
660
- <div class="avatar">CS</div>
779
+ <div class="avatar">${assets.brandLogoDataUri ? `<img src="${assets.brandLogoDataUri}" alt="">` : 'CS'}</div>
661
780
  <div>
662
781
  <div class="brand-title">${escapeHtml(config.brandName)}</div>
663
782
  <div class="brand-subtitle">CS2 更新日志工具</div>
@@ -678,9 +797,12 @@ body {
678
797
  <div class="published-label">PUBLISHED AT</div>
679
798
  <div class="published-time">${escapeHtml(publishedAt)}</div>
680
799
  </div>
681
- <div class="site">
682
- <div class="site-name">${escapeHtml(config.siteName)}</div>
683
- <div class="author">${escapeHtml(author)}</div>
800
+ <div class="site-wrap">
801
+ <div class="site">
802
+ <div class="site-name">${escapeHtml(config.siteName)}</div>
803
+ <div class="author">${escapeHtml(author)}</div>
804
+ </div>
805
+ ${assets.githubQrDataUri ? `<img class="qr-code" src="${assets.githubQrDataUri}" alt="">` : ''}
684
806
  </div>
685
807
  </footer>
686
808
  </article>
@@ -791,7 +913,77 @@ function isNodeError(error) {
791
913
  return !!error && typeof error === 'object' && 'code' in error;
792
914
  }
793
915
  function formatError(error) {
794
- if (error instanceof Error)
795
- return error.stack || error.message;
796
- return String(error);
916
+ const lines = [];
917
+ appendErrorDetails(lines, error);
918
+ return lines.join('\n');
919
+ }
920
+ function appendErrorDetails(lines, error, label = 'error', depth = 0, seen = new Set()) {
921
+ if (error == null || typeof error !== 'object') {
922
+ lines.push(`${label}: ${String(error)}`);
923
+ return;
924
+ }
925
+ if (seen.has(error)) {
926
+ lines.push(`${label}: [Circular]`);
927
+ return;
928
+ }
929
+ seen.add(error);
930
+ const record = error;
931
+ const name = typeof record.name === 'string' ? record.name : error instanceof Error ? error.name : 'Error';
932
+ const message = typeof record.message === 'string' ? record.message : String(error);
933
+ lines.push(`${label}: ${name}: ${message}`);
934
+ const details = collectErrorDetails(record);
935
+ if (details.length)
936
+ lines.push(`${label} details: ${details.join(', ')}`);
937
+ const stack = typeof record.stack === 'string' ? record.stack : '';
938
+ const stackLines = stack.split(/\r?\n/).slice(1, 7).map((line) => line.trim()).filter(Boolean);
939
+ if (stackLines.length)
940
+ lines.push(`${label} stack:\n ${stackLines.join('\n ')}`);
941
+ appendNestedObjectDetails(lines, record, 'request', label);
942
+ appendNestedObjectDetails(lines, record, 'response', label);
943
+ const cause = record.cause;
944
+ if (cause !== undefined && depth < 5) {
945
+ appendErrorDetails(lines, cause, `${label}.cause`, depth + 1, seen);
946
+ }
947
+ }
948
+ function collectErrorDetails(record) {
949
+ const keys = [
950
+ 'code',
951
+ 'errno',
952
+ 'type',
953
+ 'syscall',
954
+ 'hostname',
955
+ 'host',
956
+ 'address',
957
+ 'port',
958
+ 'method',
959
+ 'url',
960
+ 'status',
961
+ 'statusCode',
962
+ 'statusText',
963
+ ];
964
+ const details = [];
965
+ for (const key of keys) {
966
+ const value = record[key];
967
+ if (value == null)
968
+ continue;
969
+ const rendered = renderLogValue(value);
970
+ if (rendered)
971
+ details.push(`${key}=${rendered}`);
972
+ }
973
+ return details;
974
+ }
975
+ function appendNestedObjectDetails(lines, record, key, label) {
976
+ const value = record[key];
977
+ if (!value || typeof value !== 'object')
978
+ return;
979
+ const details = collectErrorDetails(value);
980
+ if (details.length)
981
+ lines.push(`${label}.${key} details: ${details.join(', ')}`);
982
+ }
983
+ function renderLogValue(value) {
984
+ if (typeof value === 'string')
985
+ return value;
986
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint')
987
+ return String(value);
988
+ return undefined;
797
989
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "koishi-plugin-cs2-update-log",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Koishi plugin for monitoring CS2 official Steam announcements and update logs.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
7
  "files": [
8
+ "assets",
8
9
  "lib",
9
10
  "README.md"
10
11
  ],