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 +2 -1
- package/assets/brand-logo.png +0 -0
- package/assets/github-qrcode.png +0 -0
- package/lib/index.js +208 -16
- package/package.json +2 -1
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
|
-
|
|
144
|
+
const parsedItems = items
|
|
112
145
|
.map(parseRssItem)
|
|
113
|
-
.filter((item) => !!item?.gid && !!item.title)
|
|
114
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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"
|
|
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
|
|
683
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
return
|
|
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.
|
|
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
|
],
|