koishi-plugin-cs2-update-log 1.0.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 +28 -0
- package/lib/index.d.ts +31 -0
- package/lib/index.js +728 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# koishi-plugin-cs2-update-log
|
|
2
|
+
|
|
3
|
+
作者:BestBcz
|
|
4
|
+
|
|
5
|
+
Koishi QQ 机器人插件,用于轮询 CS2 官方 Steam 公告流,按 `gid` 判重后将官方更新日志或官方公告推送到指定群。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- 使用 Steam `ISteamNews/GetNewsForApp/v2` 拉取 AppID `730` 的官方公告流。
|
|
10
|
+
- 默认每 30 秒轮询一次。
|
|
11
|
+
- 首次启动默认只记录历史 `gid`,不推送历史内容。
|
|
12
|
+
- 自动分类:
|
|
13
|
+
- 标题包含 `Counter-Strike 2 Update` / `Release Notes`
|
|
14
|
+
- 或正文包含 `[MAPS]`、`[GAMEPLAY]`、`[MISC]`、`[AUDIO]`、`[ITEMS]`、`[WORKSHOP]`、`[PREMIER]` 等更新分区
|
|
15
|
+
- 支持纯文本推送或 Puppeteer 深色长图卡片推送。
|
|
16
|
+
- 支持 OpenAI-compatible Chat Completions API 翻译。
|
|
17
|
+
- 提供 `cs2log.check` 与 `cs2log.push` 命令。
|
|
18
|
+
|
|
19
|
+
## 配置要点
|
|
20
|
+
|
|
21
|
+
- `targets` 必须配置目标 QQ 群,其中 `channelId` 一般填写群号。
|
|
22
|
+
- `picture` 开启后需要安装并启用 Puppeteer 服务插件,例如 `@koishijs/plugin-puppeteer`。
|
|
23
|
+
- `trans` 开启后需要填写 `translateApiKey`;默认接口是 OpenAI Chat Completions 格式,可按你的服务商修改 `translateApiEndpoint` 和 `translateModel`。
|
|
24
|
+
|
|
25
|
+
## 命令
|
|
26
|
+
|
|
27
|
+
- `cs2log.check`:查看最近 5 条新闻的分类结果。
|
|
28
|
+
- `cs2log.push`:手动检查并推送新内容。
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
export declare const name = "cs2-update-log";
|
|
3
|
+
export declare const inject: {
|
|
4
|
+
required: string[];
|
|
5
|
+
optional: string[];
|
|
6
|
+
};
|
|
7
|
+
interface TargetConfig {
|
|
8
|
+
platform: string;
|
|
9
|
+
selfId?: string;
|
|
10
|
+
channelId: string;
|
|
11
|
+
guildId?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Config {
|
|
14
|
+
interval: number;
|
|
15
|
+
count: number;
|
|
16
|
+
stateFile: string;
|
|
17
|
+
pushOnFirstRun: boolean;
|
|
18
|
+
targets: TargetConfig[];
|
|
19
|
+
brandName: string;
|
|
20
|
+
siteName: string;
|
|
21
|
+
picture: boolean;
|
|
22
|
+
appendLink: boolean;
|
|
23
|
+
trans: boolean;
|
|
24
|
+
translateApiKey: string;
|
|
25
|
+
translateApiEndpoint: string;
|
|
26
|
+
translateModel: string;
|
|
27
|
+
translatePrompt: string;
|
|
28
|
+
}
|
|
29
|
+
export declare const Config: Schema<Config>;
|
|
30
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
31
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Config = exports.inject = exports.name = void 0;
|
|
7
|
+
exports.apply = apply;
|
|
8
|
+
const koishi_1 = require("koishi");
|
|
9
|
+
const markdown_it_1 = __importDefault(require("markdown-it"));
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
exports.name = 'cs2-update-log';
|
|
13
|
+
exports.inject = {
|
|
14
|
+
required: ['http'],
|
|
15
|
+
optional: ['puppeteer'],
|
|
16
|
+
};
|
|
17
|
+
const APP_ID = 730;
|
|
18
|
+
const FEED_NAME = 'steam_community_announcements';
|
|
19
|
+
const STEAM_NEWS_API = 'https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/';
|
|
20
|
+
const STATE_LIMIT = 1000;
|
|
21
|
+
const loggerName = 'cs2-update-log';
|
|
22
|
+
exports.Config = koishi_1.Schema.object({
|
|
23
|
+
interval: koishi_1.Schema.number().min(5).step(1).default(30).description('轮询间隔,单位:秒。'),
|
|
24
|
+
count: koishi_1.Schema.number().min(1).max(100).step(1).default(20).description('每次从 Steam 拉取的新闻数量。'),
|
|
25
|
+
stateFile: koishi_1.Schema.string().default('.koishi-cs2-update-log.json').description('本地 gid 判重文件路径。相对路径会基于 Koishi 启动目录解析。'),
|
|
26
|
+
pushOnFirstRun: koishi_1.Schema.boolean().default(false).description('首次启动时是否推送历史内容。默认关闭,避免刷屏。'),
|
|
27
|
+
targets: koishi_1.Schema.array(koishi_1.Schema.object({
|
|
28
|
+
platform: koishi_1.Schema.string().default('onebot').description('目标平台,例如 onebot。'),
|
|
29
|
+
selfId: koishi_1.Schema.string().description('机器人账号 ID。留空时使用该平台第一个可用机器人。'),
|
|
30
|
+
channelId: koishi_1.Schema.string().required().description('目标频道或 QQ 群号。'),
|
|
31
|
+
guildId: koishi_1.Schema.string().description('可选群组 ID。标准 bot.sendMessage 只使用 channelId,此字段用于记录配置语义。'),
|
|
32
|
+
})).default([]).description('推送目标列表。'),
|
|
33
|
+
brandName: koishi_1.Schema.string().default('FKBUFF').description('图片顶部品牌名。'),
|
|
34
|
+
siteName: koishi_1.Schema.string().default('CS2 官方公告').description('图片底部站点名。'),
|
|
35
|
+
picture: koishi_1.Schema.boolean().default(true).description('是否以 Puppeteer 截图长图形式推送。关闭后推送纯文本。'),
|
|
36
|
+
appendLink: koishi_1.Schema.boolean().default(true).description('图片或文本后是否附带 Steam 原文链接。'),
|
|
37
|
+
trans: koishi_1.Schema.boolean().default(false).description('是否启用 AI 翻译。关闭时推送 Steam 返回的原文。'),
|
|
38
|
+
translateApiKey: koishi_1.Schema.string().role('secret').description('AI 翻译 API Key。启用 trans 时必填。'),
|
|
39
|
+
translateApiEndpoint: koishi_1.Schema.string().default('https://api.openai.com/v1/chat/completions').description('OpenAI-compatible Chat Completions 接口地址。'),
|
|
40
|
+
translateModel: koishi_1.Schema.string().default('gpt-4o-mini').description('AI 翻译模型名。'),
|
|
41
|
+
translatePrompt: koishi_1.Schema.string().role('textarea').default('你是一个专业的游戏公告翻译助手。请将 CS2 Steam 官方公告翻译为简体中文,保留 Markdown 结构、更新分区、列表、粗体、行内代码和代码块,不要添加原文没有的解释。').description('AI 翻译系统提示词。'),
|
|
42
|
+
});
|
|
43
|
+
const markdown = new markdown_it_1.default({
|
|
44
|
+
html: false,
|
|
45
|
+
linkify: true,
|
|
46
|
+
breaks: true,
|
|
47
|
+
});
|
|
48
|
+
const updateTitlePattern = /\b(?:Counter-Strike 2 Update|Release Notes)\b/i;
|
|
49
|
+
const updateSectionPattern = /^\s*\[\s*(MAPS|GAMEPLAY|MISC|AUDIO|ITEMS|WORKSHOP|PREMIER|GRAPHICS|ANIMATION|UI|SOUND|INPUT|NETWORKING|MATCHMAKING)\s*\]\s*$/gim;
|
|
50
|
+
const updateSectionInlinePattern = /\[\s*(?:MAPS|GAMEPLAY|MISC|AUDIO|ITEMS|WORKSHOP|PREMIER|GRAPHICS|ANIMATION|UI|SOUND|INPUT|NETWORKING|MATCHMAKING)\s*\]/i;
|
|
51
|
+
function apply(ctx, config) {
|
|
52
|
+
const logger = ctx.logger(loggerName);
|
|
53
|
+
const statePath = resolveStatePath(ctx, config.stateFile);
|
|
54
|
+
const knownGids = new Set();
|
|
55
|
+
let stateInitialized = false;
|
|
56
|
+
let polling = false;
|
|
57
|
+
async function loadState() {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await node_fs_1.promises.readFile(statePath, 'utf8');
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
for (const gid of parsed.gids || []) {
|
|
62
|
+
if (gid)
|
|
63
|
+
knownGids.add(String(gid));
|
|
64
|
+
}
|
|
65
|
+
stateInitialized = !!parsed.initialized;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (isNodeError(error) && error.code === 'ENOENT')
|
|
69
|
+
return;
|
|
70
|
+
logger.warn('读取 state 文件失败,将按首次启动处理:%s', formatError(error));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function saveState(recentItems) {
|
|
74
|
+
try {
|
|
75
|
+
const recentGids = recentItems.map((item) => item.gid).filter(Boolean);
|
|
76
|
+
const recentSet = new Set(recentGids);
|
|
77
|
+
const gids = [
|
|
78
|
+
...recentGids,
|
|
79
|
+
...Array.from(knownGids).filter((gid) => !recentSet.has(gid)),
|
|
80
|
+
].slice(0, STATE_LIMIT);
|
|
81
|
+
await node_fs_1.promises.mkdir(node_path_1.default.dirname(statePath), { recursive: true });
|
|
82
|
+
await node_fs_1.promises.writeFile(statePath, `${JSON.stringify({
|
|
83
|
+
initialized: true,
|
|
84
|
+
gids,
|
|
85
|
+
updatedAt: new Date().toISOString(),
|
|
86
|
+
}, null, 2)}\n`, 'utf8');
|
|
87
|
+
knownGids.clear();
|
|
88
|
+
for (const gid of gids)
|
|
89
|
+
knownGids.add(gid);
|
|
90
|
+
stateInitialized = true;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
logger.error('写入 state 文件失败:%s', formatError(error));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function fetchNews() {
|
|
97
|
+
try {
|
|
98
|
+
const response = await ctx.http.get(STEAM_NEWS_API, {
|
|
99
|
+
params: {
|
|
100
|
+
appid: APP_ID,
|
|
101
|
+
count: config.count,
|
|
102
|
+
maxlength: 0,
|
|
103
|
+
format: 'json',
|
|
104
|
+
feeds: FEED_NAME,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const items = response?.appnews?.newsitems;
|
|
108
|
+
if (!Array.isArray(items))
|
|
109
|
+
return [];
|
|
110
|
+
return items.filter((item) => item?.gid && item?.title);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
logger.error('拉取 Steam CS2 新闻失败:%s', formatError(error));
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function pollAndPush(source) {
|
|
118
|
+
if (polling) {
|
|
119
|
+
logger.debug('已有轮询任务正在执行,跳过本次 %s。', source);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
polling = true;
|
|
123
|
+
try {
|
|
124
|
+
const items = await fetchNews();
|
|
125
|
+
if (!items.length)
|
|
126
|
+
return 0;
|
|
127
|
+
const firstRun = !stateInitialized && knownGids.size === 0;
|
|
128
|
+
if (firstRun && !config.pushOnFirstRun) {
|
|
129
|
+
for (const item of items)
|
|
130
|
+
knownGids.add(item.gid);
|
|
131
|
+
await saveState(items);
|
|
132
|
+
logger.info('首次启动已记录 %d 条历史新闻,不推送。', items.length);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
const freshItems = items
|
|
136
|
+
.filter((item) => !knownGids.has(item.gid))
|
|
137
|
+
.reverse();
|
|
138
|
+
let pushed = 0;
|
|
139
|
+
for (const item of freshItems) {
|
|
140
|
+
const classified = classifyNews(item);
|
|
141
|
+
await pushNews(classified);
|
|
142
|
+
knownGids.add(item.gid);
|
|
143
|
+
pushed += 1;
|
|
144
|
+
}
|
|
145
|
+
await saveState(items);
|
|
146
|
+
return pushed;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
logger.error('检查并推送 CS2 新闻失败:%s', formatError(error));
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
polling = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function pushNews(news) {
|
|
157
|
+
const link = getNewsLink(news.item);
|
|
158
|
+
const baseMarkdown = steamBbcodeToMarkdown(news.item.contents);
|
|
159
|
+
const translated = await maybeTranslate(news.item.title, baseMarkdown);
|
|
160
|
+
const displayTitle = translated.title || news.item.title;
|
|
161
|
+
const displayMarkdown = translated.markdown || baseMarkdown;
|
|
162
|
+
const content = config.picture
|
|
163
|
+
? await renderOrFallbackText(news, displayTitle, displayMarkdown, link)
|
|
164
|
+
: buildTextMessage(news, displayTitle, displayMarkdown, link);
|
|
165
|
+
if (!config.targets.length) {
|
|
166
|
+
logger.warn('未配置推送目标,已跳过:%s', news.item.title);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
for (const target of config.targets) {
|
|
170
|
+
try {
|
|
171
|
+
const bot = findTargetBot(ctx, target);
|
|
172
|
+
if (!bot) {
|
|
173
|
+
logger.warn('找不到推送机器人 platform=%s selfId=%s', target.platform, target.selfId || '*');
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
await bot.sendMessage(target.channelId, content);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
logger.error('推送到 channelId=%s 失败:%s', target.channelId, formatError(error));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function renderOrFallbackText(news, title, bodyMarkdown, link) {
|
|
184
|
+
const puppeteer = ctx.puppeteer;
|
|
185
|
+
if (!puppeteer?.page) {
|
|
186
|
+
logger.warn('已开启 picture,但未检测到 puppeteer 服务,将降级为纯文本推送。');
|
|
187
|
+
return buildTextMessage(news, title, bodyMarkdown, link);
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const png = await renderCard(puppeteer, news, title, bodyMarkdown);
|
|
191
|
+
const image = koishi_1.h.image(png, 'image/png');
|
|
192
|
+
return config.appendLink ? [image, '\n', link] : image;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
logger.error('生成公告长图失败,将降级为纯文本推送:%s', formatError(error));
|
|
196
|
+
return buildTextMessage(news, title, bodyMarkdown, link);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function renderCard(puppeteer, news, title, bodyMarkdown) {
|
|
200
|
+
const html = buildCardHtml(news, title, bodyMarkdown, config);
|
|
201
|
+
const page = await puppeteer.page();
|
|
202
|
+
try {
|
|
203
|
+
if (page.setViewport) {
|
|
204
|
+
await page.setViewport({
|
|
205
|
+
width: 780,
|
|
206
|
+
height: 1200,
|
|
207
|
+
deviceScaleFactor: 2,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
await page.setContent(html, {
|
|
211
|
+
waitUntil: 'networkidle0',
|
|
212
|
+
});
|
|
213
|
+
const card = await page.$('#card');
|
|
214
|
+
if (!card)
|
|
215
|
+
throw new Error('card element not found');
|
|
216
|
+
const buffer = await card.screenshot({
|
|
217
|
+
type: 'png',
|
|
218
|
+
omitBackground: true,
|
|
219
|
+
});
|
|
220
|
+
return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await page.close().catch(() => undefined);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function maybeTranslate(title, bodyMarkdown) {
|
|
227
|
+
if (!config.trans)
|
|
228
|
+
return { title, markdown: bodyMarkdown };
|
|
229
|
+
if (!config.translateApiKey) {
|
|
230
|
+
logger.warn('已开启 AI 翻译但未填写 translateApiKey,将推送原文。');
|
|
231
|
+
return { title, markdown: bodyMarkdown };
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const response = await ctx.http.post(config.translateApiEndpoint, {
|
|
235
|
+
model: config.translateModel,
|
|
236
|
+
messages: [
|
|
237
|
+
{
|
|
238
|
+
role: 'system',
|
|
239
|
+
content: config.translatePrompt,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
role: 'user',
|
|
243
|
+
content: [
|
|
244
|
+
'请输出严格 JSON,不要使用 Markdown 代码块。',
|
|
245
|
+
'JSON 结构为 {"title":"翻译后的标题","markdown":"翻译后的正文 Markdown"}。',
|
|
246
|
+
'保留原文的 Markdown 层级、列表、粗体、行内 code 和代码块。',
|
|
247
|
+
'',
|
|
248
|
+
`标题:${title}`,
|
|
249
|
+
'',
|
|
250
|
+
'正文 Markdown:',
|
|
251
|
+
bodyMarkdown,
|
|
252
|
+
].join('\n'),
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
temperature: 0.2,
|
|
256
|
+
}, {
|
|
257
|
+
headers: {
|
|
258
|
+
Authorization: `Bearer ${config.translateApiKey}`,
|
|
259
|
+
'Content-Type': 'application/json',
|
|
260
|
+
},
|
|
261
|
+
timeout: 60000,
|
|
262
|
+
});
|
|
263
|
+
const content = response?.choices?.[0]?.message?.content?.trim();
|
|
264
|
+
if (!content)
|
|
265
|
+
throw new Error('empty translation response');
|
|
266
|
+
const parsed = parseJsonObject(content);
|
|
267
|
+
return {
|
|
268
|
+
title: parsed.title || title,
|
|
269
|
+
markdown: parsed.markdown || bodyMarkdown,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
logger.error('AI 翻译失败,将推送原文:%s', formatError(error));
|
|
274
|
+
return { title, markdown: bodyMarkdown };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
ctx.command('cs2log.check', '查看最近 5 条 CS2 官方公告分类结果')
|
|
278
|
+
.action(async () => {
|
|
279
|
+
const items = await fetchNews();
|
|
280
|
+
if (!items.length)
|
|
281
|
+
return '没有拉取到 CS2 官方新闻。';
|
|
282
|
+
return items.slice(0, 5)
|
|
283
|
+
.map((item, index) => {
|
|
284
|
+
const category = classifyNews(item).category === 'update' ? '官方更新日志' : '官方公告';
|
|
285
|
+
return [
|
|
286
|
+
`${index + 1}. [${category}] ${item.title}`,
|
|
287
|
+
`发布时间:${formatDate(item.date)}`,
|
|
288
|
+
`gid:${item.gid}`,
|
|
289
|
+
`链接:${getNewsLink(item)}`,
|
|
290
|
+
].join('\n');
|
|
291
|
+
})
|
|
292
|
+
.join('\n\n');
|
|
293
|
+
});
|
|
294
|
+
ctx.command('cs2log.push', '手动检查并推送新的 CS2 官方公告')
|
|
295
|
+
.action(async () => {
|
|
296
|
+
const pushed = await pollAndPush('manual');
|
|
297
|
+
return pushed ? `已推送 ${pushed} 条新的 CS2 官方新闻。` : '没有发现新的 CS2 官方新闻。';
|
|
298
|
+
});
|
|
299
|
+
ctx.on('ready', () => {
|
|
300
|
+
void loadState()
|
|
301
|
+
.then(() => pollAndPush('startup'))
|
|
302
|
+
.catch((error) => {
|
|
303
|
+
logger.error('启动 CS2 新闻轮询失败:%s', formatError(error));
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
ctx.setInterval(() => {
|
|
307
|
+
void pollAndPush('timer');
|
|
308
|
+
}, Math.max(5, config.interval) * 1000);
|
|
309
|
+
}
|
|
310
|
+
function classifyNews(item) {
|
|
311
|
+
const body = decodeHtmlEntities(item.contents || '');
|
|
312
|
+
const isUpdate = updateTitlePattern.test(item.title)
|
|
313
|
+
|| updateSectionPattern.test(body)
|
|
314
|
+
|| updateSectionInlinePattern.test(body);
|
|
315
|
+
updateSectionPattern.lastIndex = 0;
|
|
316
|
+
return {
|
|
317
|
+
item,
|
|
318
|
+
category: isUpdate ? 'update' : 'announcement',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function steamBbcodeToMarkdown(input) {
|
|
322
|
+
let output = decodeHtmlEntities(input || '')
|
|
323
|
+
.replace(/\r\n?/g, '\n');
|
|
324
|
+
output = output
|
|
325
|
+
.replace(/\[h1\]([\s\S]*?)\[\/h1\]/gi, (_, text) => `\n# ${text.trim()}\n`)
|
|
326
|
+
.replace(/\[h2\]([\s\S]*?)\[\/h2\]/gi, (_, text) => `\n## ${text.trim()}\n`)
|
|
327
|
+
.replace(/\[h3\]([\s\S]*?)\[\/h3\]/gi, (_, text) => `\n### ${text.trim()}\n`)
|
|
328
|
+
.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, '**$1**')
|
|
329
|
+
.replace(/\[strong\]([\s\S]*?)\[\/strong\]/gi, '**$1**')
|
|
330
|
+
.replace(/\[i\]([\s\S]*?)\[\/i\]/gi, '*$1*')
|
|
331
|
+
.replace(/\[em\]([\s\S]*?)\[\/em\]/gi, '*$1*')
|
|
332
|
+
.replace(/\[u\]([\s\S]*?)\[\/u\]/gi, '$1')
|
|
333
|
+
.replace(/\[strike\]([\s\S]*?)\[\/strike\]/gi, '~~$1~~')
|
|
334
|
+
.replace(/\[code\]([\s\S]*?)\[\/code\]/gi, (_, code) => `\n\`\`\`\n${code.trim()}\n\`\`\`\n`)
|
|
335
|
+
.replace(/\[quote\]([\s\S]*?)\[\/quote\]/gi, (_, quote) => quote.split('\n').map((line) => `> ${line}`).join('\n'))
|
|
336
|
+
.replace(/\[url=([^\]]+)\]([\s\S]*?)\[\/url\]/gi, '[$2]($1)')
|
|
337
|
+
.replace(/\[url\]([\s\S]*?)\[\/url\]/gi, '<$1>')
|
|
338
|
+
.replace(/\[img\]([\s\S]*?)\[\/img\]/gi, '')
|
|
339
|
+
.replace(/\[previewyoutube=[^\]]+\]([\s\S]*?)\[\/previewyoutube\]/gi, '')
|
|
340
|
+
.replace(/\[list\]|\[\/list\]|\[olist\]|\[\/olist\]/gi, '\n')
|
|
341
|
+
.replace(/^\s*\[\*\]\s*/gim, '- ')
|
|
342
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
343
|
+
.replace(/<\/p>\s*<p>/gi, '\n\n')
|
|
344
|
+
.replace(/<\/?p>/gi, '\n');
|
|
345
|
+
output = output.replace(updateSectionPattern, (_, section) => `\n## [${String(section).toUpperCase()}]\n`);
|
|
346
|
+
updateSectionPattern.lastIndex = 0;
|
|
347
|
+
return output
|
|
348
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
349
|
+
.trim();
|
|
350
|
+
}
|
|
351
|
+
function buildTextMessage(news, title, bodyMarkdown, link) {
|
|
352
|
+
const categoryName = news.category === 'update' ? 'CS2 官方更新日志' : 'CS2 官方公告';
|
|
353
|
+
const parts = [
|
|
354
|
+
`【${categoryName}】`,
|
|
355
|
+
title,
|
|
356
|
+
'',
|
|
357
|
+
truncateText(stripMarkdown(bodyMarkdown), 1800),
|
|
358
|
+
'',
|
|
359
|
+
`发布时间:${formatDate(news.item.date)}`,
|
|
360
|
+
];
|
|
361
|
+
if (news.item.author)
|
|
362
|
+
parts.push(`作者:${news.item.author}`);
|
|
363
|
+
if (link)
|
|
364
|
+
parts.push(`原文:${link}`);
|
|
365
|
+
return parts.join('\n');
|
|
366
|
+
}
|
|
367
|
+
function buildCardHtml(news, title, bodyMarkdown, config) {
|
|
368
|
+
const categoryName = news.category === 'update' ? 'CS2 官方更新日志' : 'CS2 官方公告';
|
|
369
|
+
const categoryTag = news.category === 'update' ? 'UPDATE LOG' : 'ANNOUNCEMENT';
|
|
370
|
+
const rendered = markdown.render(bodyMarkdown);
|
|
371
|
+
const publishedAt = formatDate(news.item.date);
|
|
372
|
+
const author = news.item.author || 'Valve';
|
|
373
|
+
return `<!doctype html>
|
|
374
|
+
<html lang="zh-CN">
|
|
375
|
+
<head>
|
|
376
|
+
<meta charset="utf-8">
|
|
377
|
+
<style>
|
|
378
|
+
* {
|
|
379
|
+
box-sizing: border-box;
|
|
380
|
+
}
|
|
381
|
+
html,
|
|
382
|
+
body {
|
|
383
|
+
margin: 0;
|
|
384
|
+
padding: 0;
|
|
385
|
+
width: 780px;
|
|
386
|
+
min-height: 100%;
|
|
387
|
+
background: transparent;
|
|
388
|
+
font-family: Inter, "HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;
|
|
389
|
+
color: #f7f9ff;
|
|
390
|
+
}
|
|
391
|
+
body {
|
|
392
|
+
padding: 24px;
|
|
393
|
+
}
|
|
394
|
+
#card {
|
|
395
|
+
width: 732px;
|
|
396
|
+
overflow: hidden;
|
|
397
|
+
border-radius: 0;
|
|
398
|
+
background:
|
|
399
|
+
radial-gradient(circle at 88% 6%, rgba(58, 118, 255, 0.16), transparent 28%),
|
|
400
|
+
radial-gradient(circle at 88% 92%, rgba(132, 66, 210, 0.16), transparent 30%),
|
|
401
|
+
linear-gradient(180deg, #0a1224 0%, #070b15 54%, #070714 100%);
|
|
402
|
+
border: 1px solid rgba(149, 177, 255, 0.11);
|
|
403
|
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
|
404
|
+
padding: 44px 48px 42px;
|
|
405
|
+
}
|
|
406
|
+
.topbar {
|
|
407
|
+
display: flex;
|
|
408
|
+
align-items: center;
|
|
409
|
+
justify-content: space-between;
|
|
410
|
+
gap: 24px;
|
|
411
|
+
padding-bottom: 28px;
|
|
412
|
+
border-bottom: 1px solid rgba(170, 193, 255, 0.14);
|
|
413
|
+
}
|
|
414
|
+
.brand {
|
|
415
|
+
display: flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
gap: 14px;
|
|
418
|
+
min-width: 0;
|
|
419
|
+
}
|
|
420
|
+
.avatar {
|
|
421
|
+
display: grid;
|
|
422
|
+
place-items: center;
|
|
423
|
+
width: 54px;
|
|
424
|
+
height: 54px;
|
|
425
|
+
flex: 0 0 auto;
|
|
426
|
+
border-radius: 50%;
|
|
427
|
+
background: linear-gradient(145deg, #1f2b44, #0e1424);
|
|
428
|
+
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
429
|
+
box-shadow: inset 0 0 0 5px rgba(255, 255, 255, 0.03);
|
|
430
|
+
font-size: 26px;
|
|
431
|
+
}
|
|
432
|
+
.brand-title {
|
|
433
|
+
font-size: 21px;
|
|
434
|
+
font-weight: 800;
|
|
435
|
+
letter-spacing: 0;
|
|
436
|
+
line-height: 1.1;
|
|
437
|
+
}
|
|
438
|
+
.brand-subtitle,
|
|
439
|
+
.category-subtitle {
|
|
440
|
+
margin-top: 5px;
|
|
441
|
+
color: #8f9bb6;
|
|
442
|
+
font-size: 14px;
|
|
443
|
+
line-height: 1.35;
|
|
444
|
+
}
|
|
445
|
+
.category {
|
|
446
|
+
text-align: right;
|
|
447
|
+
flex: 0 0 auto;
|
|
448
|
+
}
|
|
449
|
+
.category-title {
|
|
450
|
+
color: #54a8ff;
|
|
451
|
+
font-size: 15px;
|
|
452
|
+
font-weight: 700;
|
|
453
|
+
letter-spacing: 0;
|
|
454
|
+
}
|
|
455
|
+
.headline {
|
|
456
|
+
margin: 42px 0 28px;
|
|
457
|
+
font-size: 34px;
|
|
458
|
+
line-height: 1.22;
|
|
459
|
+
font-weight: 900;
|
|
460
|
+
letter-spacing: 0;
|
|
461
|
+
color: #f8fbff;
|
|
462
|
+
}
|
|
463
|
+
.type-label {
|
|
464
|
+
display: inline-flex;
|
|
465
|
+
align-items: center;
|
|
466
|
+
height: 28px;
|
|
467
|
+
margin-bottom: 18px;
|
|
468
|
+
padding: 0 11px;
|
|
469
|
+
border-radius: 6px;
|
|
470
|
+
background: rgba(74, 139, 255, 0.15);
|
|
471
|
+
color: #8cbfff;
|
|
472
|
+
border: 1px solid rgba(103, 159, 255, 0.22);
|
|
473
|
+
font-size: 13px;
|
|
474
|
+
font-weight: 700;
|
|
475
|
+
}
|
|
476
|
+
.content {
|
|
477
|
+
color: #d9e1f2;
|
|
478
|
+
font-size: 21px;
|
|
479
|
+
line-height: 1.72;
|
|
480
|
+
word-break: break-word;
|
|
481
|
+
}
|
|
482
|
+
.content h1,
|
|
483
|
+
.content h2,
|
|
484
|
+
.content h3 {
|
|
485
|
+
margin: 34px 0 14px;
|
|
486
|
+
line-height: 1.32;
|
|
487
|
+
color: #ffffff;
|
|
488
|
+
font-weight: 850;
|
|
489
|
+
letter-spacing: 0;
|
|
490
|
+
}
|
|
491
|
+
.content h1 {
|
|
492
|
+
font-size: 28px;
|
|
493
|
+
}
|
|
494
|
+
.content h2 {
|
|
495
|
+
font-size: 25px;
|
|
496
|
+
}
|
|
497
|
+
.content h3 {
|
|
498
|
+
font-size: 22px;
|
|
499
|
+
}
|
|
500
|
+
.content p {
|
|
501
|
+
margin: 0 0 18px;
|
|
502
|
+
}
|
|
503
|
+
.content strong {
|
|
504
|
+
color: #ffffff;
|
|
505
|
+
font-weight: 850;
|
|
506
|
+
}
|
|
507
|
+
.content a {
|
|
508
|
+
color: #7bb6ff;
|
|
509
|
+
text-decoration: none;
|
|
510
|
+
}
|
|
511
|
+
.content ul,
|
|
512
|
+
.content ol {
|
|
513
|
+
margin: 8px 0 20px;
|
|
514
|
+
padding-left: 28px;
|
|
515
|
+
}
|
|
516
|
+
.content li {
|
|
517
|
+
margin: 8px 0;
|
|
518
|
+
}
|
|
519
|
+
.content code {
|
|
520
|
+
display: inline-block;
|
|
521
|
+
max-width: 100%;
|
|
522
|
+
vertical-align: baseline;
|
|
523
|
+
padding: 2px 8px 4px;
|
|
524
|
+
border-radius: 5px;
|
|
525
|
+
background: rgba(67, 96, 144, 0.56);
|
|
526
|
+
color: #8bc5ff;
|
|
527
|
+
font-family: "JetBrains Mono", Consolas, "Courier New", monospace;
|
|
528
|
+
font-size: 0.78em;
|
|
529
|
+
line-height: 1.45;
|
|
530
|
+
}
|
|
531
|
+
.content pre {
|
|
532
|
+
margin: 22px 0 24px;
|
|
533
|
+
padding: 22px 24px;
|
|
534
|
+
border-radius: 8px;
|
|
535
|
+
background: rgba(3, 8, 19, 0.72);
|
|
536
|
+
border: 1px solid rgba(142, 171, 234, 0.18);
|
|
537
|
+
overflow: hidden;
|
|
538
|
+
}
|
|
539
|
+
.content pre code {
|
|
540
|
+
display: block;
|
|
541
|
+
padding: 0;
|
|
542
|
+
border-radius: 0;
|
|
543
|
+
background: transparent;
|
|
544
|
+
color: #d7e5ff;
|
|
545
|
+
font-size: 16px;
|
|
546
|
+
line-height: 1.65;
|
|
547
|
+
white-space: pre-wrap;
|
|
548
|
+
}
|
|
549
|
+
.footer {
|
|
550
|
+
display: flex;
|
|
551
|
+
align-items: end;
|
|
552
|
+
justify-content: space-between;
|
|
553
|
+
gap: 28px;
|
|
554
|
+
margin-top: 42px;
|
|
555
|
+
padding-top: 28px;
|
|
556
|
+
border-top: 1px solid rgba(170, 193, 255, 0.14);
|
|
557
|
+
}
|
|
558
|
+
.published-label {
|
|
559
|
+
color: #70809e;
|
|
560
|
+
font-size: 11px;
|
|
561
|
+
font-weight: 800;
|
|
562
|
+
letter-spacing: 0.8px;
|
|
563
|
+
}
|
|
564
|
+
.published-time {
|
|
565
|
+
margin-top: 8px;
|
|
566
|
+
font-size: 29px;
|
|
567
|
+
line-height: 1.1;
|
|
568
|
+
font-weight: 900;
|
|
569
|
+
color: #ffffff;
|
|
570
|
+
}
|
|
571
|
+
.site {
|
|
572
|
+
text-align: right;
|
|
573
|
+
min-width: 160px;
|
|
574
|
+
}
|
|
575
|
+
.site-name {
|
|
576
|
+
font-size: 17px;
|
|
577
|
+
font-weight: 850;
|
|
578
|
+
color: #ffffff;
|
|
579
|
+
}
|
|
580
|
+
.author {
|
|
581
|
+
margin-top: 6px;
|
|
582
|
+
color: #78b7ff;
|
|
583
|
+
font-size: 14px;
|
|
584
|
+
}
|
|
585
|
+
</style>
|
|
586
|
+
</head>
|
|
587
|
+
<body>
|
|
588
|
+
<article id="card">
|
|
589
|
+
<header class="topbar">
|
|
590
|
+
<div class="brand">
|
|
591
|
+
<div class="avatar">CS</div>
|
|
592
|
+
<div>
|
|
593
|
+
<div class="brand-title">${escapeHtml(config.brandName)}</div>
|
|
594
|
+
<div class="brand-subtitle">CS2 更新日志工具</div>
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
<div class="category">
|
|
598
|
+
<div class="category-title">${categoryTag}</div>
|
|
599
|
+
<div class="category-subtitle">${escapeHtml(categoryName)}</div>
|
|
600
|
+
</div>
|
|
601
|
+
</header>
|
|
602
|
+
<main>
|
|
603
|
+
<h1 class="headline">${escapeHtml(title)}</h1>
|
|
604
|
+
<div class="type-label">${escapeHtml(categoryName)}</div>
|
|
605
|
+
<section class="content">${rendered}</section>
|
|
606
|
+
</main>
|
|
607
|
+
<footer class="footer">
|
|
608
|
+
<div>
|
|
609
|
+
<div class="published-label">PUBLISHED AT</div>
|
|
610
|
+
<div class="published-time">${escapeHtml(publishedAt)}</div>
|
|
611
|
+
</div>
|
|
612
|
+
<div class="site">
|
|
613
|
+
<div class="site-name">${escapeHtml(config.siteName)}</div>
|
|
614
|
+
<div class="author">${escapeHtml(author)}</div>
|
|
615
|
+
</div>
|
|
616
|
+
</footer>
|
|
617
|
+
</article>
|
|
618
|
+
</body>
|
|
619
|
+
</html>`;
|
|
620
|
+
}
|
|
621
|
+
function findTargetBot(ctx, target) {
|
|
622
|
+
const bots = Array.from(ctx.bots || []);
|
|
623
|
+
return bots.find((bot) => {
|
|
624
|
+
if (target.platform && bot.platform !== target.platform)
|
|
625
|
+
return false;
|
|
626
|
+
if (target.selfId && bot.selfId !== target.selfId)
|
|
627
|
+
return false;
|
|
628
|
+
return true;
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
function resolveStatePath(ctx, stateFile) {
|
|
632
|
+
if (node_path_1.default.isAbsolute(stateFile))
|
|
633
|
+
return stateFile;
|
|
634
|
+
const baseDir = typeof ctx.baseDir === 'string' ? ctx.baseDir : process.cwd();
|
|
635
|
+
return node_path_1.default.resolve(baseDir, stateFile);
|
|
636
|
+
}
|
|
637
|
+
function getNewsLink(item) {
|
|
638
|
+
return item.url || `https://store.steampowered.com/news/app/${APP_ID}/view/${item.gid}`;
|
|
639
|
+
}
|
|
640
|
+
function formatDate(timestamp) {
|
|
641
|
+
if (!timestamp)
|
|
642
|
+
return '未知';
|
|
643
|
+
const date = new Date(timestamp * 1000);
|
|
644
|
+
const pad = (value) => value.toString().padStart(2, '0');
|
|
645
|
+
return [
|
|
646
|
+
date.getFullYear(),
|
|
647
|
+
'/',
|
|
648
|
+
pad(date.getMonth() + 1),
|
|
649
|
+
'/',
|
|
650
|
+
pad(date.getDate()),
|
|
651
|
+
' ',
|
|
652
|
+
pad(date.getHours()),
|
|
653
|
+
':',
|
|
654
|
+
pad(date.getMinutes()),
|
|
655
|
+
].join('');
|
|
656
|
+
}
|
|
657
|
+
function stripMarkdown(input) {
|
|
658
|
+
return input
|
|
659
|
+
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, ''))
|
|
660
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
661
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
662
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
663
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
|
664
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
665
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
666
|
+
.replace(/^>\s?/gm, '')
|
|
667
|
+
.trim();
|
|
668
|
+
}
|
|
669
|
+
function truncateText(input, maxLength) {
|
|
670
|
+
if (input.length <= maxLength)
|
|
671
|
+
return input;
|
|
672
|
+
return `${input.slice(0, maxLength).trimEnd()}\n...`;
|
|
673
|
+
}
|
|
674
|
+
function decodeHtmlEntities(input) {
|
|
675
|
+
return input.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, code) => {
|
|
676
|
+
const lower = String(code).toLowerCase();
|
|
677
|
+
if (lower === 'amp')
|
|
678
|
+
return '&';
|
|
679
|
+
if (lower === 'lt')
|
|
680
|
+
return '<';
|
|
681
|
+
if (lower === 'gt')
|
|
682
|
+
return '>';
|
|
683
|
+
if (lower === 'quot')
|
|
684
|
+
return '"';
|
|
685
|
+
if (lower === 'apos')
|
|
686
|
+
return '\'';
|
|
687
|
+
if (lower === 'nbsp')
|
|
688
|
+
return ' ';
|
|
689
|
+
if (lower.startsWith('#x'))
|
|
690
|
+
return String.fromCodePoint(Number.parseInt(lower.slice(2), 16));
|
|
691
|
+
if (lower.startsWith('#'))
|
|
692
|
+
return String.fromCodePoint(Number.parseInt(lower.slice(1), 10));
|
|
693
|
+
return entity;
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
function escapeHtml(input) {
|
|
697
|
+
return String(input)
|
|
698
|
+
.replace(/&/g, '&')
|
|
699
|
+
.replace(/</g, '<')
|
|
700
|
+
.replace(/>/g, '>')
|
|
701
|
+
.replace(/"/g, '"')
|
|
702
|
+
.replace(/'/g, ''');
|
|
703
|
+
}
|
|
704
|
+
function parseJsonObject(input) {
|
|
705
|
+
const cleaned = input
|
|
706
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
707
|
+
.replace(/\s*```$/i, '')
|
|
708
|
+
.trim();
|
|
709
|
+
try {
|
|
710
|
+
return JSON.parse(cleaned);
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
const start = cleaned.indexOf('{');
|
|
714
|
+
const end = cleaned.lastIndexOf('}');
|
|
715
|
+
if (start >= 0 && end > start) {
|
|
716
|
+
return JSON.parse(cleaned.slice(start, end + 1));
|
|
717
|
+
}
|
|
718
|
+
throw new Error('translation response is not valid JSON');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function isNodeError(error) {
|
|
722
|
+
return !!error && typeof error === 'object' && 'code' in error;
|
|
723
|
+
}
|
|
724
|
+
function formatError(error) {
|
|
725
|
+
if (error instanceof Error)
|
|
726
|
+
return error.stack || error.message;
|
|
727
|
+
return String(error);
|
|
728
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-cs2-update-log",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Koishi plugin for monitoring CS2 official Steam announcements and update logs.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"koishi",
|
|
17
|
+
"plugin",
|
|
18
|
+
"cs2",
|
|
19
|
+
"counter-strike",
|
|
20
|
+
"steam",
|
|
21
|
+
"qq"
|
|
22
|
+
],
|
|
23
|
+
"author": "BestBcz",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@koishijs/plugin-puppeteer": "^3.2.0",
|
|
27
|
+
"koishi": "^4.18.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@koishijs/plugin-puppeteer": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"markdown-it": "^14.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@koishijs/plugin-puppeteer": "^3.2.0",
|
|
39
|
+
"@types/markdown-it": "^14.1.2",
|
|
40
|
+
"@types/node": "^24.0.10",
|
|
41
|
+
"koishi": "^4.18.8",
|
|
42
|
+
"typescript": "^5.8.3"
|
|
43
|
+
},
|
|
44
|
+
"koishi": {
|
|
45
|
+
"description": {
|
|
46
|
+
"zh": "监控 CS2 官方 Steam 公告和更新日志,并推送到指定 QQ 群。",
|
|
47
|
+
"en": "Monitor official CS2 Steam announcements and update logs."
|
|
48
|
+
},
|
|
49
|
+
"service": {
|
|
50
|
+
"required": [
|
|
51
|
+
"http"
|
|
52
|
+
],
|
|
53
|
+
"optional": [
|
|
54
|
+
"puppeteer"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|