koishi-plugin-cs2-update-log 2.1.2 → 2.2.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/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,6 +126,10 @@ 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, {
106
134
  headers: {
107
135
  Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
@@ -113,10 +141,20 @@ function apply(ctx, config) {
113
141
  const parsed = rssParser.parse(xml);
114
142
  const rawItems = parsed.rss?.channel?.item;
115
143
  const items = Array.isArray(rawItems) ? rawItems : rawItems ? [rawItems] : [];
116
- return items
144
+ const parsedItems = items
117
145
  .map(parseRssItem)
118
- .filter((item) => !!item?.gid && !!item.title)
119
- .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);
120
158
  }
121
159
  catch (error) {
122
160
  logger.error('拉取 Steam CS2 RSS 失败:url=%s\n%s', STEAM_RSS_URL, formatError(error));
@@ -199,7 +237,15 @@ function apply(ctx, config) {
199
237
  return buildTextMessage(news, title, bodyMarkdown, link);
200
238
  }
201
239
  try {
202
- 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
+ }
203
249
  const image = koishi_1.h.image(png, 'image/png');
204
250
  return config.appendLink ? [image, '\n', link] : image;
205
251
  }
@@ -209,7 +255,8 @@ function apply(ctx, config) {
209
255
  }
210
256
  }
211
257
  async function renderCard(puppeteer, news, title, bodyMarkdown) {
212
- const html = buildCardHtml(news, title, bodyMarkdown, config);
258
+ const assets = await loadCardAssets();
259
+ const html = buildCardHtml(news, title, bodyMarkdown, config, assets);
213
260
  const page = await puppeteer.page();
214
261
  try {
215
262
  if (page.setViewport) {
@@ -242,6 +289,12 @@ function apply(ctx, config) {
242
289
  logger.warn('已开启 AI 翻译但未填写 translateApiKey,将推送原文。');
243
290
  return { title, markdown: bodyMarkdown };
244
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
+ }
245
298
  try {
246
299
  const response = await ctx.http.post(config.translateApiEndpoint, {
247
300
  model: config.translateModel,
@@ -270,22 +323,49 @@ function apply(ctx, config) {
270
323
  Authorization: `Bearer ${config.translateApiKey}`,
271
324
  'Content-Type': 'application/json',
272
325
  },
273
- timeout: 60000,
326
+ timeout: TRANSLATE_TIMEOUT_MS,
274
327
  });
275
328
  const content = response?.choices?.[0]?.message?.content?.trim();
276
329
  if (!content)
277
330
  throw new Error('empty translation response');
278
331
  const parsed = parseJsonObject(content);
279
- return {
332
+ const translated = {
280
333
  title: parsed.title || title,
281
334
  markdown: parsed.markdown || bodyMarkdown,
282
335
  };
336
+ cache.translations.set(cacheKey, translated);
337
+ return translated;
283
338
  }
284
339
  catch (error) {
285
340
  logger.error('AI 翻译失败,将推送原文:%s', formatError(error));
286
341
  return { title, markdown: bodyMarkdown };
287
342
  }
288
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
+ }
289
369
  ctx.command('cs2log.check', '查看最近 5 条 CS2 官方公告分类结果')
290
370
  .action(async () => {
291
371
  const items = await fetchNews();
@@ -332,6 +412,9 @@ function apply(ctx, config) {
332
412
  ctx.setInterval(() => {
333
413
  void pollAndPush('timer');
334
414
  }, Math.max(5, config.interval) * 1000);
415
+ ctx.setInterval(() => {
416
+ clearRuntimeCache('short cache interval');
417
+ }, RUNTIME_CACHE_CLEAR_INTERVAL_MS);
335
418
  }
336
419
  function classifyNews(item) {
337
420
  const body = decodeHtmlEntities(item.content || '');
@@ -422,6 +505,14 @@ function steamContentToMarkdown(input) {
422
505
  .replace(/\n{3,}/g, '\n\n')
423
506
  .trim();
424
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
+ }
425
516
  function buildTextMessage(news, title, bodyMarkdown, link) {
426
517
  const categoryName = news.category === 'update' ? 'CS2 官方更新日志' : 'CS2 官方公告';
427
518
  const parts = [
@@ -438,7 +529,7 @@ function buildTextMessage(news, title, bodyMarkdown, link) {
438
529
  parts.push(`原文:${link}`);
439
530
  return parts.join('\n');
440
531
  }
441
- function buildCardHtml(news, title, bodyMarkdown, config) {
532
+ function buildCardHtml(news, title, bodyMarkdown, config, assets) {
442
533
  const categoryName = news.category === 'update' ? 'CS2 官方更新日志' : 'CS2 官方公告';
443
534
  const categoryTag = news.category === 'update' ? 'UPDATE LOG' : 'ANNOUNCEMENT';
444
535
  const rendered = markdown.render(bodyMarkdown);
@@ -502,6 +593,13 @@ body {
502
593
  border: 1px solid rgba(255, 255, 255, 0.14);
503
594
  box-shadow: inset 0 0 0 5px rgba(255, 255, 255, 0.03);
504
595
  font-size: 26px;
596
+ overflow: hidden;
597
+ }
598
+ .avatar img {
599
+ display: block;
600
+ width: 100%;
601
+ height: 100%;
602
+ object-fit: cover;
505
603
  }
506
604
  .brand-title {
507
605
  font-size: 21px;
@@ -642,6 +740,13 @@ body {
642
740
  font-weight: 900;
643
741
  color: #ffffff;
644
742
  }
743
+ .site-wrap {
744
+ display: flex;
745
+ align-items: center;
746
+ justify-content: flex-end;
747
+ gap: 14px;
748
+ min-width: 230px;
749
+ }
645
750
  .site {
646
751
  text-align: right;
647
752
  min-width: 160px;
@@ -656,13 +761,22 @@ body {
656
761
  color: #78b7ff;
657
762
  font-size: 14px;
658
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
+ }
659
773
  </style>
660
774
  </head>
661
775
  <body>
662
776
  <article id="card">
663
777
  <header class="topbar">
664
778
  <div class="brand">
665
- <div class="avatar">CS</div>
779
+ <div class="avatar">${assets.brandLogoDataUri ? `<img src="${assets.brandLogoDataUri}" alt="">` : 'CS'}</div>
666
780
  <div>
667
781
  <div class="brand-title">${escapeHtml(config.brandName)}</div>
668
782
  <div class="brand-subtitle">CS2 更新日志工具</div>
@@ -683,9 +797,12 @@ body {
683
797
  <div class="published-label">PUBLISHED AT</div>
684
798
  <div class="published-time">${escapeHtml(publishedAt)}</div>
685
799
  </div>
686
- <div class="site">
687
- <div class="site-name">${escapeHtml(config.siteName)}</div>
688
- <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="">` : ''}
689
806
  </div>
690
807
  </footer>
691
808
  </article>
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "koishi-plugin-cs2-update-log",
3
- "version": "2.1.2",
3
+ "version": "2.2.2",
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
  ],
@@ -22,6 +23,11 @@
22
23
  ],
23
24
  "author": "BestBcz",
24
25
  "license": "MIT",
26
+ "homepage": "https://github.com/BestBcz/koishi-cs2-update-log",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/BestBcz/koishi-cs2-update-log.git"
30
+ },
25
31
  "peerDependencies": {
26
32
  "@koishijs/plugin-puppeteer": "^3.2.0",
27
33
  "koishi": "^4.18.0"